From 09f530382db4cfc75a68ff2f2dc6e1eca9333fa1 Mon Sep 17 00:00:00 2001 From: amittell Date: Sat, 11 Jul 2026 13:34:02 -0400 Subject: [PATCH 1/9] Harden governance and scheduler handoff --- .github/workflows/ci.yml | 40 +- .github/workflows/publish.yml | 20 + CHANGELOG.md | 24 +- MANIFEST-QUICK-REF.md | 18 +- README.md | 41 +- SECURITY.md | 27 + bin/agentcli.js | 13 +- docs/adoption.md | 7 +- docs/architecture.md | 33 +- docs/capabilities.md | 15 +- docs/conformance.md | 6 + docs/execution-identity.md | 125 +- docs/field-reference.md | 20 +- docs/guide-identity.md | 70 +- docs/guide-testing-stripe-identity-step-up.md | 38 +- docs/protocol.md | 96 +- docs/roadmap.md | 8 +- docs/runtime-integration-backlog.md | 25 +- docs/spec.md | 36 +- docs/versioning.md | 3 +- examples/ansible-ops.json | 9 +- examples/full-stack-deploy.json | 4 +- examples/oidc-service-auth.json | 2 +- examples/stripe-projects.json | 3 +- examples/vercel-ops.json | 18 +- package-lock.json | 18 +- package.json | 6 +- skills/manifest-authoring/SKILL.md | 20 +- src/apply.js | 126 +- src/approvals.js | 230 +++- src/audit.js | 75 +- src/authorization-proof/certificate.js | 288 +++- src/authorization-proof/detached-signature.js | 197 ++- src/authorization-proof/index.js | 101 ++ src/authorization-proof/jwt.js | 142 +- src/canonical.js | 43 + src/capabilities.js | 43 +- src/cli.js | 384 +++++- src/command.js | 115 +- src/compiler/openclaw-scheduler.js | 49 +- src/compiler/shared.js | 203 ++- src/compiler/standalone.js | 138 +- src/convert.js | 58 +- src/describe.js | 7 + src/errors.js | 92 ++ src/evidence/index.js | 41 + src/evidence/payload.js | 368 +++++- src/evidence/ssh.js | 177 ++- src/exec.js | 1165 ++++++++++------- src/home.js | 20 +- src/identity/entra-agent-id.js | 17 +- src/identity/file-bearer.js | 30 +- src/identity/index.js | 374 +++++- src/identity/oidc-client-credentials.js | 13 +- src/identity/oidc-token-exchange.js | 19 +- src/identity/session.js | 694 ++++++++-- src/identity/spiffe-jwt-svid.js | 775 +++++------ src/identity/stripe-api-key.js | 250 ++-- src/index.js | 49 +- src/init.js | 18 +- src/io.js | 111 +- src/jsonrpc.js | 236 +++- src/merge.js | 35 +- src/registry.js | 75 +- src/run.js | 52 +- src/runtime/openclaw-scheduler.js | 28 +- src/sandbox.js | 184 +-- src/scheduler-fields.js | 7 + src/schema.js | 488 +++++++ src/signing/ssh.js | 37 +- src/targets.js | 24 +- src/validate.js | 393 +++++- test/agentcli.test.js | 628 +++++---- test/approvals.test.js | 201 ++- test/cli-rpc-validation.test.js | 494 +++++++ test/exec-ordering.test.js | 382 ++++++ test/foundation.test.js | 271 ++++ test/identity-security.test.js | 448 +++++++ test/integration-scheduler.test.js | 4 +- test/proof-evidence.test.js | 642 +++++++++ test/run-workflow.test.js | 75 ++ test/sandbox.test.js | 152 +++ test/scheduler-conformance.test.js | 178 +++ 83 files changed, 9967 insertions(+), 2224 deletions(-) create mode 100644 src/canonical.js create mode 100644 src/errors.js create mode 100644 test/cli-rpc-validation.test.js create mode 100644 test/exec-ordering.test.js create mode 100644 test/foundation.test.js create mode 100644 test/identity-security.test.js create mode 100644 test/proof-evidence.test.js create mode 100644 test/sandbox.test.js create mode 100644 test/scheduler-conformance.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58159c6..0a43a9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,14 +10,50 @@ concurrency: cancel-in-progress: true jobs: - lint-test: + test-matrix: + name: test (Node ${{ matrix.node-version }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: ['22.13.0', '24.x'] + env: + OPENCLAW_SCHEDULER_REF: ac9ea643a8efc68e9f81c8d93125467ca62140b5 + SCHEDULER_PATH: ${{ runner.temp }}/openclaw-scheduler steps: - uses: actions/checkout@v5 + - name: Check out pinned openclaw-scheduler + uses: actions/checkout@v5 + with: + repository: amittell/openclaw-scheduler + ref: ac9ea643a8efc68e9f81c8d93125467ca62140b5 + path: openclaw-scheduler - uses: actions/setup-node@v5 with: - node-version: '22' + node-version: ${{ matrix.node-version }} cache: npm + cache-dependency-path: | + package-lock.json + openclaw-scheduler/package-lock.json + - name: Move scheduler fixture outside the agentcli source tree + run: mv openclaw-scheduler "$SCHEDULER_PATH" - run: npm ci + - name: Install and verify pinned scheduler runtime + run: | + cd "$SCHEDULER_PATH" + test "$(git rev-parse HEAD)" = "$OPENCLAW_SCHEDULER_REF" + npm ci + node bin/openclaw-scheduler.js --json capabilities > /dev/null - run: npm run lint - run: npm test + + lint-test: + name: lint-test + needs: test-matrix + if: ${{ always() }} + runs-on: ubuntu-latest + steps: + - name: Require every Node matrix job to pass + env: + MATRIX_RESULT: ${{ needs.test-matrix.result }} + run: test "$MATRIX_RESULT" = success diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 05e5eb3..fe49e68 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,13 +11,33 @@ permissions: jobs: test: runs-on: ubuntu-latest + env: + OPENCLAW_SCHEDULER_REF: ac9ea643a8efc68e9f81c8d93125467ca62140b5 + SCHEDULER_PATH: ${{ runner.temp }}/openclaw-scheduler steps: - uses: actions/checkout@v5 + - name: Check out pinned openclaw-scheduler + uses: actions/checkout@v5 + with: + repository: amittell/openclaw-scheduler + ref: ac9ea643a8efc68e9f81c8d93125467ca62140b5 + path: openclaw-scheduler - uses: actions/setup-node@v5 with: node-version: '24' cache: npm + cache-dependency-path: | + package-lock.json + openclaw-scheduler/package-lock.json + - name: Move scheduler fixture outside the agentcli source tree + run: mv openclaw-scheduler "$SCHEDULER_PATH" - run: npm ci + - name: Install and verify pinned scheduler runtime + run: | + cd "$SCHEDULER_PATH" + test "$(git rev-parse HEAD)" = "$OPENCLAW_SCHEDULER_REF" + npm ci + node bin/openclaw-scheduler.js --json capabilities > /dev/null - run: npm run lint - run: npm test diff --git a/CHANGELOG.md b/CHANGELOG.md index 73d9ad6..590c58a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## Unreleased (2026-07-11) + +- security: manual approvals now bind the canonical manifest and complete effective execution configuration, enforce `approver_scope` and `timeout_s`, reject unexpected unsigned records, and fail without writing a grant when signing fails +- security: approval checks now run before proof commands, provider calls, sandbox probes, credential materialization, signing, and all other live side effects +- security: `exec --dry-run` is now a static preview that performs no proof, provider, network, sandbox, signing, evidence, verification, or audit side effects +- security: JWT, detached-signature, and certificate authorization proofs require cryptographic verification and canonical manifest binding; `verify.required: false` no longer permits presence-only or claims-only success +- security: evidence uses a complete versioned canonical payload, persists the verification envelope, binds manifest and effective execution metadata, and detects cross-execution transplantation +- security: sandbox, allowed-path, and network restrictions fail closed when unavailable; child processes inherit only a small operational allowlist and require every other ambient variable to be explicitly declared or provider-materialized +- security: identity providers validate configuration before network access, enforce delegation and handoff capabilities, use safer endpoint and file handling, and clean up materialized credentials across failure paths +- validation: v0.2 nested objects reject unknown fields, provider-specific structural validation runs during manifest validation, and the default schema output is JSON Schema Draft 2020-12 with `--legacy` opt-in +- CLI and JSON-RPC: strict flag parsing rejects unknown, duplicate, missing-value, and misplaced flags; RPC responses use stable result/error envelopes and add read-only targets, paths, audit, approvals, and registry discovery methods +- execution: disabled tasks and branches are skipped by `agentcli run`; audit identifiers are collision-resistant and malformed audit lines are skipped with warnings +- conversion and merge: v0.1 conversion maps unverifiable legacy attestations to `method: "none"`; merge preserves all v0.2 profile collections and detects conflicting profile definitions +- scheduler: live capability values override static fallback values, handoff v3 preserves governed approval and output fields, auto-reject jobs compile disabled, and apply refuses inline `shell.env` or `shell.stdin` +- examples: repaired invalid runtime timeout placement and fail-closed proof and credential-cache declarations; all published JSON examples are validated in the test suite +- maintenance: minimum Node version is now 22.13.0 and CI also tests Node 24 with a pinned `openclaw-scheduler` integration checkout +- dependencies: pinned patched `brace-expansion` and `flatted` releases; `npm audit` reports no known vulnerabilities + ## 0.3.2 (2026-04-21) - fix: `verifyApprovalSignature` now performs a tamper check against the stored `signature.signed_payload`. Previously the canonical payload was rebuilt from the current grant but then discarded; post-sign edits to `approver`, `reason`, `expires_at`, or `task_hash` fields in `approvals.ndjson` would not be detected (the ssh provider only checks signature-against-signed_payload). The rebuilt payload is now compared to `signature.signed_payload` and divergence returns `verified: false` with reason "grant fields do not match signed payload (possible tampering)" @@ -16,8 +34,8 @@ ## 0.3.0 (2026-04-21) -- local approval gate enforcement in `agentcli exec`: tasks with `approval.policy: "manual"` refuse to execute unless a matching, unconsumed, unrevoked, unexpired approval record is present (`error_type: approval_required`) -- `approval.policy: "auto-reject"` refuses execution even when an approval record exists (`error_type: approval_auto_rejected`) +- local approval gate enforcement in `agentcli exec`: tasks with `approval.policy: "manual"` refuse to execute unless a matching, unconsumed, unrevoked, unexpired approval record is present (detailed `code: approval_required`; closed `error_type: validation_error`) +- `approval.policy: "auto-reject"` refuses execution even when an approval record exists (detailed `code: approval_auto_rejected`; closed `error_type: validation_error`) - approval grants are bound to a canonical task hash over `{workflow_id, task_id, shell.program, shell.args, shell.cwd, identity.ref, approval.policy, approval.risk_level}`; drift in any of those fields invalidates prior approvals - `--dry-run` bypasses the approval gate (no approval consumed, no gate enforced) - successful gated executions include `approval_used: {approval_id, approver, reason, risk_level, granted_at, expires_at, signature_verified, signature: {method, key_fingerprint}}` in both the result payload and the audit record @@ -28,7 +46,7 @@ - new `--approval-id ` flag on `exec` to target a specific pending grant when more than one matches - new append-only state file at `~/.agentcli/state/approvals.ndjson` (grant, consume, revoke events); path exposed by `agentcli paths` - new module `src/approvals.js` exports `grantApproval`, `listApprovals`, `findValidApproval`, `consumeApproval`, `revokeApproval`, `computeTaskApprovalHash`, `approvalPolicyRequiresApproval`, `approvalPolicyAutoRejects`, `verifyApprovalSignature` -- approval signature verification reuses existing ssh allowed-signers chain (`~/.agentcli/state/allowed_signers`); tampered grants are refused (`error_type: approval_signature_invalid`) +- approval signature verification reuses existing ssh allowed-signers chain (`~/.agentcli/state/allowed_signers`); tampered grants are refused with detailed `code: approval_signature_invalid` and closed `error_type: validation_error` - scope: local single-machine enforcement only; durable multi-actor cron-triggered approvals remain owned by openclaw-scheduler ## 0.2.2 (2026-04-08) diff --git a/MANIFEST-QUICK-REF.md b/MANIFEST-QUICK-REF.md index c2d7a5e..b01ad0f 100644 --- a/MANIFEST-QUICK-REF.md +++ b/MANIFEST-QUICK-REF.md @@ -79,13 +79,24 @@ Copy-paste patterns for common agentcli manifests. | `delivery.channel` | optional | `telegram`, etc. | | `delivery.to` | optional | Channel-specific target (chat ID) | | `reliability.overlap_policy` | optional | `skip`, `queue`, `allow` | +| `runtime.timeout_ms` | optional | Local or backend execution timeout in milliseconds | | `verify.shell` | optional | Post-completion verification command | -| `verify.required` | optional | When `true`, requires `public_key` or `jwks_uri` for jwt proofs | +| `authorization_proof_profiles[].verify.required` | optional | Verification policy; every non-`none` proof is verified regardless | | `authorization.request.include` | optional | Array of include fields for OPA request (`actor`, `step_up`) | | `subject.attributes` | optional | Actor metadata object (`org_id`, `on_behalf_of_user_id`, `delegation_grant_id`, `run_id`, `agent_id`, `verification_ref`, `verification_level`) | | `authorization_proof_profiles[].jwks_uri` | optional | JWKS endpoint URI for JWT key discovery and caching | | `authorization_proof_profiles[].public_key` | optional | Inline public key for JWT verification | +## Safety rules + +- `agentcli exec --dry-run` is a static preview. It performs no approval consumption, proof command, provider call, sandbox probe, credential materialization, signing, evidence, postcondition, or audit write. +- Manual grants are single-use and bind the canonical manifest plus the full effective execution configuration. Approver scope and timeout are enforced. Unexpected unsigned grants are rejected. +- `jwt`, `detached-signature`, and `certificate` proofs must verify cryptographically and bind the canonical manifest. Use `method: "none"` for an intentionally unverifiable declaration. +- Requested sandbox or network restrictions fail closed if the local host cannot enforce them. +- Child processes inherit only a small operational allowlist. Every other ambient variable requires explicit `shell.env` declaration or identity-provider materialization. +- `agentcli run` skips disabled tasks and their branches. +- Scheduler apply rejects inline `shell.env` and `shell.stdin`; durable credentials belong in runtime identity providers. + ## Session targets - **shell**: Runs a command. Fast, predictable. Use for scripts and pipelines. @@ -151,13 +162,14 @@ agentcli apply manifest.json --db scheduler.db --scheduler-prefix ./scheduler -- agentcli apply manifest.json --db scheduler.db --scheduler-prefix ./scheduler --adopt-by name agentcli exec manifest.json task-id # Run a task locally agentcli exec manifest.json task-id --approval-id # Target a specific pending approval -agentcli schema manifest # Machine-readable schema +agentcli schema manifest # Draft 2020-12 JSON Schema +agentcli schema manifest --legacy # Legacy agentcli descriptor agentcli describe commands --json # All CLI commands ``` ## Approvals (local gate) -Tasks with `approval.policy: "manual"` refuse to run via `agentcli exec` unless a matching, unconsumed approval record exists. Grants are ssh-signed, single-use, and bound to the exact task hash (`workflow_id`, `task_id`, `shell.program`, `shell.args`, `shell.cwd`, `identity.ref`, policy, risk level). `--dry-run` bypasses the gate. +Tasks with `approval.policy: "manual"` refuse to run via `agentcli exec` unless a matching, unconsumed approval record exists. Grants are signed by default, single-use, constrained by `approver_scope` and `timeout_s`, and bound to the canonical manifest and complete effective execution configuration. Unexpected unsigned grants fail; `--signer none` is the explicit unsigned mode. `--dry-run` is static and does not consume or enforce the gate. ```bash agentcli approve manifest.json task-id --by alex --reason "tuesday deploy" --ttl-s 3600 diff --git a/README.md b/README.md index fade4e6..3e58f68 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ AGENTCLI_SCHEDULER_DB=~/.openclaw/scheduler/scheduler.db \ agentcli inspect jobs --fields id,name,last_status ``` -Node 22.5.0 or newer is required. Scheduler inspection uses `node:sqlite`, which became stable in Node 23.4.0. +Node 22.13.0 or newer is required. The minimum is tested in CI alongside the current Node 24 line. ## Why Teams Use It @@ -128,6 +128,20 @@ Node 22.5.0 or newer is required. Scheduler inspection uses `node:sqlite`, which If you are trying to add controls to an existing loop, start with `agentcli` alone. Add `openclaw-scheduler` when you need durable runtime behavior. +### Security and execution guarantees + +- `exec --dry-run` is a static preview. It does not consume approvals, run proof commands, resolve providers, call token or policy endpoints, probe a sandbox, materialize credentials, sign evidence, execute postconditions, or write audit records. +- Manual approvals are single-use and bind the complete effective execution configuration, including the manifest digest, resolved profiles, command argument hashes, declared environment and stdin hashes, contract, verification, proof, evidence, and approval policy. `approver_scope` and `timeout_s` are enforced. Unexpected unsigned approval records are rejected; unsigned grants exist only when signing was explicitly disabled. +- Every non-`none` authorization proof is cryptographically verified and bound to the canonical manifest. JWT, detached-signature, and certificate declarations fail closed when their trust material or manifest binding is missing. +- Evidence uses a versioned canonical payload bound to the manifest, effective task, identity, command, result, and postcondition. Verification detects an envelope copied to another execution. +- Requested filesystem or network isolation fails closed when the host cannot enforce it. Use a container or a runtime with the needed isolation capabilities for production workloads. +- Child processes inherit only a small operational allowlist such as PATH, HOME, temporary-directory, locale, shell, user, timezone, terminal, and Windows equivalents. Every other ambient variable requires explicit `shell.env` declaration or identity-provider materialization. +- Never put credentials in `shell.args` or prompts. Arguments remain executable payload and may be visible in process listings; prompts may be persisted by a durable runtime. Use identity-provider env, file, or stdin materialization. +- `agentcli run` skips disabled roots, descendants, and failure handlers. It remains a local, non-durable shell DAG runner. +- Durable scheduler apply refuses `shell.env` and `shell.stdin` because persisting credential-bearing values in scheduler job records would cross the trust boundary. Use runtime identity materialization instead. + +Dry-run and governance inspection are deliberately separate. Use `identity resolve`, `identity validate-delegation`, `authorization-proof verify`, or `authorization evaluate` when you intentionally want a live, read-only governance check without spawning the target command. Those commands may read declared proof or credential sources and contact configured identity, JWKS, or policy endpoints; they do not generate execution evidence or audit records. + ## Better Together `agentcli` and `openclaw-scheduler` are complementary: @@ -155,7 +169,7 @@ For example, [flyctl-ops.json](examples/flyctl-ops.json) wraps a simple `flyctl ```bash agentcli validate examples/flyctl-ops.json -agentcli exec examples/flyctl-ops.json check-app-status --dry-run --identity-debug +agentcli identity resolve examples/flyctl-ops.json check-app-status agentcli compile examples/flyctl-ops.json --target openclaw-scheduler --explain ``` @@ -203,7 +217,7 @@ This works with any CLI that prints a credential to stdout: Vault, 1Password, AW Stripe Projects, Doppler, macOS Keychain, and others. See [stripe-projects.json](examples/stripe-projects.json) for a full example and [docs/guide-identity.md](docs/guide-identity.md#dynamic-credential-acquisition) for the -complete reference. +complete reference. Command-based proof acquisition is disabled during scheduler apply unless the caller explicitly enables it; `exec` runs a declared proof command only after any manual approval gate succeeds. ## Core Model @@ -306,6 +320,8 @@ Authorization proof verifies that the manifest itself was approved before execut Use `agentcli authorization-proof methods` to list available methods and `agentcli authorization-proof schema ` to inspect verifier metadata for a method. +`jwt`, `detached-signature`, and `certificate` are always cryptographic, manifest-bound checks. `verify.required: false` does not turn a non-`none` method into a claims-only or presence-only check. Use `method: "none"` when a declaration is intentionally informational and unverifiable. + ## Authorization Providers | Provider | Description | @@ -324,6 +340,8 @@ Use `agentcli authorization providers` to list registered providers and `agentcl Use `agentcli evidence providers` to list registered providers and `agentcli evidence schema ` to inspect provider metadata. +Evidence envelopes retain the complete versioned payload needed for later verification. The payload contains hashes and audit-safe descriptors, not raw credential values, stdin, stdout, or stderr. + ## Signing Providers `agentcli exec` and `agentcli run` use signing providers for execution attestations. @@ -339,7 +357,7 @@ Use `agentcli signing providers` to list the registered signing providers and th | `version` | Show package and manifest spec version. | | `init [--tool program] [--output path] [--workflow-id id] [--task-id id]` | Initialize agentcli home directory with starter manifests. | | `paths` | Show resolved agentcli home, manifest, output, state, and audit paths. | -| `schema [target]` | Emit JSON schema for manifest, workflow, task, schedulerJob, standalonePlan, rpcRequest, or rpcResponse. | +| `schema [target] [--legacy]` | Emit Draft 2020-12 JSON Schema for manifest, workflow, task, schedulerJob, standalonePlan, rpcRequest, or rpcResponse. `--legacy` opts into the older agentcli descriptor format. | | `describe [target]` | Describe manifest, workflow, task, targets, commands, or rpc surfaces as structured JSON. | | `targets` | List available compilation targets. | | `skill-path` | Print the path to the agentcli skill manifest for MCP tool registration. | @@ -366,6 +384,7 @@ Use `agentcli signing providers` to list the registered signing providers and th - It only executes tasks with `target.session_target: "shell"`. - It runs one workflow DAG locally from a selected scheduled root, or from every root when `--all-roots` is set. +- It skips every task whose effective `enabled` value is `false`, including roots, triggered descendants, and failure handlers. - It does not add queueing, retries, or durable state. Approval gates declared on tasks are enforced through the same local mechanism that `exec` uses. - Manifests that include `main` or `isolated` tasks still need a runtime adapter such as `openclaw-scheduler`. @@ -373,17 +392,17 @@ Use `agentcli signing providers` to list the registered signing providers and th | Command | Description | |---|---| -| `approve [--workflow id] [--by principal] [--reason text] [--ttl-s seconds] [--signer ssh\|none] [--signing-key path]` | Grant a single-use local approval for a gated task. Writes an ssh-signed record bound to the exact task hash to `~/.agentcli/state/approvals.ndjson`. | +| `approve [--workflow id] [--by principal] [--reason text] [--ttl-s seconds] [--signer ssh\|none] [--signing-key path]` | Grant a single-use local approval for a gated task. Writes a signed record bound to the complete effective execution hash to `~/.agentcli/state/approvals.ndjson`. | | `approvals list [--status pending\|consumed\|expired\|revoked\|all] [--workflow id] [--task id]` | List approval records with current status, approver, reason, and signature metadata. | | `approvals revoke [--by principal] [--reason text]` | Revoke a pending approval. | `agentcli exec` enforces `approval.policy` at runtime: -- `manual`: exec refuses (`error_type: approval_required`) unless a matching, unconsumed, unrevoked, unexpired approval record exists. The grant's task hash must match the exact task (workflow id, task id, `shell.program`, `shell.args`, `shell.cwd`, `identity.ref`, policy, risk level). Any drift invalidates prior grants. -- `auto-reject`: exec refuses unconditionally (`error_type: approval_auto_rejected`). Grants cannot override. +- `manual`: exec refuses (`code: approval_required`, `error_type: validation_error`) unless a matching, unconsumed, unrevoked, unexpired approval record exists. The grant binds the canonical manifest and complete effective execution configuration, including profiles, command inputs, identity, contract, verify, proof, evidence, and approval settings. Any bound drift invalidates prior grants. The approver must satisfy `approver_scope`, and grant lifetime cannot exceed `timeout_s`. +- `auto-reject`: exec refuses unconditionally (`code: approval_auto_rejected`, `error_type: validation_error`). Grants cannot override. - `auto-approve` or absent: exec proceeds without an approval record. -Approvals are single-use and consumed before `spawnSync` (fail-closed: a crashed execution still consumes the grant). `--dry-run` bypasses the gate without consuming anything. Successful gated executions include `approval_used` in both the result payload and the audit record. The local mechanism is single-machine; durable cron-triggered approvals remain owned by `openclaw-scheduler`. +Approvals are single-use and consumed before `spawnSync` (fail-closed: a crashed execution still consumes the grant). Unexpected unsigned records fail verification; `--signer none` is the only explicit unsigned mode. `--dry-run` is a static preview and does not consume or enforce the gate. Successful gated executions include `approval_used` in both the result payload and the audit record. The local mechanism is single-machine; durable cron-triggered approvals remain owned by `openclaw-scheduler`. ### Identity and Authorization @@ -453,7 +472,7 @@ Approvals are single-use and consumed before `spawnSync` (fail-closed: a crashed `agentcli serve` exposes the full command surface over stdio JSON-RPC 2.0. This is the preferred integration point for agent systems that need programmatic access without shell parsing. -The server emits an `agentcli.ready` notification on startup. Use `agentcli describe rpc` to inspect the machine-readable method and notification surface. +The server emits an `agentcli.ready` notification on startup. Use `agentcli describe rpc` to inspect the machine-readable method and notification surface. Read-only RPC methods include target and path discovery, sanitized audit reads, approval listing, and registry list/show. Successful replies use a JSON-RPC `result` envelope; failures use a JSON-RPC `error` envelope with machine-readable `data.code` and `data.error_type`. See [docs/protocol.md](docs/protocol.md) for the full protocol specification. @@ -462,7 +481,7 @@ See [docs/protocol.md](docs/protocol.md) for the full protocol specification. | Target | Description | |---|---| | `standalone` | Portable plan for authoring, validation, explanation, and protocol use. No durable runtime required. | -| `openclaw-scheduler` | Compiler target for the durable scheduler runtime. Supports runtime model policy, plan/read-only intent, output offload budgets, queue/approval/fan-out guardrails, and identity compilation. | +| `openclaw-scheduler` | Compiler target for the durable scheduler runtime. Apply uses live runtime capabilities when reported and conservative static fallback values otherwise. Governed root approvals, approver scopes, structured output, and v3 handoff fields require explicit runtime support. | ```bash # Compile for standalone use @@ -496,7 +515,7 @@ The converter applies safe defaults: - Trust level defaults to `supervised` - Delegation mode defaults to `none` - Cleanup policy defaults to `always` -- Attestation strings are mapped to authorization proof profiles with method detection (OIDC to `jwt`, SSH to `detached-signature`, cert to `certificate`) +- Legacy attestation strings are preserved as informational authorization proof profiles with `method: "none"`; conversion never invents missing cryptographic trust material For scheduler migration (adopting existing jobs), use `--adopt-by name` during the initial apply: diff --git a/SECURITY.md b/SECURITY.md index 8a1dfc8..a03655c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -16,17 +16,44 @@ Include: Security-sensitive areas include: - manifest input validation +- approval binding, signing, scope, expiry, and single-use consumption +- authorization proof trust material and manifest binding +- identity provider resolution and credential materialization +- sandbox, allowed-path, and network enforcement +- evidence payload binding and later verification - file output handling - runtime inspection surfaces - protocol parsing - sanitization behavior +`agentcli` is a governed control plane and local shell executor. It is not a durable scheduler or an operating-system security boundary. `openclaw-scheduler` owns durable dispatch, retries, delivery, and persistent runtime state. + +## Fail-Closed Boundaries + +- `exec --dry-run` is static and side-effect free with respect to approval records, proof commands, identity and authorization providers, network endpoints, sandbox probes, credential materialization, signing, evidence, post-execution verification, and audit files. +- A manual approval binds the canonical manifest and complete effective execution configuration. Approver scope and task timeout cap the grant. Unexpected unsigned records, invalid signatures, and any bound configuration drift are rejected. +- Every authorization proof method other than `none` requires cryptographic verification and a canonical manifest binding. Missing trust material is an error, even if `verify.required` is false. +- Evidence is a versioned, canonical envelope. Verification checks the execution and manifest binding so an envelope cannot be transplanted to another audit record. +- Requested sandbox or network restrictions are enforced or execution is refused. There is no permissive fallback when the host lacks an enforcement adapter. +- Child processes inherit only a small operational allowlist such as PATH, HOME, temporary-directory, locale, shell, user, timezone, terminal, and Windows equivalents. All other ambient variables are stripped unless explicitly declared by the task or materialized by its identity provider. +- Credentials must not be placed in `shell.args` or prompts. Arguments can appear in process listings and prompts can be stored in durable runtime records. Use identity-provider env, file, or stdin materialization. +- Durable scheduler compilation rejects inline `shell.env` and `shell.stdin`. Secrets must be resolved at dispatch through an identity provider instead of being persisted in a job record. +- Output, registry, audit, approval, temporary credential, and allowed-signer files use restrictive paths and permissions. Symlinked or otherwise unsafe destinations are refused. + +## Runtime Capability Trust + +When a scheduler reports capabilities, its live values are authoritative. Static target declarations are conservative fallback values only. Root approval gates, approver scopes, structured output, and scheduler handoff v3 fields require explicit runtime support. An operator who controls the scheduler binary, provider path, environment, or capability response controls that runtime trust boundary. + +For production isolation, run local shell execution inside a container or another operating-system boundary that can enforce the manifest's filesystem and network contract. Do not treat a manifest declaration by itself as isolation. + ## Expectations This project aims to be safe for agent-facing usage, which means: - rejecting unsafe control characters where practical - refusing unsafe write paths +- keeping raw secrets, stdin, stdout, and stderr out of canonical approval and evidence bindings +- preserving only audit-safe identity and command metadata in durable artifacts - preserving machine-readable error behavior Please report bypasses or unsafe edge cases. diff --git a/bin/agentcli.js b/bin/agentcli.js index e8b8e72..8d34efc 100755 --- a/bin/agentcli.js +++ b/bin/agentcli.js @@ -1,19 +1,24 @@ #!/usr/bin/env node import { runCli } from '../src/cli.js'; +import { normalizeError } from '../src/errors.js'; try { - const output = await runCli(process.argv.slice(2)); + const output = await runCli(process.argv.slice(2), { throwOnValidationFailure: true }); if (output) { process.stdout.write(`${output}\n`); } } catch (err) { - const errorType = err.validation ? 'validation_error' : (err.code || 'internal_error'); + const normalized = normalizeError(err); + const code = normalized.validation ? 'validation_error' : normalized.code; + const errorType = normalized.validation ? 'validation_error' : normalized.error_type; process.stderr.write(JSON.stringify({ ok: false, - error: err.message, + error: normalized.message, error_type: errorType, - ...(err.validation ? { validation: err.validation } : {}) + code, + ...(normalized.validation ? { validation: normalized.validation } : {}), + ...(normalized.cleanup_warnings ? { cleanup_warnings: normalized.cleanup_warnings } : {}), }, null, 2) + '\n'); process.exit(1); } diff --git a/docs/adoption.md b/docs/adoption.md index 88fab7c..b184503 100644 --- a/docs/adoption.md +++ b/docs/adoption.md @@ -15,7 +15,7 @@ If you own both products, the clean story is: - `agentcli` is the control plane for workflow authoring, identity, validation, local execution, local approval gates for direct `exec`, and discovery - `openclaw-scheduler` is the durable runtime for schedule execution, retries, cron-triggered approval queues, delivery, and persistent state -- the same manifest can be authored and tested in `agentcli`, then compiled and applied into `openclaw-scheduler`; approval declarations (`approval.policy`, `approval.risk_level`) are honored by both layers +- the same manifest can be authored and tested in `agentcli`, then compiled and applied into `openclaw-scheduler`; local approval declarations are enforced by `exec`, while durable enforcement is accepted only when the scheduler explicitly advertises the required gate, scope, and handoff capabilities That means users do not have to choose between them. @@ -40,6 +40,7 @@ Use `agentcli` to: - validate manifests - compile standalone plans - expose schema and describe to agents +- run governed shell tasks and shell-only DAGs locally without adding a durable runtime This is the lowest-friction entry point. @@ -60,6 +61,8 @@ Use `openclaw-scheduler` to: - manage retries, approvals, delivery, and queue state - store runtime state in SQLite +`agentcli apply` treats live scheduler capability values as authoritative and falls back to conservative static declarations only when a value is unavailable. It rejects jobs that require unsupported root approval gates, approver scopes, structured output formats, trust, authorization, proof, evidence, or credential handoff. It also rejects inline `shell.env` and `shell.stdin`; durable secrets must be resolved at dispatch through an identity provider. + Current reference example: ```bash @@ -188,7 +191,7 @@ The main current risks are: - the standard is still draft - only one production-grade runtime adapter exists today -- some backend-specific areas, especially approvals, still need richer negotiation +- runtime compatibility depends on explicit capability negotiation, especially for root approvals, approver scope, structured output, and handoff v3 ### Avoiding heavy single-prompt jobs diff --git a/docs/architecture.md b/docs/architecture.md index 0f119de..faa2774 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -20,6 +20,7 @@ - scheduler inspection surface - machine-readable CLI - stdio JSON-RPC server +- non-durable local shell execution for `exec` and `run` - local approval gate for direct `exec` (single-use ssh-signed grants; no queue, no cron coupling, no multi-actor routing) The local gate and the scheduler's durable gate coexist: both honor the same `approval.policy` and `approval.risk_level` declarations in the manifest. `agentcli exec` enforces the gate for single-machine invocations using `~/.agentcli/state/approvals.ndjson`; `openclaw-scheduler` enforces the gate for cron-triggered durable execution using its own approval queue. @@ -53,7 +54,7 @@ The identity architecture separates concerns into six distinct layers: Four separate provider registries serve distinct concerns: - **Identity provider registry** -- resolves credentials for declared identity profiles (`none`, `env-bearer`, `oidc-client-credentials`, `oidc-token-exchange`, and future enterprise providers). -- **Authorization proof verifier registry** -- validates manifest-time authorization proof (`jwt`, `certificate`, `signature`). +- **Authorization proof verifier registry** -- validates manifest-time authorization proof (`jwt`, `certificate`, `detached-signature`, `none`). - **Evidence provider registry** -- generates and verifies post-execution attestation (`ssh`, `none`). Conceptual successor to the v0.1 signing provider. - **Authorization provider registry** -- dispatches per-action authorization to external policy engines (`opa`). @@ -63,23 +64,21 @@ Each provider file auto-registers with its registry on import (side-effect regis `agentcli exec` runs the following pipeline for v0.2 manifests: -- **Phase 1: Manifest Loading + Authorization Proof Verification** -- load, expand shorthands, validate schema, verify manifest authorization proof when declared. -- **Phase 2: Identity Resolution** -- resolve profile references, merge workflow and task overrides (three-stage merge), validate delegation chains, resolve credential session, evaluate trust level. Async for providers that call external token endpoints. -- **Phase 3: Presentation Materialization** -- materialize credentials per declared bindings (env vars, temp files, stdin payload). -- **Phase 3.5: Credential Handoff** (optional) -- when the executing runtime exposes an explicit downstream handoff boundary, prepare a derived credential (downscoped or transaction-scoped). Fails closed if required but unsupported. -- **Phase 4: Contract Evaluation + Trust Enforcement** -- evaluate execution boundaries. When `required_trust_level` is declared, compare against the resolved trust level. Enforcement modes: `none` (log only), `advisory` (warn and continue), `strict` (escalate or fail closed). -- **Phase 4.5: Authorization** (optional) -- invoke external policy engine (OPA, Cedar, Topaz) when an authorization block is configured. Decisions: `permit`, `deny`, `require-escalation`. Skipped entirely when no authorization block resolves for the task. -- **Phase 5: Execution** -- run the tool, capture stdout/stderr/exit code/duration, compute hashes. -- **Phase 6: Evidence Generation** -- build canonical evidence payload, attest execution, verify evidence if required. -- **Phase 6.5: Post-exec Verify** (optional) -- run `workflow.verify` / `task.verify` after a successful command. This is an operator-local postcondition check recorded separately from evidence; verify failures can still fail the task or downgrade to warnings according to `verify.on_failure`. -- **Phase 7: Audit** -- write structured append-only audit record with declared/resolved identity, authorization proof summary, delegation chain, trust level, authorization decision, and runtime instance attribution. -- **Phase 8: Cleanup** -- delete temporary files, destroy ephemeral materialization and derived handoff credentials. +- **Phase 1: Static preparation**: load, expand, validate, resolve the selected task, and compute the canonical manifest digest and secret-safe effective execution binding. +- **Phase 2: Approval gate**: enforce `auto-reject` or atomically consume a matching manual grant before any live side effect. The grant binds the complete effective configuration, scope, and timeout. +- **Phase 3: Runtime boundary preparation**: resolve the signing provider and require the requested sandbox, allowed-path, and network enforcement. Missing enforcement fails closed. +- **Phase 4: Manifest authorization proof**: validate proof configuration, acquire the proof value, and cryptographically verify every non-`none` method against the canonical manifest. +- **Phase 5: Identity resolution and presentation**: validate the provider before network access, resolve the session and delegation chain, materialize declared bindings, and prepare any supported handoff. Required caching, refresh, or handoff capabilities fail closed when unavailable. +- **Phase 6: Trust and authorization**: enforce the trust floor and invoke the configured authorization provider. Deny, unknown, and unsupported escalation outcomes fail closed unless the manifest explicitly selects an advisory policy. +- **Phase 7: Execution**: run the tool with a sanitized child environment, capture the result, and calculate audit-safe hashes. +- **Phase 8: Postcondition and evidence**: run `workflow.verify` or `task.verify` after a successful command, then build and verify the complete versioned evidence envelope so the postcondition is part of the binding. +- **Phase 9: Audit and cleanup**: append an audit-safe record according to policy and clean up materialized and handoff credentials on success or failure. -### v0.1/v0.2 Dual Path +`exec --dry-run` stops after static preparation and returns a plan whose live phases are marked `skipped`. It does not consume an approval, execute proof commands, resolve providers, contact a network endpoint, probe a sandbox, materialize credentials, sign or verify evidence, run postconditions, or write audit records. -`exec.js` detects the manifest version (`manifest.version === '0.2' || Boolean(manifest.identity_profiles)`) and dispatches to entirely separate code paths. The v0.1 path (`executeTaskV1`) is preserved unchanged -- synchronous, no identity providers, original signing flow. The v0.2 path (`executeTaskV2`) is async and runs the full lifecycle above. +### v0.1/v0.2 Dual Path -This dual-path design guarantees zero behavioral change for v0.1 manifests. `src/signing/` is preserved for v0.1; `src/evidence/` exists alongside it for v0.2 without cross-coupling. +`exec.js` detects the manifest version (`manifest.version === '0.2' || Boolean(manifest.identity_profiles)`) and dispatches to separate execution paths. The v0.1 path remains synchronous and uses the signing-provider flow. The v0.2 path is asynchronous and adds proof, identity, authorization, and evidence providers. Both paths share the static dry-run contract, approval-before-side-effects ordering, canonical execution binding, child-environment sanitization, and safe audit metadata. ### Standards Alignment @@ -100,7 +99,7 @@ The credential flow traces through four control surfaces: 1. **Operator provisions** -- credentials enter the system via env vars, Vault, managed identity, or files. The operator controls `SCHEDULER_PROVIDER_PATH` and the scheduler's execution environment. -2. **Scheduler resolves** -- at dispatch time, the scheduler calls the +2. **Scheduler resolves** -- when the selected runtime explicitly advertises the required capabilities, at dispatch time it calls the identity provider to resolve a credential session. Trust evaluation and authorization gates run before any credential is materialized. 3. **Provider narrows** -- when `child_credential_policy` is `downscope`, @@ -113,7 +112,7 @@ The credential flow traces through four control surfaces: For agent tasks, auth-profile forwarding directs the gateway to use the appropriate profile. -**Trust boundary definition:** the operator controls the scheduler env +**Trust boundary definition:** the operator controls the scheduler binary, capability response, env and provider directory. Everything downstream narrows only. A child MUST NOT receive broader credentials than its parent. If the provider directory or scheduler env is compromised, the trust model is broken -- diff --git a/docs/capabilities.md b/docs/capabilities.md index 7710790..a1d5a26 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -46,7 +46,9 @@ Runtime capabilities: - `capabilities`: a string array of active capability identifiers (matching the control-plane and inspection groups above, using dash-delimited names like `field-mask`) - `features`: a keyed object where each key corresponds to an execution-shape or runtime capability (using underscore-delimited names like `model_policy`, `execution_intent`) and each value describes the support level. Valid values: `"portable"`, `"runtime"`, `"model+thinking"`, `"intent-only"`, `true`, or `false`. The `"model+thinking"` value indicates the target compiles model policy into separate model and thinking fields for the backend. `true` indicates full runtime support; `false` indicates the feature is not supported. -Feature keys: `approvals`, `model_policy`, `execution_intent`, `output_hints`, `timeout_support`, `context_retrieval`, `runtime_execution`. +Feature keys include `approvals`, `model_policy`, `execution_intent`, `output_hints`, `timeout_support`, `context_retrieval`, `runtime_execution`, `identity_declaration`, `runtime_identity_resolution`, `trust_evaluation`, `delegation_validation`, `credential_handoff`, `authorization_proof_verification`, `authorization_hook`, `evidence_generation`, `audit_export`, `root_approval_gate`, `approval_scope_enforcement`, and `structured_output_format`. + +During scheduler apply, a live runtime value is authoritative for every known feature it reports, including `false`. Static target values are used only when the capability command is unavailable or omits a known key. This prevents a stale optimistic declaration from overriding the runtime's observed behavior. ## Current Target Matrix @@ -74,7 +76,7 @@ Does not provide: Provides in `agentcli exec` (single-machine, non-durable): -- `approval-gates` (local enforcement): refuses execution of tasks whose `approval.policy` is `manual` without a matching, unconsumed, unrevoked, unexpired ssh-signed grant in `~/.agentcli/state/approvals.ndjson`; refuses unconditionally when `policy` is `auto-reject` +- `approval-gates` (local enforcement): refuses execution of tasks whose `approval.policy` is `manual` without a matching, unconsumed, unrevoked, unexpired signed grant in `~/.agentcli/state/approvals.ndjson`; enforces the complete execution binding, approver scope, and timeout; refuses unconditionally when `policy` is `auto-reject` Interpretation: @@ -82,6 +84,8 @@ Interpretation: - durable multi-actor and cron-triggered approval flows still require a runtime target such as `openclaw-scheduler` - plan/read-only intent is preserved in the compiled plan - output hints and budgets are preserved for another backend or consumer +- the standalone artifact hashes or removes raw shell environment, stdin, provider configuration, proof literals or commands, and other credential-bearing material +- `exec --dry-run` is static and marks every live phase skipped ### `openclaw-scheduler` @@ -99,7 +103,7 @@ Provides through compile or inspection: - `timeout-support` - `context-retrieval` -Provides in the runtime itself: +May be provided by the runtime itself and must be confirmed through capability negotiation: - `runtime-execution` - `durability` @@ -114,6 +118,11 @@ Interpretation: - plan/read-only intent compiles into runtime execution-boundary fields - output hints compile into scheduler output preview/offload budgets - queue, approval, and fan-out budgets compile into runtime guardrails +- handoff version 3 is required to preserve approval risk, approver scope, and output format fields +- root manual approvals require `root_approval_gate`; approver scopes require `approval_scope_enforcement`; output formats require `structured_output_format` +- `auto-reject` jobs compile disabled so an older dispatcher cannot accidentally run them +- inline `shell.env` and `shell.stdin` are rejected because scheduler persistence is not a secret store +- proof, identity, authorization, trust, evidence, and credential-handoff requirements are checked against the effective runtime feature map before apply ## Why This Matters diff --git a/docs/conformance.md b/docs/conformance.md index 8dcc7b1..9c62254 100644 --- a/docs/conformance.md +++ b/docs/conformance.md @@ -11,8 +11,10 @@ This document defines what it means to be compatible with the `agentcli` draft s Must: - parse manifest version `0.1` +- parse manifest version `0.2` - validate required structure - reject invalid task invocation modes +- reject unknown nested v0.2 fields and structurally invalid provider profiles Should: @@ -25,6 +27,7 @@ Must implement everything in Profile A and also: - expose schema access - expose validation - support at least one compile target +- emit JSON Schema Draft 2020-12 by default when advertising JSON Schema conformance Should: @@ -42,6 +45,7 @@ Must implement everything in Profile B and also: - implement `agentcli.describe` - implement `agentcli.validate` - implement `agentcli.compile` +- use JSON-RPC result envelopes for success and error envelopes with machine-readable `data.code` and `data.error_type` for failure ### Profile D: Runtime Adapter @@ -50,12 +54,14 @@ Must: - accept a valid manifest-derived compile output - document backend-specific constraints - preserve task ordering and trigger semantics +- expose or document an explicit capability map for security-relevant runtime behavior Should: - expose a manifest apply or upsert path - expose runtime inspection - document delivery, retry, and approval behavior separately from the core manifest +- fail closed when a compiled contract requires an enforcement capability the runtime does not advertise ## Reference Implementation diff --git a/docs/execution-identity.md b/docs/execution-identity.md index 3ebe2e2..ae53912 100644 --- a/docs/execution-identity.md +++ b/docs/execution-identity.md @@ -4,21 +4,21 @@ This document describes the execution identity architecture for `agentcli` manifest spec `0.2`. -Implementation status as of 2026-03-23: +Implementation status as of 2026-07-11: -- Phases 1-6 are complete +- The local execution pipeline is complete from static binding through approval, proof, identity, authorization, sandbox enforcement, execution, postcondition, evidence, audit, and cleanup - All eleven identity providers are shipped: none, env-bearer, file-bearer, oidc-client-credentials, oidc-token-exchange, azure-managed-identity, aws-sts-assume-role, gcp-workload-identity, spiffe-jwt-svid, entra-agent-id, stripe-api-key - Authorization providers: none, opa - Evidence providers: none, ssh - Authorization proof verifiers: none, jwt, detached-signature, certificate - All provider targets from the original spec are implemented, including `entra-agent-id` -- v0.1 backward compatibility is preserved: v0.1 manifests execute through the original code path unchanged +- v0.1 compatibility is preserved through its synchronous execution path, with shared security guarantees for static dry-run, approval ordering, child-environment sanitization, and audit-safe command metadata This spec is normative for `0.2` and backward-compatible with `0.1`. ## Problem -`agentcli` currently has the beginnings of an identity model: +`agentcli` provides an execution identity model built around: - `identity` fields on workflows and tasks - `contract` fields on workflows and tasks @@ -26,16 +26,7 @@ This spec is normative for `0.2` and backward-compatible with `0.1`. - attestation signing - append-only audit records -That is directionally correct, but the current architecture is still incomplete. - -Current limitations: - -- `identity` is mostly declarative metadata, not a first-class runtime abstraction -- signing providers are treated as the main identity extension point, even though signing and authentication are different concerns -- manifest-time identity declaration and execution-time proof are partially conflated -- credential acquisition is not modeled generically -- credential presentation to tools is not modeled explicitly -- compile targets do not yet preserve enough structured identity intent for richer runtimes +The implementation separates identity providers, authorization proof verifiers, authorization providers, signing providers, and evidence providers. It validates provider profiles before network access, materializes credentials only after approval, capability-gates caching, refresh, delegation, and handoff, and cleans up provider artifacts on success or failure. Compile targets preserve audit-safe identity intent while removing raw provider inputs and credential values. This matters because agent workflows increasingly need: @@ -94,7 +85,7 @@ The IETF published `draft-klrc-aiagent-auth-00` in March 2026, defining the Agen - Prefer structured machine-readable data over prose. - Never require raw secrets in manifests. - Make audit and evidence first-class outputs, not side effects. -- Make dangerous fallbacks explicit. +- Reject dangerous or unenforceable fallbacks by default. - Compose existing standards rather than inventing new protocols. The IETF AIMS framework validates this: "No new authentication protocol is needed specifically for AI agents." - Distinguish stable subject identity, runtime instance attribution, and per-run execution identifiers. `execution_id` is universal; instance attribution is additional runtime metadata when available. - Model delegation chains explicitly so that authority provenance is traceable and auditable. @@ -421,8 +412,8 @@ An identity profile defines subject intent, authentication requirements, and cre ], "audience": null, "resource": null, - "cache": "memory", - "refresh": "auto", + "cache": "none", + "refresh": "never", "required": true, "delegation_policy": { "max_depth": 3, @@ -556,6 +547,8 @@ Rules for `auth.cache`: - cached sessions MUST still be validated against the current manifest profile before reuse; if the profile has changed, the cache entry MUST be invalidated - `auth.cache` defaults to `none` when not specified +The local shell executor advertises no credential cache, refresh, or downstream handoff boundary. Local profiles therefore use `cache: "none"`, `refresh: "never"`, and `handoff: "none"`; requesting more fails closed. A durable runtime may support other values only when its capability response and selected provider both advertise them. + Proposed enums for `auth.refresh`: - `never` -- the credential session is used as-is until it expires; no refresh is attempted even if the provider supports it @@ -816,7 +809,7 @@ Each authorization proof profile MUST contain: Proposed enums for `method`: -- `jwt` -- the proof is a signed JWT; verification checks the signature against the declared `issuer`'s JWKS or a configured public key. When `verify.required` is `true`, the profile MUST provide `jwks_uri` or `public_key`. +- `jwt` -- the proof is a signed JWT; verification checks the signature against the declared `issuer`'s JWKS or a configured public key, requires `jwks_uri` or `public_key`, and binds a canonical manifest digest claim - `detached-signature` -- the proof is a detached signature over the manifest payload (or a specified subset); verification checks the signature against a configured public key or allowed_signers file - `certificate` -- the proof is a certificate chain; verification checks the chain against a configured trust anchor - `none` -- no proof is attached; this is valid only when `verify.required` is `false` and exists for development or opt-out scenarios @@ -838,15 +831,16 @@ The valid claim names depend on the `method`: - for `detached-signature`: claims are not applicable; if present, they are ignored - for `certificate`: `subject` (DN or SAN), `issuer` (CA DN); the verifier SHOULD validate declared claims against the certificate fields -Custom claims (e.g., `workflow_scope`) are method-specific and validated by the verifier. Unknown claims that the verifier cannot validate SHOULD cause a warning, not a hard failure, unless `verify.required` is `true` and the verifier's policy treats unknown claims as errors. +Custom claims (e.g., `workflow_scope`) are method-specific and validated by the verifier. A declared claim that the verifier cannot validate MUST NOT be treated as verified. Rules: - authorization proof profiles describe manifest-time proof only; they MUST NOT be used for runtime credential acquisition - authorization proof values MAY use `value_from` indirection and MUST NOT require inline raw secrets in the manifest -- `verify.required: true` means the execution stage responsible for verification MUST reject the execution unit if the proof cannot be verified; this is local `exec`, `apply` for backends that lack `authorization_proof_verification`, or a capable backend runtime -- authorization proof verification happens before execution-time identity resolution -- for local `exec`, authorization proof verification occurs during Phase 1 +- every method other than `none` MUST verify cryptographically and bind the canonical manifest, regardless of `verify.required`; missing trust material or binding rejects the execution unit +- `method: "none"` is the explicit representation for an informational or unverifiable legacy declaration +- authorization proof verification happens after the local approval gate and before execution-time identity resolution +- for local `exec`, authorization proof verification occurs during Phase 1 after Phase 0.5 approval - for backend targets, verification occurs at execution time only when the target advertises `authorization_proof_verification`; otherwise `apply` MUST verify each resolved execution unit's proof before handoff and MUST persist only audit-safe verification summaries bound to the corresponding manifest digest and execution-unit scope - compiled artifacts and persisted backend specs MUST NOT embed raw authorization proof values unless the target explicitly models secure proof retrieval and verification - authorization proof metadata included in audit records MUST be audit-safe and MUST NOT expose raw tokens or detached signature payloads @@ -1232,6 +1226,8 @@ When `auth.cache` is `state`, the same reuse rules apply across workflow runs, w When `auth.cache` is `none`, a fresh session is always resolved. This is the safest default and SHOULD be used when tasks have different scopes or audiences even if they reference the same profile. +The reuse rules above apply only to runtimes that explicitly advertise credential caching. The local `agentcli exec` and `run` paths do not cache sessions. + ### Merge Rules - scalar values replace parent values @@ -1615,7 +1611,23 @@ The current `ssh` and `none` signing providers map cleanly to this model. ## Execution Lifecycle -`agentcli exec` should evolve into the following pipeline. +`agentcli exec` implements the following pipeline. + +### Phase 0: Static Preparation + +- load and validate the manifest +- expand shorthands and select the workflow and task +- compute the canonical manifest digest and secret-safe effective execution binding +- return immediately for `--dry-run`, with every live phase marked `skipped` + +A dry run does not consume an approval, run proof commands, resolve providers, contact network endpoints, probe a sandbox, materialize credentials, sign evidence, run postconditions, or write audit records. + +### Phase 0.5: Approval and Runtime Boundary + +- reject `auto-reject` tasks or atomically consume a matching manual approval +- verify approval signature, complete effective execution hash, approver scope, and timeout +- resolve signing configuration and require requested sandbox, path, and network enforcement +- complete this phase before proof commands, provider calls, or credential work ### Phase 1: Manifest Loading @@ -1654,6 +1666,7 @@ The current `ssh` and `none` signing providers map cleanly to this model. - evaluate allowed paths - evaluate network and sandbox expectations +- fail closed when a restrictive boundary is requested but unavailable - evaluate audit policy - when `contract.required_trust_level` is present, evaluate the resolved trust level against it before execution - when trust matches the contract floor, continue normally @@ -1698,7 +1711,7 @@ Credentials are resolved in Phase 2 and materialized in Phase 3. Once a tool beg This is a known limitation for local exec with long-running tasks. Mitigations: - operators SHOULD ensure credential lifetimes exceed expected task duration -- for tasks with unpredictable duration, providers SHOULD issue credentials with generous lifetimes or operators SHOULD use `auth.refresh: "auto"` so that pre-execution refresh extends the window +- for tasks with unpredictable duration, providers SHOULD issue credentials with sufficient lifetimes; a durable runtime MAY use `auth.refresh: "auto"` only when both runtime and provider advertise refresh support - `agentcli` records `credentials.expires_at` in the audit record so that operators can detect tasks that ran past credential expiry - future runtime backends MAY implement mid-execution refresh when the backend models long-lived sessions natively @@ -1709,28 +1722,23 @@ This is a known limitation for local exec with long-running tasks. Mitigations: - compute command and result hashes - parse structured output when requested -### Phase 6: Evidence - -- build canonical evidence payload -- collect compliance context metadata if configured (model version, policy version, tool versions) -- attest the execution if configured -- verify evidence if required by policy - -Evidence verification occurs after execution (Phase 5) has already completed. A verification failure does not undo execution. Instead: - -- `evidence.verified` is set to `false` in the audit record -- when `verify.required` is `true` in the evidence profile, a verification failure causes `agentcli exec` to return a non-zero exit code even if the tool itself succeeded; the audit record includes the tool's actual result alongside the verification failure -- when `verify.required` is `false`, a verification failure is recorded as a warning but does not affect the exit code -- the evidence envelope (including the failed verification status) is always written to the audit record so that operators can investigate - -### Phase 6.5: Post-execution Verify +### Phase 6: Post-execution Verify - only enter this phase when the main command exited successfully and a workflow/task `verify` block resolves - run the declared verify shell in the task's effective execution context -- treat `verify` as an operator-local postcondition separate from evidence attestation; the attested evidence payload reflects the main command result, not the later verify shell outcome +- record the postcondition outcome for the subsequent evidence binding - when `verify.on_failure` is `error`, return a non-zero status after cleanup and audit - when `verify.on_failure` is `warn`, record the verify failure as a warning without changing the exit code +### Phase 6.5: Evidence + +- build a complete versioned canonical evidence payload after post-execution verification +- bind the manifest digest, effective task hash, execution id, audit-safe identity and command metadata, result, and postcondition +- exclude raw credentials, stdin, stdout, and stderr +- collect configured compliance context, attest, and verify the envelope +- reject required evidence that cannot be produced or verified and record the failure safely +- detect payload changes and an envelope transplanted to another execution + ### Phase 7: Audit - write append-only audit record @@ -1741,6 +1749,7 @@ Evidence verification occurs after execution (Phase 5) has already completed. A - include trust level and authorization decision - include runtime instance attribution when available - include handoff mode +- include the complete evidence envelope needed for later independent verification ### Phase 8: Cleanup @@ -1905,7 +1914,7 @@ Rules for resolution failures: ### Authorization Proof Failure Records -When manifest authorization proof verification (Phase 1) fails and `verify.required` is `true`, the runtime MUST reject the manifest before identity resolution begins. A failure record SHOULD still be written to audit: +When a non-`none` manifest authorization proof fails verification in Phase 1, the runtime MUST reject the task before identity resolution begins. A failure record SHOULD still be written to audit according to the audit policy: ```json { @@ -1930,10 +1939,10 @@ When manifest authorization proof verification (Phase 1) fails and `verify.requi Rules: -- when authorization proof verification fails with `verify.required: true`, all subsequent phases (2-8) are skipped except audit (Phase 7) +- when a non-`none` authorization proof fails verification, all subsequent live phases are skipped except audit and cleanup - `declared_identity` is `null` because identity resolution never began - the `authorization_proof.error` field contains a human-readable reason that MUST NOT expose raw proof values -- when `verify.required` is `false` and verification fails, the failure is recorded as a warning and execution proceeds normally +- `verify.required: false` does not weaken a cryptographic method; only `method: "none"` opts out of cryptographic verification ### Audit Rules @@ -2013,7 +2022,7 @@ It MUST NOT: ### Target Capability Model -Future target capabilities SHOULD distinguish: +Target capabilities distinguish: - `identity_declaration` - `runtime_identity_resolution` @@ -2024,10 +2033,13 @@ Future target capabilities SHOULD distinguish: - `credential_handoff` - `authorization_proof_verification` - `authorization_hook` +- `root_approval_gate` +- `approval_scope_enforcement` +- `structured_output_format` ## CLI Design -### Proposed Commands +### Current Commands - `agentcli authorization-proof methods` - `agentcli authorization-proof schema ` @@ -2064,7 +2076,7 @@ No CLI flag should elevate or directly override the resolved trust level for an ## JSON-RPC Design -### Proposed Methods +### Current Methods - `agentcli.authorizationProof.methods` - `agentcli.authorizationProof.schema` @@ -2078,7 +2090,14 @@ No CLI flag should elevate or directly override the resolved trust level for an - `agentcli.authorization.evaluate` - `agentcli.evidence.providers` - `agentcli.evidence.schema` -- `agentcli.exec` +- `agentcli.targets` +- `agentcli.paths` +- `agentcli.audit` +- `agentcli.approvals.list` +- `agentcli.registry.list` +- `agentcli.registry.show` + +See [protocol.md](protocol.md) for the authoritative parameter and envelope definitions. - `agentcli.audit` ### Result Shapes @@ -2348,7 +2367,7 @@ Implementation note: All eleven identity providers are fully implemented and fun - [DONE] v0.1 to v0.2 conversion utility (`src/convert.js`, CLI `agentcli convert`, RPC `agentcli.convert`) - [DONE] v0.2 example manifest (`examples/identity-v2.json`) -- [DONE] v0.2 test coverage -- 365 total tests including 12 end-to-end integration tests (credential materialization, trust enforcement, authorization proof rejection, evidence generation, file-bearer e2e, validation of malformed profiles) +- [DONE] v0.2 test coverage for credential materialization, trust enforcement, authorization proof rejection, evidence generation, file-bearer behavior, and malformed profile validation - [DONE] scheduler schema updated with v0.2 flat fields - [DONE] comprehensive v0.2 profile validation in `validateManifest` (identity profiles, authorization proof profiles, authorization profiles, evidence profiles, cross-reference validation for dangling refs) - [DONE] v0.1 to v0.2 converter produces proper profile refs (not inline identity blocks) @@ -2359,7 +2378,7 @@ The following decisions were made during implementation and differ from or exten #### v0.1/v0.2 Dual-Path Execution -`exec.js` detects the manifest version and dispatches to entirely separate code paths rather than using a single unified path with conditionals. Detection logic: `manifest.version === '0.2' || Boolean(manifest.identity_profiles)`. This guarantees zero behavioral change for v0.1 manifests and avoids accidental regressions from v0.2 logic affecting v0.1 execution. +`exec.js` detects the manifest version and dispatches to separate code paths rather than using a single unified path with conditionals. Detection logic: `manifest.version === '0.2' || Boolean(manifest.identity_profiles)`. The v0.1 path remains synchronous while sharing current security guarantees for static dry-run, approval-before-side-effects ordering, child-environment sanitization, and audit-safe bindings. #### `src/signing/` Preserved Alongside `src/evidence/` @@ -2387,7 +2406,7 @@ Each provider file auto-registers with its registry on import (e.g., `import './ #### JWT Verifier Signature Verification -The JWT authorization proof verifier performs structural validation, temporal checks (exp/nbf), issuer and audience matching, and declared-claim checks without external dependencies. Cryptographic signature verification (RS256, ES256) is supported through either a configured `public_key` or a fetched `jwks_uri`, using Node's built-in `crypto.createVerify`. When `verify.required` is `true`, execution is rejected unless signature verification succeeds. +The JWT authorization proof verifier performs structural validation, temporal checks (exp/nbf), issuer and audience matching, declared-claim checks, canonical manifest binding, and cryptographic signature verification without external dependencies. RS256 and ES256 use either a configured `public_key` or fetched `jwks_uri` through Node's built-in crypto APIs. Every JWT proof is rejected unless signature and manifest binding succeed, regardless of `verify.required`. #### Certificate Verifier Uses `crypto.X509Certificate` @@ -2399,7 +2418,7 @@ The `file-bearer` identity provider checks file permissions via `statSync` and w #### Conversion Utility Attestation Mapping -The v0.1 to v0.2 conversion utility maps legacy `identity.attestation` strings to `authorization_proof_profiles` entries by inferring the method from the attestation string content: strings containing "oidc" or "jwt" map to method `jwt`, strings containing "ssh" or "signature" map to `detached-signature`, strings containing "cert" or "x509" map to `certificate`, and all others map to `none`. Generated profile IDs use a `legacy-` prefix for traceability (e.g., `legacy-ssh-signature`). +The v0.1 to v0.2 conversion utility maps legacy `identity.attestation` strings to informational `authorization_proof_profiles` entries with `method: "none"`. It preserves provenance without inventing public keys, trust anchors, signatures, or canonical manifest bindings that were absent from the source. Generated profile IDs use a `legacy-` prefix for traceability. #### Sync/Async Dual Dispatch in executeTask @@ -2411,7 +2430,7 @@ When Phase 2 (identity resolution) fails, the runtime writes an audit record wit #### Authorization Proof Failure Audit Records -When Phase 1 (authorization proof verification) fails with `verify.required: true`, the runtime writes an audit record with the merged declared identity, actor context, and proof verification summary before throwing. This captures pre-execution authorization failures in the audit trail. +When Phase 1 authorization proof verification fails for any non-`none` method, the runtime writes an audit-safe failure record according to policy and throws before identity resolution. `verify.required: false` does not weaken a cryptographic method. #### OPA Authorization Provider @@ -2423,7 +2442,7 @@ The `oidc-token-exchange` provider (`src/identity/oidc-token-exchange.js`) is th #### Scheduler Target Capability Defaults -The openclaw-scheduler target declares `authorization_proof_verification: false` and `authorization_hook: false` because the scheduler runtime itself does not yet implement these capabilities. When these are false, `apply.js` verifies authorization proofs locally during apply (for proofs marked `verify.required: true`) and rejects manifests that declare authorization blocks. These flags can be flipped to `true` when the scheduler runtime adds native support. +The openclaw-scheduler target uses conservative static defaults for security-sensitive capabilities. During apply, values reported by the live runtime are authoritative, including explicit `false`. If `authorization_proof_verification` is false, `apply.js` verifies every non-`none` proof locally before handoff. Authorization, trust, evidence, root approval, approver scope, structured output, and credential handoff declarations fail capability negotiation when the effective runtime feature map cannot enforce them. ## Rejected Alternatives diff --git a/docs/field-reference.md b/docs/field-reference.md index b5deff8..c04288e 100644 --- a/docs/field-reference.md +++ b/docs/field-reference.md @@ -210,11 +210,11 @@ All budget fields, when present, must be integers >= 1. | `required` | boolean | No | -- | Whether approval is required. Superseded by `policy` when both are present. | | `policy` | string | No | `manual`, `auto-approve`, `auto-reject` | Approval gate policy. Takes precedence over `required`. | | `risk_level` | string | No | `low`, `medium`, `high` | Risk classification. | -| `approver_scope` | string (token) | No | -- | Scope or group that may approve. Restricted token. | +| `approver_scope` | string (token) | No | -- | Exact approver, `principal:`, `user:`, or `domain:`. Restricted token. | | `timeout_s` | integer | No | >= 1 | Approval timeout in seconds. | | `auto` | string | No | `approve`, `reject` | Direct override for auto-resolution on timeout. Explicit value takes precedence over inference from `policy`. | -`agentcli exec` enforces `policy` locally (see spec.md): `manual` requires a matching record in `~/.agentcli/state/approvals.ndjson` created via `agentcli approve`, `auto-reject` refuses unconditionally, `auto-approve` or absent runs freely. `--dry-run` bypasses the gate. The approval record is bound to a canonical hash over `workflow_id`, `task_id`, `shell.program`, `shell.args`, `shell.cwd`, `identity.ref`, `policy`, and `risk_level`. +`agentcli exec` enforces `policy` locally (see spec.md): `manual` requires a matching record in `~/.agentcli/state/approvals.ndjson` created via `agentcli approve`, `auto-reject` refuses unconditionally, and `auto-approve` or absent runs freely. The approval record binds the canonical manifest and complete effective execution configuration, including hashed command inputs, resolved profiles, contract, proof, evidence, output, and postcondition. `approver_scope` and `timeout_s` are enforced at grant and consumption. Unexpected unsigned records fail verification; only an explicit `--signer none` grant is accepted unsigned. `--dry-run` is static and does not consume or enforce the gate. --- @@ -284,7 +284,7 @@ When `ref` is present, the referenced profile is loaded first, then inline field Runs a shell command after the main task succeeds. Workflow-level `verify` acts as the default for tasks; a task-level `verify` replaces the workflow block and omitted optional fields fall back to built-in defaults. -In the v0.2 execution pipeline, `verify` runs after evidence generation. Evidence and attestation therefore describe the main command result; the `verify` outcome is recorded separately and can still flip the final task status according to `on_failure`. If operators need end-to-end proof that includes the verification step, model that requirement in the evidence payload rather than assuming `verify` is part of the attested result. +In the v0.2 execution pipeline, `verify` runs before evidence generation. The complete evidence payload binds the main result and the postcondition outcome. A verify failure can fail the task or become a warning according to `on_failure`. | Field | Type | Required | Values | Description | |-------|------|----------|--------|-------------| @@ -319,6 +319,8 @@ In the v0.2 execution pipeline, `verify` runs after evidence generation. Evidenc | `provider_config` | object | No | -- | Provider-specific configuration. Free-form. | | `inputs` | object | No | -- | Named values using `value_from` indirection. Each value is a [value_from](#value_from-fields) object. | +Caching, automatic refresh, and credential handoff are capability-gated. If a profile requests one of these behaviors and the selected executor/provider cannot implement it, validation or execution fails closed instead of silently degrading. + ### Identity Auth Delegation Policy Fields | Field | Type | Required | Description | @@ -417,9 +419,9 @@ Each element in the `bindings` array is an object with these fields: | Field | Type | Required | Description | |-------|------|----------|-------------| -| `required` | boolean | No | Whether verification must succeed before execution proceeds. | +| `required` | boolean | No | Must not be true for `method: "none"`. Every non-`none` method is always verified. | -For `jwt`, `verify.required: true` requires either `public_key` or `jwks_uri`. +For `jwt`, `public_key` or `jwks_uri` is required and the signed claims must include the canonical manifest digest. Detached-signature and certificate profiles likewise require their method-specific trust material and manifest binding. Use `method: "none"` for an intentionally unverifiable declaration. --- @@ -437,7 +439,7 @@ For `jwt`, `verify.required: true` requires either `public_key` or `jwks_uri`. | Field | Type | Required | Description | |-------|------|----------|-------------| -| `required` | boolean | No | Whether verification is required for this scope. | +| `required` | boolean | No | Scope policy overlay. It cannot weaken verification for a non-`none` method. | --- @@ -549,6 +551,8 @@ Current include values are: |-------|------|----------|-------------| | `required` | boolean | No | Whether evidence verification is required for this scope. | +Generated evidence uses a complete versioned canonical payload bound to the manifest digest, effective task hash, execution id, audit-safe identity and command descriptors, result, and postcondition. Raw credentials, stdin, stdout, and stderr are excluded. Verification rejects payload changes and cross-execution transplantation. + --- ## Contract Fields @@ -565,6 +569,8 @@ Workflow-level `contract` acts as a default for tasks. Task-level overrides key | `required_trust_level` | string | No | `untrusted`, `restricted`, `supervised`, `autonomous` | Minimum trust level for execution (v0.2). Must not exceed the resolved identity's `trust.constraints.max_autonomy`. | | `trust_enforcement` | string | No | `none`, `advisory`, `strict` | How trust level mismatches are handled (v0.2). Default: `none`. | +For direct shell execution, restrictive sandbox, allowed-path, and network requests must be enforceable on the current host or execution fails closed. `exec --dry-run` does not probe that host boundary; it returns a static plan and marks live phases skipped. + --- ## On-Failure Fields @@ -610,7 +616,7 @@ Used for credential and proof inputs that must not be hardcoded. At least one so | `env` | string | No | Environment variable name. | | `file` | string | No | File path. The file should have restrictive permissions. | | `literal` | string | No | Inline value. Use sparingly. Not allowed in all contexts. | -| `command` | string | No | Shell command to run. stdout is captured. 30s timeout. | +| `command` | string | No | Shell command to run. stdout is captured with a 30s timeout. Proof commands run only after local approval; scheduler apply disables them by default and requires explicit caller opt-in. | --- diff --git a/docs/guide-identity.md b/docs/guide-identity.md index 8f32bff..d0dd5ef 100644 --- a/docs/guide-identity.md +++ b/docs/guide-identity.md @@ -134,10 +134,10 @@ Preview what agentcli will do without executing the command: agentcli exec manifest.json call-api --dry-run ``` -Add `--identity-debug` to see the resolved (redacted) identity session: +Dry-run does not resolve a provider or materialize credentials. It shows the merged, audit-safe identity declaration and marks live phases skipped. Use the dedicated identity command when you intentionally want provider-backed resolution without running the task: ```bash -agentcli exec manifest.json call-api --dry-run --identity-debug +agentcli identity resolve manifest.json call-api ``` ### Making auth optional @@ -493,10 +493,10 @@ export UPSTREAM_TOKEN="eyJhbGciOi..." agentcli exec manifest.json call-downstream ``` -Preview without executing: +Resolve the identity without executing the task: ```bash -agentcli exec manifest.json call-downstream --dry-run --identity-debug +agentcli identity resolve manifest.json call-downstream ``` ### When to use this provider @@ -703,10 +703,10 @@ export AWS_SECRET_ACCESS_KEY="wJalr..." agentcli exec manifest.json list-buckets ``` -Preview the assumed role session: +Resolve the assumed role session without executing the task: ```bash -agentcli exec manifest.json list-buckets --dry-run --identity-debug +agentcli identity resolve manifest.json list-buckets ``` ### When to use this provider @@ -900,10 +900,10 @@ since standard Node `fetch()` does not support UDS connections. agentcli exec manifest.json call-peer ``` -Preview identity resolution: +Resolve identity without executing the task: ```bash -agentcli exec manifest.json call-peer --dry-run --identity-debug +agentcli identity resolve manifest.json call-peer ``` ### When to use this provider @@ -1011,10 +1011,10 @@ export AGENTCLI_ENTRA_CLIENT_ASSERTION="eyJhbGciOi..." agentcli exec manifest.json query-graph ``` -Preview the resolved identity session: +Resolve the identity session without executing the task: ```bash -agentcli exec manifest.json query-graph --dry-run --identity-debug +agentcli identity resolve manifest.json query-graph ``` ### When to use this provider @@ -1162,32 +1162,21 @@ The `contract` block also answers "what execution boundary is this task supposed ### What local `agentcli exec` enforces today -Local `agentcli exec` fully enforces some contract checks, and records others as advisory intent: +Local `agentcli exec` enforces the contract before spawning: -- `allowed_paths`: enforced +- `allowed_paths`: verifies the effective working directory, resolves symlinks, and requires an enforceable filesystem sandbox - `required_trust_level` + `trust_enforcement`: enforced - `audit`: enforced -- `sandbox`: enforced on macOS when `sandbox-exec` is available; advisory on other OSes -- `network`: enforced on macOS when `sandbox-exec` is available for `restricted` and `none`; advisory on other OSes +- `sandbox: strict`: requires supported operating-system isolation +- `network: restricted` or `network: none`: requires supported network isolation -That is why you may see warnings such as: - -```text -contract.sandbox is "strict" but no supported local sandbox runner is available; execution proceeds without OS-level sandbox enforcement -``` - -or: - -```text -contract.network is "none" but no supported local sandbox runner is available; execution proceeds without OS-level network enforcement -``` - -These warnings do not mean the manifest is invalid. They mean the declaration is valid, but the local machine does not currently have a supported sandbox backend for that boundary. +If a restrictive sandbox, allowed-path, or network boundary cannot be enforced, execution fails with a sandbox or contract error. It never warns and proceeds outside the requested boundary. `sandbox: permissive` with `network: unrestricted` and no `allowed_paths` explicitly accepts execution without strong isolation. Use this rule of thumb: -- local testing: `permissive` / `unrestricted` is usually fine -- macOS local enforcement: use `strict`, `restricted`, or `none` and let `sandbox-exec` enforce the boundary +- local static preview: use `exec --dry-run`; it does not probe sandbox support +- live local execution without isolation: explicitly use `permissive` and `unrestricted` with no `allowed_paths` +- restrictive execution: use a supported macOS sandbox boundary or run agentcli inside a container or another operating-system isolation layer - other OSes: keep the contract declaration, but rely on a backend or environment that can enforce it until an OS-specific adapter is available - if you want no warning during local runs on an unsupported machine, use `sandbox: "none"` and `network: "unrestricted"` @@ -1347,7 +1336,7 @@ Presentation supports a `cleanup` field that controls when temporary files are d } ``` -Cleanup runs after execution completes, including on dry runs where materialization occurred. +Cleanup runs after live inspection or execution completes. Static dry-runs never materialize credentials and therefore have no provider artifacts to clean up. ## Evidence and Attestation @@ -1373,9 +1362,7 @@ Define an evidence profile at the top level of the manifest: ] ``` -The `bind` array controls which execution fields are included in the signed payload. -Available bind targets: `execution_id`, `declared_identity`, `resolved_identity`, -`authorization_proof`, `authorization`, `contract`, `command`, `result`. +The evidence declaration selects additional sections and context, while the stored envelope always retains the complete versioned binding needed for verification. That canonical binding includes the manifest digest, effective task hash, execution id, audit-safe identity and command descriptors, result, and postcondition. Raw credentials, stdin, stdout, and stderr are excluded. ### Reference the evidence profile from a task @@ -1664,11 +1651,11 @@ curl -X POST https://auth.example.com/oauth/token \ ### "Authorization proof verification failed" The authorization proof (JWT, detached signature, or certificate) did not pass verification. -Check that the proof value is current and matches the expected claims. Use `--dry-run` to -inspect the proof verification result without executing: +Check that the proof value is current, cryptographically valid, bound to the canonical manifest, +and matches the expected claims. Use the dedicated verification command without executing the task: ```bash -agentcli exec manifest.json my-task --dry-run +agentcli authorization-proof verify manifest.json my-task ``` ### "Authorization denied" @@ -1682,14 +1669,15 @@ Use these flags to get more detail during troubleshooting: | Flag | What it shows | |---|---| -| `--dry-run` | Full execution plan without running the command | -| `--identity-debug` | Redacted identity session and credential summary | -| `--presentation-debug` | Materialization summary (env keys, temp file counts) | +| `--dry-run` | Static execution plan; all live phases are skipped | +| `--identity-debug` | Redacted identity session and credential summary during a live or dedicated identity operation | +| `--presentation-debug` | Materialization summary during a live operation | Example: ```bash -agentcli exec manifest.json my-task --dry-run --identity-debug --presentation-debug +agentcli exec manifest.json my-task --dry-run +agentcli identity resolve manifest.json my-task ``` ### Validating identity resolution without execution @@ -1715,7 +1703,7 @@ Recommended pattern: 1. Put the normal CLI or service credential in an `identity_profile`. 2. Put org, delegation, run, and non-secret verification references in `identity.subject.attributes`. 3. Require a short-lived signed JWT in `authorization_proof` for sensitive tasks. -4. Use `jwks_uri` or `public_key` so `verify.required: true` enforces signature-backed verification. +4. Use `jwks_uri` or `public_key`; every JWT proof requires signature verification and a canonical manifest digest claim regardless of `verify.required`. 5. If you use OPA, request the `actor` and `step_up` sections so policy can see the actor chain and verification summary without reading raw tokens. The dedicated example manifest is: diff --git a/docs/guide-testing-stripe-identity-step-up.md b/docs/guide-testing-stripe-identity-step-up.md index 8b38690..20a8616 100644 --- a/docs/guide-testing-stripe-identity-step-up.md +++ b/docs/guide-testing-stripe-identity-step-up.md @@ -5,7 +5,7 @@ This guide walks through a local end-to-end test of the Stripe Identity step-up - the manifest: [`../examples/stripe-identity-step-up.json`](../examples/stripe-identity-step-up.json) - the OPA policy: [`../examples/stripe-identity-step-up.rego`](../examples/stripe-identity-step-up.rego) -The flow uses `--dry-run`, so you can test proof verification, OPA authorization, and audit output without needing a live backend API behind the wrapped shell commands. +The generated local manifest replaces the remote API commands with harmless `printf` calls, so the guide can exercise live proof verification, OPA authorization, identity materialization, and audit output without contacting a backend API. Do not add `--dry-run` to those checks: dry-run is intentionally static and skips proof, provider, authorization, evidence, and audit side effects. ## Prerequisites @@ -57,7 +57,7 @@ http://127.0.0.1:8181/v1/data/agentcli/authz/allow ## 3. Create a local test manifest with a generated public key -The checked-in example uses a placeholder `jwks_uri`. For local testing, generate an RSA key pair and write a temporary manifest that embeds the public key directly. +The checked-in example uses an illustrative remote `jwks_uri`. For local testing, generate an RSA key pair and write a temporary manifest that embeds the public key directly. ```bash node --input-type=module <<'EOF' @@ -70,6 +70,10 @@ writeFileSync('/tmp/agentcli-step-up-private.pem', privateKey.export({ type: 'pk const manifest = JSON.parse(readFileSync('examples/stripe-identity-step-up.json', 'utf8')); delete manifest.authorization_proof_profiles[0].jwks_uri; manifest.authorization_proof_profiles[0].public_key = publicKey.export({ type: 'spki', format: 'pem' }); +manifest.workflows[0].contract.sandbox = 'none'; +for (const task of manifest.workflows[0].tasks) { + task.shell = { program: 'printf', args: ['%s\\n', `local test: ${task.id}`] }; +} writeFileSync('/tmp/stripe-identity-step-up.local.json', JSON.stringify(manifest, null, 2)); EOF @@ -83,7 +87,7 @@ node bin/agentcli.js validate /tmp/stripe-identity-step-up.local.json --json ## 4. Export a dummy primary auth token -The manifest's `identity_profile` still expects a runtime bearer token. Because this guide uses `--dry-run`, the token can be a placeholder. +The manifest's identity profile still expects a runtime bearer token. The local commands only print fixed text, so use a synthetic local token that is never sent to a remote service. ```bash export BOT_ACCESS_TOKEN="dummy-bot-token" @@ -101,8 +105,10 @@ export ACTOR_STEP_UP_JWT="$( node --input-type=module <<'EOF' import { createSign } from 'node:crypto'; import { readFileSync } from 'node:fs'; +import { canonicalDigest } from './src/canonical.js'; const privateKey = readFileSync('/tmp/agentcli-step-up-private.pem', 'utf8'); +const manifest = JSON.parse(readFileSync('/tmp/stripe-identity-step-up.local.json', 'utf8')); const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT', @@ -122,6 +128,7 @@ const payload = Buffer.from(JSON.stringify({ verification_level: 'document', verification_verified_at: '2026-04-07T12:00:00Z', step_up_policy: 'stripe_identity_sensitive_ops', + manifest_digest: canonicalDigest(manifest), exp: Math.floor(Date.now() / 1000) + 3600 })).toString('base64url'); @@ -132,12 +139,12 @@ EOF )" ``` -## 6. Dry-run the non-sensitive task +## 6. Run the harmless non-sensitive task This task does not require step-up proof or OPA authorization. ```bash -node bin/agentcli.js exec /tmp/stripe-identity-step-up.local.json list-safe-state --dry-run --signer none --json +node bin/agentcli.js exec /tmp/stripe-identity-step-up.local.json list-safe-state --signer none --json ``` What to look for: @@ -146,10 +153,10 @@ What to look for: - no authorization proof requirement - resolved identity from the `ops-bot` profile -## 7. Dry-run the sensitive task with valid step-up proof +## 7. Run the harmless sensitive task with valid step-up proof ```bash -node bin/agentcli.js exec /tmp/stripe-identity-step-up.local.json view-sensitive-customer --dry-run --signer none --json +node bin/agentcli.js exec /tmp/stripe-identity-step-up.local.json view-sensitive-customer --signer none --json ``` What to look for in the JSON output: @@ -168,7 +175,7 @@ Unset the JWT and rerun the sensitive task: ```bash unset ACTOR_STEP_UP_JWT -node bin/agentcli.js exec /tmp/stripe-identity-step-up.local.json view-sensitive-customer --dry-run --signer none --json +node bin/agentcli.js exec /tmp/stripe-identity-step-up.local.json view-sensitive-customer --signer none --json ``` Expected result: @@ -185,8 +192,10 @@ export ACTOR_STEP_UP_JWT="$( node --input-type=module <<'EOF' import { createSign } from 'node:crypto'; import { readFileSync } from 'node:fs'; +import { canonicalDigest } from './src/canonical.js'; const privateKey = readFileSync('/tmp/agentcli-step-up-private.pem', 'utf8'); +const manifest = JSON.parse(readFileSync('/tmp/stripe-identity-step-up.local.json', 'utf8')); const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT', @@ -206,6 +215,7 @@ const payload = Buffer.from(JSON.stringify({ verification_level: 'document', verification_verified_at: '2026-04-07T12:00:00Z', step_up_policy: 'stripe_identity_sensitive_ops', + manifest_digest: canonicalDigest(manifest), exp: Math.floor(Date.now() / 1000) + 3600 })).toString('base64url'); @@ -219,7 +229,7 @@ EOF Now rerun: ```bash -node bin/agentcli.js exec /tmp/stripe-identity-step-up.local.json view-sensitive-customer --dry-run --signer none --json +node bin/agentcli.js exec /tmp/stripe-identity-step-up.local.json view-sensitive-customer --signer none --json ``` Expected result: @@ -230,7 +240,7 @@ Expected result: ## 10. Inspect audit output -Because the example workflow uses `contract.audit: "always"`, both successful dry-runs and authorization failures are written to the audit log. +Because the example workflow uses `contract.audit: "always"`, successful live local runs and authorization failures are written to the audit log. Static dry-runs never write audit records. ```bash node bin/agentcli.js audit --limit 5 --json @@ -265,7 +275,7 @@ unset ACTOR_STEP_UP_JWT 1. `validate` passes for the checked-in example. 2. `validate` passes for the generated local manifest. -3. `list-safe-state --dry-run` succeeds without step-up. -4. `view-sensitive-customer --dry-run` succeeds with a valid JWT and OPA allow. -5. `view-sensitive-customer --dry-run` fails when the JWT is missing. -6. `view-sensitive-customer --dry-run` fails with `authorization_denied` when OPA rejects the actor context. +3. The harmless local `list-safe-state` task succeeds without step-up. +4. The harmless local `view-sensitive-customer` task succeeds with a valid manifest-bound JWT and OPA allow. +5. The sensitive task fails when the JWT is missing. +6. The sensitive task fails with `authorization_denied` when OPA rejects the actor context. diff --git a/docs/protocol.md b/docs/protocol.md index 789435e..4e9c556 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -63,17 +63,18 @@ Purpose: Result: -- `{ "ok": true, "package_version": "0.2.0", "manifest_version": "0.2" }` +- `{ "ok": true, "package_version": "0.3.2", "manifest_version": "0.2" }` ### `agentcli.schema` Params: - `target` - defaults to `"manifest"` when omitted. Valid targets: `manifest`, `workflow`, `task`, `schedulerJob`, `standalonePlan`, `rpcRequest`, `rpcResponse`. Also accepts kebab-case aliases: `scheduler-job`, `standalone-plan`, `rpc-request`, `rpc-response`. +- `legacy` - boolean, defaults to `false`. When false, returns JSON Schema Draft 2020-12. When true, returns the legacy agentcli descriptor. Result: -- `{ "ok": true, "schema": }` +- `{ "ok": true, "schema_format": "json-schema-draft-2020-12|agentcli-legacy", "schema": }` ### `agentcli.describe` @@ -86,6 +87,26 @@ Result: - `{ "ok": true, "description": }` - for `target: "rpc"`, description contains separate `methods[]` and `notifications[]` arrays +### `agentcli.targets` + +Purpose: + +- discover compile targets and their declared static capabilities + +Result: + +- `{ "ok": true, "targets": [{ "name": "...", "description": "...", "capabilities": [...], "features": {...} }] }` + +### `agentcli.paths` + +Purpose: + +- resolve local agentcli home, manifest, output, registry, state, audit, approval, and allowed-signers paths + +Result: + +- `{ "ok": true, "paths": {...} }` + ### `agentcli.validate` Params: @@ -121,10 +142,11 @@ Params: - `dryRun` - boolean, defaults to `false`. When `true`, no scheduler writes are executed (preview mode). - `explain` - `adoptBy` - `"id"` (default) or `"name"`. Use `"name"` for one-time migration of existing scheduler jobs to agentcli management. See README for the migration workflow. +- `allowProofCommand` - boolean, defaults to `false`. Explicitly permits `value_from.command` while locally verifying a proof for a runtime that cannot verify it. Keep false for untrusted manifests. Result: -- `{ "ok": true, "target": "openclaw-scheduler", "dry_run": , "scheduler": { "command": "...", "db_path": "..." }, "capabilities": { "source": "static|runtime", "negotiated": , "handoff_version": "..." }, "handoff": { "field_version": "1|2", "projected_fields": , "v02_fields_included": }, "job_count": , "actions": [{ "action": "created|updated|adopted", "job_id": "...", "adopted_from_job_id": "...", "name": "...", "invocation_mode": "schedule|trigger", "authorization_proof_verification": { ... } }], "authorization_proof_verifications": [{ ... }], "explain": [...] }` +- `{ "ok": true, "target": "openclaw-scheduler", "dry_run": , "scheduler": { "command": "...", "db_path": "..." }, "capabilities": { "source": "static|runtime", "negotiated": , "handoff_version": "..." }, "handoff": { "field_version": "1|2|3", "projected_fields": , "v02_fields_included": }, "job_count": , "actions": [{ "action": "created|updated|adopted", "job_id": "...", "adopted_from_job_id": "...", "name": "...", "invocation_mode": "schedule|trigger", "authorization_proof_verification": { ... } }], "authorization_proof_verifications": [{ ... }], "explain": [...] }` - `adopted_from_job_id` is present only when `action` is `"adopted"` - `capabilities` summarizes runtime capability negotiation for the selected scheduler - `handoff` summarizes which scheduler field version was projected during apply @@ -147,6 +169,46 @@ Result: - `{ "ok": true, "target": "openclaw-scheduler", "entity": "...", "count": , "items": [...] }` +### `agentcli.audit` + +Params: + +- `limit` - optional positive integer + +Result: + +- `{ "ok": true, "count": , "records": [...], "warnings": [{ "line_number": , "message": "malformed audit record skipped" }] }` + +Records are sanitized. Malformed JSONL lines are skipped and surfaced as warnings instead of aborting the read. + +### `agentcli.approvals.list` + +Params: + +- `status` - optional `pending`, `consumed`, `expired`, `revoked`, or `all` +- `workflowId` - optional workflow filter +- `taskId` - optional task filter + +Result: + +- `{ "ok": true, "count": , "records": [...] }` + +### `agentcli.registry.list` + +Result: + +- `{ "ok": true, "entries": [...] }` + +### `agentcli.registry.show` + +Params: + +- `name` - required registry entry name + +Result: + +- `{ "ok": true, "name": "...", "manifest": {...} }` + ### `agentcli.convert` *v0.2* @@ -265,6 +327,26 @@ Result: - `{ "ok": true, "method": "...", "verifier": "..." }` +### `agentcli.authorizationProof.verify` + +*v0.2* + +Purpose: + +- explicitly verify a task's resolved authorization proof without executing the target command + +Params: + +- `manifest` -- a valid manifest object +- `taskId` -- task id whose resolved proof is verified +- `workflowId` -- optional workflow id + +Result: + +- `{ "ok": true, "authorization_proof": {...} }` + +This is a live governance inspection, not dry-run. It may resolve `value_from`, execute an explicitly declared proof command, or fetch configured JWKS trust material. It does not resolve identity, spawn the target command, generate evidence, or write an execution audit record. + ### `agentcli.authorization.providers` *v0.2* @@ -374,6 +456,14 @@ Current error classes: Implementations SHOULD include machine-readable `data` for richer failures when available. Caller-fixable request shape and argument issues SHOULD use `-32602`, including unknown schema targets, unknown description topics, unsupported compile targets, and invalid inspect arguments. +Error responses use this envelope: + +```json +{"jsonrpc":"2.0","id":"1","error":{"code":-32602,"message":"...","data":{"code":"invalid_argument","error_type":"invalid_argument"}}} +``` + +Validation errors add `data.validation`. Internal failures use `-32603` and a generic public message. Application failures use `-32000` with their stable machine-readable code and one of the documented error types. + ## Stability The following are intended to be stable within manifest spec versions `0.1` and `0.2`: diff --git a/docs/roadmap.md b/docs/roadmap.md index 5dd471f..1f2255b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -25,7 +25,7 @@ - Credential handoff (downscope and transaction modes) - Audit enhancements (delegation chain, trust level, authorization decision, runtime instance attribution, handoff mode) - v0.1 to v0.2 conversion utility (`agentcli convert`) -- v0.1/v0.2 dual-path execution (zero behavioral change for v0.1 manifests) +- v0.1/v0.2 dual-path execution with shared static dry-run, approval ordering, child-environment sanitization, and audit safety guarantees - Provider discovery CLI and JSON-RPC (`agentcli identity providers`, `agentcli identity validate-delegation`) - Delegation chain validation with policy enforcement - Three-stage profile merge (profile, workflow, task) with tightening-only rules @@ -33,8 +33,12 @@ - Enterprise identity providers: `azure-managed-identity`, `aws-sts-assume-role`, `gcp-workload-identity`, `spiffe-jwt-svid` - Comprehensive v0.2 profile validation with cross-reference checks for dangling refs - Converter produces proper identity profile refs (not inline blocks) -- 591 total tests including 12 end-to-end integration tests - Local approval gate enforcement in `agentcli exec` with single-use ssh-signed grants (`agentcli approve`, `agentcli approvals list|revoke`, `exec --approval-id`); approval records stored at `~/.agentcli/state/approvals.ndjson`; enforces `approval.policy: manual` and `approval.policy: auto-reject` +- Complete effective-execution approval binding with approver scope, timeout caps, unexpected-unsigned rejection, and approval-before-side-effects ordering +- Cryptographic, manifest-bound authorization proofs and versioned evidence envelopes with transplantation detection +- Fail-closed sandbox, network, provider capability, delegation, and credential cleanup behavior +- Draft 2020-12 JSON Schema output, strict nested validation, strict CLI flags, and read-only JSON-RPC discovery methods +- Scheduler handoff v3, authoritative live capabilities, governed feature gates, auto-reject disabling, and refusal to persist inline shell credentials ## v0.3 diff --git a/docs/runtime-integration-backlog.md b/docs/runtime-integration-backlog.md index 5fdaeba..5b09def 100644 --- a/docs/runtime-integration-backlog.md +++ b/docs/runtime-integration-backlog.md @@ -1,6 +1,6 @@ # Runtime Integration Backlog -Date: 2026-03-28 +Date: 2026-07-11 ## Purpose @@ -25,7 +25,7 @@ Do not: Allowed inside `agentcli` (non-durable, single-machine scope): -- local enforcement of `approval.policy` for direct `exec` via single-use, ssh-signed, task-hash-bound grants in an append-only ndjson state file (`approvals.ndjson`). This is an authoring-time policy enforced at local execution time; it is not a queue, has no timeout resolver, no multi-actor routing, and no cron coupling. +- local enforcement of `approval.policy` for direct `exec` via single-use, signed grants bound to the complete effective execution configuration in an append-only ndjson state file (`approvals.ndjson`). This is an authoring-time policy enforced at local execution time; it is not a queue, has no timeout resolver, no multi-actor routing, and no cron coupling. ## Current State @@ -34,6 +34,15 @@ Allowed inside `agentcli` (non-durable, single-machine scope): - `openclaw-scheduler` currently depends on OpenClaw gateway/session APIs for prompt-task execution. - OpenClaw already exposes the gateway/session runtime surface the scheduler builds on: typed WebSocket control-plane APIs, device/auth handshakes, session lifecycle APIs, and built-in cron/heartbeat automation. +Current control-plane hardening: + +- local approvals bind the complete effective execution configuration, enforce scope and timeout, and run before all live side effects +- scheduler apply queries live capabilities and treats reported values as authoritative, with conservative static fallback values only for unavailable keys +- scheduler handoff versions 1, 2, and 3 are explicit; version 3 carries approval risk, approver scope, and output format +- root approval gates, approver scope, structured output, proof, authorization, trust, evidence, and credential handoff fail capability negotiation when required support is absent +- scheduler compilation refuses raw `shell.env` and `shell.stdin` persistence and compiles `auto-reject` jobs disabled +- CI provisions a pinned scheduler checkout so missing cross-repository integration cannot silently skip the suite + ## Scheduling Boundary To avoid duplicating automation semantics across all three repos: @@ -55,13 +64,15 @@ To avoid duplicating automation semantics across all three repos: Owner: `agentcli` + `openclaw-scheduler` -Problem: +Status: implemented in the `agentcli` control plane. Each scheduler release remains responsible for accurately advertising its runtime surface. + +Original problem: - `agentcli` hardcodes target capability flags for `openclaw-scheduler`. - `apply` currently compensates locally when runtime capabilities are missing. - Capability drift will get worse as `openclaw-scheduler` adds more `v0.2` support. -Backlog: +Implemented contract: 1. Add a machine-readable scheduler capability endpoint/command. 2. Version the capability payload separately from human-facing docs. @@ -73,7 +84,7 @@ Backlog: - `credential_handoff` - `evidence_generation` - `runtime_identity_resolution` -4. Make `agentcli apply` optionally query the runtime before execution. +4. Make `agentcli apply` query the runtime before governed execution. 5. Fall back to conservative static flags only when the runtime is unreachable or too old. 6. Emit clear mismatch errors when the manifest requires a capability the runtime does not advertise. @@ -113,6 +124,8 @@ Acceptance criteria: Owner: `agentcli` + `openclaw-scheduler` +Status: versioned field projection through handoff v3 and negative capability tests are implemented in `agentcli`; runtime releases must opt into each version and feature. + Problem: - The control plane and runtime need a cleaner contract than “flatten some fields and hope the semantics line up.” @@ -214,6 +227,8 @@ Acceptance criteria: Owner: `agentcli` + `openclaw-scheduler` + OpenClaw (where available) +Status: CI checks out an exact scheduler commit, verifies its capability command, and runs the agentcli scheduler integration tests. Broader gateway-backed end-to-end scenarios remain future cross-repository work. + Backlog: 1. Add an integration fixture that exercises `agentcli apply` against a real scheduler instance. diff --git a/docs/spec.md b/docs/spec.md index 75d55b4..082e320 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -103,6 +103,8 @@ If omitted, implementations SHOULD treat the task as enabled by default. This field expresses the desired active state when compiled into a runtime that supports dormant or disabled jobs. +The local `run` command MUST NOT execute a task whose effective `enabled` value is `false`. A disabled root, triggered task, or failure handler is reported as skipped, and its dependent branch does not run. + ### Target `target.session_target` MUST be one of: @@ -223,6 +225,8 @@ It MAY also define: `shell.stdin`, if present, MUST be a string. +A durable compiler or apply adapter MUST NOT persist raw `shell.env` values or `shell.stdin` into a scheduler job record. A backend that cannot resolve these values at dispatch through an explicit credential boundary MUST reject the manifest for that target. + The legacy `command` string field is not supported. Implementations MUST reject manifests that include `command` and SHOULD direct users to `shell.program` and `shell.args`. Backend targets that flatten shell execution into a single string (such as `payload_message` in `openclaw-scheduler`) SHOULD render it as a POSIX-safe shell command with single-quoted arguments. The rendered form is intended for machine consumption, not human display. @@ -378,24 +382,28 @@ This field provides a direct override for the auto-resolution behavior when an a `approval.approver_scope`, if present, MUST be a restricted token identifying the scope or group that may approve the gate. +Local approval scope accepts an exact principal or the prefixes `principal:`, `user:`, and `domain:`. `principal:` and `user:` require an exact principal match; `domain:` requires the approver's address domain to match case-insensitively. + `approval.required` is supported for compatibility, but `approval.policy` SHOULD be preferred in new manifests. ### Local enforcement in `agentcli exec` `agentcli exec` enforces `approval.policy` directly, without a scheduler: -- When `approval.policy` is `manual` (or legacy `approval.required: true` with no `policy`), `exec` MUST refuse execution unless a matching, unconsumed, unrevoked, unexpired approval record exists in `~/.agentcli/state/approvals.ndjson`. The error type is `approval_required`. -- When `approval.policy` is `auto-reject`, `exec` MUST refuse execution unconditionally. Approval records cannot override this policy. The error type is `approval_auto_rejected`. +- When `approval.policy` is `manual` (or legacy `approval.required: true` with no `policy`), `exec` MUST refuse execution unless a matching, unconsumed, unrevoked, unexpired approval record exists in `~/.agentcli/state/approvals.ndjson`. The detailed code is `approval_required`; the closed error type is `validation_error`. +- When `approval.policy` is `auto-reject`, `exec` MUST refuse execution unconditionally. Approval records cannot override this policy. The detailed code is `approval_auto_rejected`; the closed error type is `validation_error`. - When `approval.policy` is `auto-approve` or absent, `exec` proceeds without requiring an approval record. -- `exec --dry-run` MUST bypass the gate without consuming any approval record. +- `exec --dry-run` MUST return a static preview without consuming or enforcing any approval record. + +A matching approval record is one whose `task_hash` equals the canonical hash of the complete effective execution binding. The binding includes the canonical manifest digest, workflow and task ids, target and enabled state, hashes of command arguments, declared environment values and stdin, runtime timeout, approval fields, merged identity and provider configuration hashes, contract, authorization proof, authorization, evidence, child credential policy, postcondition, output contract, intent, and deletion policy. Raw credential values MUST NOT be stored in the grant or audit record. Any bound drift invalidates prior grants. -A matching approval record is one whose `task_hash` equals the canonical hash of the current task over these fields: `workflow_id`, `task_id`, `shell.program`, `shell.args`, `shell.cwd`, `identity.ref`, `approval.policy`, `approval.risk_level`. Any drift in those fields invalidates prior grants. +When `approval.approver_scope` is present, the approver MUST satisfy it at grant time and again at consumption. A grant lifetime MUST NOT exceed `approval.timeout_s`. Approvals are single-use. The matching grant MUST be consumed (written as a `consume` event in `approvals.ndjson`) before the process is spawned; crashed or failed executions still consume the grant. -Successful gated executions MUST include an `approval_used` object in both the result payload and the audit record, carrying `approval_id`, `approver`, `reason`, `risk_level`, `granted_at`, `expires_at`, `signature_verified`, and `signature.{method, key_fingerprint}`. +Successful gated executions MUST include an `approval_used` object in both the result payload and the audit record, carrying `approval_id`, `approver`, `reason`, `risk_level`, `approver_scope`, `granted_at`, `expires_at`, `signature_verified`, and `signature.{method, key_fingerprint}` when signed. -Grants SHOULD be signed. If a grant carries a `signature` field, `exec` MUST verify it against the configured allowed-signers file. A failing signature MUST refuse execution with error type `approval_signature_invalid`. +Grants SHOULD be signed. Signing failure MUST refuse grant creation without appending an unsigned record. If a grant carries a `signature` field, `exec` MUST verify it against the configured allowed-signers file. A failing signature MUST refuse execution with detailed code `approval_signature_invalid` and closed error type `validation_error`. A record without a signature MUST be rejected unless it explicitly records that signing was disabled by the caller. This local mechanism is scoped to single-machine `exec` invocations. Durable multi-actor cron-triggered approvals remain the responsibility of the runtime target (e.g. `openclaw-scheduler`). @@ -563,7 +571,7 @@ Authorization proof supports the following methods: `authorization_proof_profiles[].method`, if present, MUST be one of the above values. -Workflow/task `authorization_proof` blocks are scoped overlays on reusable `authorization_proof_profiles[]` entries. Implementations MUST verify the referenced proof before executing a task when `verify` is present and the resolved profile method is not `none`. Verification failure MUST prevent execution. +Workflow/task `authorization_proof` blocks are scoped overlays on reusable `authorization_proof_profiles[]` entries. Implementations MUST cryptographically verify every referenced proof whose resolved method is not `none`, regardless of `verify.required`. JWT verification MUST validate a signature with configured public-key or JWKS trust material and require a canonical manifest digest claim. Detached signatures and certificates MUST verify the signature or certificate chain and bind the same canonical manifest. Missing trust material, missing binding, or verification failure MUST prevent execution. `method: "none"` is the explicit representation for an informational or unverifiable declaration. See [execution-identity.md](execution-identity.md) for full architectural details. @@ -616,7 +624,7 @@ Evidence profiles describe how execution evidence is produced, bound to a specif `evidence.verify`, if present, MUST be an object describing how the evidence can be independently verified. -Implementations SHOULD produce evidence for every execution when `evidence` is declared. Evidence records MUST be included in the audit trail. +Implementations SHOULD produce evidence for every execution when `evidence` is declared. The evidence envelope MUST retain a versioned canonical payload sufficient for later independent verification. It MUST bind the canonical manifest, effective task, execution id, audit-safe identity and command descriptors, result, and postcondition. Raw credentials, stdin, stdout, and stderr MUST NOT be embedded. Verification MUST reject an envelope whose payload or execution binding was changed or transplanted to another audit record. Evidence records MUST be included in the audit trail. See [execution-identity.md](execution-identity.md) for full architectural details. @@ -687,7 +695,7 @@ Workflow-level `contract` acts as a default for tasks in that workflow. Task-level `contract` overrides workflow-level fields key by key. -Contracts are intent declarations. Backends interpret and enforce them according to their own capabilities. A backend that does not support sandboxing MAY ignore `sandbox` but SHOULD log a warning. +Contracts are portable intent declarations whose enforcement depends on the selected execution boundary. A backend MUST fail closed when a task requests restrictive sandbox, path, or network controls that the backend cannot enforce. `sandbox: none` and `network: unrestricted` explicitly opt out of those restrictions. ## Delete After Run @@ -718,17 +726,17 @@ A conforming implementation MAY support direct task execution via an `exec` comm - `exec` MUST only execute tasks with `target.session_target` equal to `shell`. Prompt-based tasks require an agent runtime and are not executable by agentcli directly. - `exec` MUST resolve identity and contract by inheriting from the workflow level, with task-level fields overriding key by key. - `exec` MUST perform pre-flight contract checks before spawning a process. If `contract.allowed_paths` is declared and the effective execution cwd (`shell.cwd` when set, otherwise the caller cwd) is not under any allowed path, `exec` MUST reject the execution with a contract violation error. -- `exec` SHOULD enforce `contract.sandbox` and `contract.network` when a supported local sandbox backend is available. -- `exec` SHOULD emit advisory warnings for contract constraints it cannot enforce on the current machine (for example, `sandbox: strict` or `network: none` on an unsupported OS). +- `exec` MUST enforce restrictive `contract.sandbox`, `contract.allowed_paths`, and `contract.network` declarations or refuse execution when no supported local boundary is available. - `exec` MUST respect `runtime.timeout_ms` as a process execution timeout. - `exec` MUST record an audit trail governed by `contract.audit`: - `always`: write an audit record for every execution - `on-failure`: write an audit record only when the exit code is non-zero - `none`: do not write an audit record - If `contract.audit` is not set, `exec` SHOULD default to `always` -- The audit record MUST include: execution_id, timestamp, source (workflow_id, task_id), identity (principal, run_as, attestation presence), contract, command metadata (program, args, cwd, env key names, stdin presence), and result (exit code, duration, output size, output hash). -- The audit record MUST NOT include environment variable values or stdin content, as these may contain secrets. -- `exec` supports `--dry-run` to perform validation and contract checks without spawning a process. +- The audit record MUST include: execution_id, timestamp, source (workflow_id, task_id), audit-safe identity, contract, effective task and manifest digests, command metadata (program, argument hashes, cwd, environment key names and value hashes, stdin presence and hash), and result (exit code, duration, output size, output hash). +- The audit record MUST NOT include raw environment variable values, command arguments, stdin, stdout, stderr, provider secrets, or credential material. +- `exec --dry-run` MUST be a static preview. It MUST NOT consume an approval, execute a proof command, resolve identity or authorization providers, contact network endpoints, probe a sandbox, materialize credentials, sign or verify evidence, run a postcondition, or write an audit record. Live phases MUST be reported as skipped. +- Before spawning, `exec` MUST construct child environments from a small operational allowlist such as PATH, HOME, temporary-directory, locale, shell, user, timezone, terminal, and Windows equivalents. Every other ambient variable MUST be omitted unless the task explicitly declares it or an identity provider materializes it. - `exec` works independently of any scheduler runtime. It reads a manifest, resolves a task, and executes it directly. ### Execution-Time Attestation diff --git a/docs/versioning.md b/docs/versioning.md index a1544f1..f8cad0f 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -2,7 +2,7 @@ ## Current Versions -- package version: `0.2.0` +- package version: `0.3.2` - manifest spec version: `0.2` - protocol status: draft, aligned to manifest spec `0.2` @@ -19,6 +19,7 @@ Backward compatibility for `0.1` remains part of the current release surface: - validators accept both `0.1` and `0.2` - `0.2` is the canonical discovery and schema version reported by `agentcli version`, `agentcli schema manifest`, and JSON-RPC `agentcli.version` +- schema discovery emits JSON Schema Draft 2020-12 by default; the legacy descriptor requires an explicit `--legacy` flag or RPC `legacy: true` - existing `0.1` manifests remain executable through the preserved legacy execution path ## Breaking Changes diff --git a/examples/ansible-ops.json b/examples/ansible-ops.json index 8ac96d2..fbf4d07 100644 --- a/examples/ansible-ops.json +++ b/examples/ansible-ops.json @@ -71,7 +71,8 @@ "required_trust_level": "restricted", "audit": "always" }, - "reliability": { "overlap_policy": "skip", "timeout_ms": 120000 } + "runtime": { "timeout_ms": 120000 }, + "reliability": { "overlap_policy": "skip" } }, { "id": "playbook-dry-run", @@ -88,7 +89,8 @@ "audit": "always" }, "delivery": { "mode": "announce-always", "channel": "telegram", "to": "@ops_channel" }, - "reliability": { "overlap_policy": "skip", "timeout_ms": 300000 } + "runtime": { "timeout_ms": 300000 }, + "reliability": { "overlap_policy": "skip" } }, { "id": "playbook-apply", @@ -110,7 +112,8 @@ "timeout_s": 3600 }, "delivery": { "mode": "announce-always", "channel": "telegram", "to": "@ops_channel" }, - "reliability": { "overlap_policy": "skip", "timeout_ms": 600000 }, + "runtime": { "timeout_ms": 600000 }, + "reliability": { "overlap_policy": "skip" }, "verify": { "shell": "ansible all -i inventory/production -m ping --one-line | grep -v SUCCESS && exit 1 || exit 0", "timeout_seconds": 60, diff --git a/examples/full-stack-deploy.json b/examples/full-stack-deploy.json index 09186b2..4bfb978 100644 --- a/examples/full-stack-deploy.json +++ b/examples/full-stack-deploy.json @@ -114,6 +114,8 @@ "id": "ci-approval", "method": "jwt", "issuer": "https://ci.example.com", + "audience": "agentcli", + "jwks_uri": "https://ci.example.com/.well-known/jwks.json", "proof": { "value_from": { "env": "CI_DEPLOY_TOKEN" } }, @@ -121,7 +123,7 @@ "audience": "agentcli", "subject": "deploy-pipeline" }, - "verify": { "required": false } + "verify": { "required": true } } ], "evidence_profiles": [ diff --git a/examples/oidc-service-auth.json b/examples/oidc-service-auth.json index e98e78c..424dbc5 100644 --- a/examples/oidc-service-auth.json +++ b/examples/oidc-service-auth.json @@ -13,7 +13,7 @@ "mode": "service", "scopes": ["api.read", "api.write"], "audience": "https://api.example.com", - "cache": "memory", + "cache": "none", "refresh": "never", "required": true, "provider_config": { diff --git a/examples/stripe-projects.json b/examples/stripe-projects.json index 3771459..dea12e7 100644 --- a/examples/stripe-projects.json +++ b/examples/stripe-projects.json @@ -261,8 +261,7 @@ "name": "Remove Vercel Project", "shell": { "program": "sh", - "args": ["-c", "echo y | vercel project rm my-app"], - "env": { "VERCEL_TOKEN": "$VERCEL_TOKEN" } + "args": ["-c", "echo y | vercel project rm my-app"] }, "target": { "session_target": "shell" }, "identity": { "ref": "deploy-credentials" }, diff --git a/examples/vercel-ops.json b/examples/vercel-ops.json index 6a36b18..e29031b 100644 --- a/examples/vercel-ops.json +++ b/examples/vercel-ops.json @@ -107,8 +107,7 @@ "name": "List Recent Deployments", "shell": { "program": "vercel", - "args": ["list", "--json"], - "env": { "VERCEL_TOKEN": "$VERCEL_TOKEN" } + "args": ["list", "--json"] }, "target": { "session_target": "shell" }, "identity": { "ref": "vercel-readonly" }, @@ -124,8 +123,7 @@ "name": "Check Domain Configuration", "shell": { "program": "vercel", - "args": ["domains", "list", "--json"], - "env": { "VERCEL_TOKEN": "$VERCEL_TOKEN" } + "args": ["domains", "list", "--json"] }, "target": { "session_target": "shell" }, "identity": { "ref": "vercel-readonly" }, @@ -140,8 +138,7 @@ "name": "Deploy to Preview", "shell": { "program": "vercel", - "args": ["deploy", "--json", "--no-wait"], - "env": { "VERCEL_TOKEN": "$VERCEL_TOKEN" } + "args": ["deploy", "--json", "--no-wait"] }, "target": { "session_target": "shell" }, "identity": { "ref": "vercel-deploy-credentials" }, @@ -166,8 +163,7 @@ "name": "Inspect Preview Deployment", "shell": { "program": "vercel", - "args": ["inspect", "--json"], - "env": { "VERCEL_TOKEN": "$VERCEL_TOKEN" } + "args": ["inspect", "--json"] }, "target": { "session_target": "shell" }, "identity": { "ref": "vercel-readonly" }, @@ -186,8 +182,7 @@ "name": "Promote to Production", "shell": { "program": "vercel", - "args": ["promote", "--json", "--yes"], - "env": { "VERCEL_TOKEN": "$VERCEL_TOKEN" } + "args": ["promote", "--json", "--yes"] }, "target": { "session_target": "shell" }, "identity": { "ref": "vercel-deploy-credentials" }, @@ -244,8 +239,7 @@ "name": "Audit Environment Variables", "shell": { "program": "vercel", - "args": ["env", "list", "--json"], - "env": { "VERCEL_TOKEN": "$VERCEL_TOKEN" } + "args": ["env", "list", "--json"] }, "target": { "session_target": "shell" }, "identity": { "ref": "vercel-readonly" }, diff --git a/package-lock.json b/package-lock.json index 76a54c7..a31914a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@amittell/agentcli", - "version": "0.2.2", + "version": "0.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@amittell/agentcli", - "version": "0.2.2", + "version": "0.3.2", "license": "MIT", "bin": { "agentcli": "bin/agentcli.js" @@ -17,7 +17,7 @@ "globals": "^17.4.0" }, "engines": { - "node": ">=22.5.0" + "node": ">=22.13.0" } }, "node_modules/@eslint-community/eslint-utils": { @@ -272,9 +272,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { @@ -555,9 +555,9 @@ } }, "node_modules/flatted": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz", - "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, diff --git a/package.json b/package.json index 543c334..d4b028c 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "agentcli": "bin/agentcli.js" }, "engines": { - "node": ">=22.5.0" + "node": ">=22.13.0" }, "scripts": { "test": "node --test", @@ -78,5 +78,9 @@ "@eslint/js": "^10.0.1", "eslint": "^10.0.2", "globals": "^17.4.0" + }, + "overrides": { + "brace-expansion": "^5.0.6", + "flatted": "^3.4.2" } } diff --git a/skills/manifest-authoring/SKILL.md b/skills/manifest-authoring/SKILL.md index 86c63ce..c008f2c 100644 --- a/skills/manifest-authoring/SKILL.md +++ b/skills/manifest-authoring/SKILL.md @@ -78,14 +78,16 @@ agentcli exec manifest.json deploy --timeout 30000 ``` Exec enforces contracts before spawning: -- Rejects `shell.cwd` outside `allowed_paths` -- Warns about sandbox/network constraints not yet enforced at OS level +- Enforces `allowed_paths` with symlink-safe path resolution and an operating-system sandbox +- Fails closed when strict sandbox, allowed-path, or restricted/none network controls are unavailable - Respects `runtime.timeout_ms` - Enforces `approval.policy` locally (see "Approval Gates" below) The audit log (`~/.agentcli/state/audit.ndjson`) records every execution with identity, contract, timing, output hash, and (for gated tasks) `approval_used` details. Read it with `agentcli audit` or `agentcli audit --limit 10`. -The audit record never includes environment variable values or stdin content (these may contain secrets). It records env key names and a boolean for stdin presence. +The audit record never includes raw arguments, environment values, stdin, stdout, or stderr. It records audit-safe hashes and descriptors. Child processes inherit only a small operational allowlist; every other ambient variable requires explicit `shell.env` declaration or identity-provider materialization. + +Never place credentials in `shell.args` or prompts. Arguments may be visible in process listings and prompts may be persisted by a durable runtime. Use identity-provider env, file, or stdin materialization. ## Approval Gates @@ -93,7 +95,7 @@ When a task declares `approval.policy: "manual"`, `agentcli exec` refuses to run ``` agentcli exec manifest.json deploy-prod -# -> { "ok": false, "error_type": "approval_required", ... } +# -> { "ok": false, "code": "approval_required", "error_type": "validation_error", ... } agentcli approve manifest.json deploy-prod --by alex --reason "tuesday deploy" # -> { "ok": true, "approval": { "approval_id": "...", "signature": {...} } } @@ -102,16 +104,16 @@ agentcli exec manifest.json deploy-prod # -> { "ok": true, "approval_used": { "approval_id": "...", "approver": "alex", ... } } agentcli exec manifest.json deploy-prod -# -> { "ok": false, "error_type": "approval_required", ... } # single-use: consumed +# -> { "ok": false, "code": "approval_required", "error_type": "validation_error", ... } # single-use: consumed ``` Properties of the local gate: - **Single-use.** Each grant is consumed before `spawnSync` (fail-closed: a crashed execution still consumes the grant). Retrying requires a new approval. -- **Hash-bound.** The grant is tied to a canonical hash over `workflow_id`, `task_id`, `shell.program`, `shell.args`, `shell.cwd`, `identity.ref`, `approval.policy`, and `approval.risk_level`. Editing any of those invalidates prior grants. -- **Dry-run bypasses.** `--dry-run` runs without needing or consuming an approval. -- **ssh-signed by default.** Grants carry a signature over the canonical payload, verified against `~/.agentcli/state/allowed_signers`. Tampered grants are refused (`error_type: approval_signature_invalid`). -- **`auto-reject` is absolute.** A task with `approval.policy: "auto-reject"` is refused even with an approval record (`error_type: approval_auto_rejected`). +- **Complete-binding hash.** The grant binds the canonical manifest and effective execution configuration, including hashed command inputs, profiles, contract, proof, evidence, output, postcondition, approver scope, and timeout. Any bound change invalidates the grant. +- **Static dry-run.** `--dry-run` neither needs nor consumes an approval and performs no proof, provider, sandbox, signing, evidence, postcondition, or audit side effects. +- **ssh-signed by default.** Grants carry a signature over the canonical payload, verified against `~/.agentcli/state/allowed_signers`. Tampered grants are refused with detailed `code: approval_signature_invalid` and closed `error_type: validation_error`. +- **`auto-reject` is absolute.** A task with `approval.policy: "auto-reject"` is refused even with an approval record, using detailed `code: approval_auto_rejected` and closed `error_type: validation_error`. Grants live in `~/.agentcli/state/approvals.ndjson` (append-only: grant, consume, and revoke events). List them with `agentcli approvals list`; revoke with `agentcli approvals revoke `. diff --git a/src/apply.js b/src/apply.js index ca85e7b..5170f0f 100644 --- a/src/apply.js +++ b/src/apply.js @@ -1,8 +1,8 @@ import { spawnSync } from 'node:child_process'; -import { createHash } from 'node:crypto'; import process from 'node:process'; import { compileManifestToScheduler } from './compiler/openclaw-scheduler.js'; -import { resolveCommandValue } from './command.js'; +import { resolveValueFrom } from './command.js'; +import { canonicalDigest } from './canonical.js'; import { mergeAuthorizationProofProfile, normalizedTaskPlan, @@ -17,36 +17,11 @@ import { import { SCHEDULER_FIELDS_V1, SCHEDULER_FIELDS_V02, + SCHEDULER_FIELDS_V03, SCHEDULER_FIELD_VERSIONS, } from './scheduler-fields.js'; export { shellCommandInvocation } from './command.js'; -export { SCHEDULER_FIELDS_V1, SCHEDULER_FIELDS_V02, SCHEDULER_FIELD_VERSIONS }; - -function sortKeysDeep(value) { - if (value === null || value === undefined) { - return value; - } - - if (Array.isArray(value)) { - return value.map(item => sortKeysDeep(item)); - } - - if (typeof value === 'object') { - const sorted = {}; - for (const key of Object.keys(value).sort()) { - sorted[key] = sortKeysDeep(value[key]); - } - return sorted; - } - - return value; -} - -function computeManifestDigest(manifest) { - return createHash('sha256') - .update(JSON.stringify(sortKeysDeep(manifest))) - .digest('hex'); -} +export { SCHEDULER_FIELDS_V1, SCHEDULER_FIELDS_V02, SCHEDULER_FIELDS_V03, SCHEDULER_FIELD_VERSIONS }; function npmCommandForPlatform(platform = process.platform) { return platform === 'win32' ? 'npm.cmd' : 'npm'; @@ -151,6 +126,30 @@ export function schedulerCreateSpec(job, { originOverride, fieldVersion = '1' } return projected; } +export function requiredSchedulerFieldVersion(jobs = []) { + if (jobs.some(job => SCHEDULER_FIELDS_V03.some(field => job[field] != null))) return 3; + if (jobs.some(job => SCHEDULER_FIELDS_V02.some(field => job[field] != null))) return 2; + return 1; +} + +export function negotiateSchedulerFieldVersion(jobs, advertisedVersion = '1') { + const requiredVersion = requiredSchedulerFieldVersion(jobs); + const parsedVersion = Number.parseInt(String(advertisedVersion), 10); + if (!Number.isInteger(parsedVersion) || parsedVersion < requiredVersion) { + throw Object.assign( + new Error( + `Scheduler handoff version ${JSON.stringify(advertisedVersion)} cannot preserve fields requiring version ${requiredVersion}` + ), + { + code: 'unsupported_capability', + required_handoff_version: String(requiredVersion), + advertised_handoff_version: advertisedVersion == null ? null : String(advertisedVersion), + } + ); + } + return String(Math.min(parsedVersion, 3)); +} + function schedulerUpdateSpec(job, { fieldVersion = '1' } = {}) { const fields = (SCHEDULER_FIELD_VERSIONS[fieldVersion] || SCHEDULER_FIELDS_V1) .filter(f => f !== 'id' && f !== 'origin'); @@ -174,6 +173,8 @@ function jobRequiresCapabilityNegotiation(job) { job.identity || job.identity_ref || job.authorization || job.authorization_ref || job.evidence || job.evidence_ref || job.child_credential_policy || job.contract_required_trust_level || job.authorization_proof || job.authorization_proof_ref + || (job.approval_required && !job.parent_id) || job.approval_approver_scope + || job.output_format ); } @@ -247,7 +248,8 @@ export async function applyManifestToScheduler( schedulerBin = '', dbPath = '', cwd = process.cwd(), - env = process.env + env = process.env, + allowValueFromCommand = false } = {} ) { const compiled = compileManifestToScheduler(manifest, { includeExplain }); @@ -271,7 +273,6 @@ export async function applyManifestToScheduler( if (hasV02Features) { const runtimeCaps = querySchedulerCapabilities(schedulerRunner); effectiveResult = resolveEffectiveFeatures('openclaw-scheduler', runtimeCaps); - handoffVersion = effectiveResult.handoff_version || '1'; const { errors: capabilityErrors, warnings } = validateManifestCapabilities(compiled, effectiveResult); capabilityWarnings = warnings; @@ -281,6 +282,10 @@ export async function applyManifestToScheduler( { code: 'unsupported_capability', capability_errors: capabilityErrors } ); } + handoffVersion = negotiateSchedulerFieldVersion( + compiled.jobs, + effectiveResult.handoff_version || '1' + ); if (capabilityWarnings.length > 0) { for (const warning of capabilityWarnings) { process.stderr.write(`warning: ${warning.message}\n`); @@ -292,56 +297,43 @@ export async function applyManifestToScheduler( // v0.2: Authorization proof verification for backends lacking the capability if (!effectiveFeatures.authorization_proof_verification && manifest.authorization_proof_profiles?.length > 0) { // Target cannot verify proofs at runtime; verify locally during apply - const { readFileSync } = await import('node:fs'); - const { resolveVerifier } = await import('./authorization-proof/index.js'); - const { resolveJwtVerificationContext } = await import('./authorization-proof/jwt.js'); + const { + assertValidAuthorizationProofProfile, + verifyAuthorizationProof, + } = await import('./authorization-proof/index.js'); await import('./authorization-proof/none.js'); await import('./authorization-proof/jwt.js'); await import('./authorization-proof/detached-signature.js'); await import('./authorization-proof/certificate.js'); - const manifestDigest = computeManifestDigest(manifest); + const manifestDigest = canonicalDigest(manifest); for (const job of compiled.jobs) { const proof = resolvedProofsByTask.get(`${job.source.workflow_id}:${job.source.task_id}`) ?? null; - if (!proof?.ref || proof.verify?.required !== true) continue; - - const verifier = resolveVerifier(proof.method || 'none'); - - let proofValue = null; - if (proof.proof?.value_from?.env) { - proofValue = env[proof.proof.value_from.env] || null; - } else if (proof.proof?.value_from?.file) { - try { - proofValue = readFileSync(proof.proof.value_from.file, 'utf8').trim(); - } catch { - proofValue = null; - } - } else if (proof.proof?.value_from?.literal) { - proofValue = proof.proof.value_from.literal; - } else if (proof.proof?.value_from?.command) { - proofValue = resolveCommandValue(proof.proof.value_from.command, { env, cwd }); - } - - if (!proofValue) { + if (!proof?.ref) continue; + const mustVerify = proof.method !== 'none' || proof.verify?.required === true; + if (!mustVerify) continue; + + const verifier = assertValidAuthorizationProofProfile(proof, { env, cwd }); + let proofValue; + try { + proofValue = resolveValueFrom(proof.proof?.value_from, { + env, + cwd, + allowCommand: allowValueFromCommand, + }); + } catch (error) { throw Object.assign( - new Error(`Authorization proof not available for profile "${proof.ref}" (value_from did not resolve)`), + new Error(`Authorization proof not available for profile "${proof.ref}": ${error.message}`), { code: 'authorization_proof_failed' } ); } - let verificationContext = { + const result = await verifyAuthorizationProof(proofValue, proof, { + manifest, env, + cwd, manifestDigest, - }; - if (proof.method === 'jwt') { - verificationContext = await resolveJwtVerificationContext( - proofValue, - proof, - verificationContext, - ); - } - - const result = await verifier.verifyProof(proofValue, proof, verificationContext); + }); if (!result.verified) { throw Object.assign( new Error(`Authorization proof verification failed for profile "${proof.ref}": ${result.reason || 'verification failed'}`), diff --git a/src/approvals.js b/src/approvals.js index 17f1c5f..9c061bb 100644 --- a/src/approvals.js +++ b/src/approvals.js @@ -1,12 +1,18 @@ import { - appendFileSync, readFileSync, existsSync, mkdirSync, - openSync, closeSync, writeSync, unlinkSync, statSync, + chmodSync, closeSync, constants as fsConstants, existsSync, lstatSync, + mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeSync, } from 'node:fs'; import { dirname } from 'node:path'; -import { createHash, randomBytes } from 'node:crypto'; +import { randomBytes } from 'node:crypto'; import { getProvider, resolveProvider } from './signing/index.js'; import { resolveAllowedSigners, generateAllowedSigners } from './signing/ssh.js'; import { getAgentcliPaths } from './home.js'; +import { canonicalStringify } from './canonical.js'; +import { + buildEffectiveExecutionBinding, + computeEffectiveTaskHash, +} from './compiler/shared.js'; +import { expandManifestShorthands } from './shorthand.js'; // Concurrency: `claimApproval` is the atomic public primitive that // enforceApprovalGate uses. It acquires an fs-lock on .lock @@ -17,7 +23,7 @@ import { getAgentcliPaths } from './home.js'; // approval_required. Locks older than LOCK_STALE_MS are treated as // abandoned (crashed holder) and removed. -const APPROVAL_RECORD_VERSION = 1; +const APPROVAL_RECORD_VERSION = 2; const DEFAULT_TTL_S = 3600; const LOCK_SUFFIX = '.lock'; const LOCK_TIMEOUT_MS = 5000; @@ -38,13 +44,22 @@ function withApprovalsLock(approvalsPath, fn, { pollMs = LOCK_POLL_MS, now = () => Date.now(), } = {}) { - mkdirSync(dirname(approvalsPath), { recursive: true }); + const stateDirectory = dirname(approvalsPath); + mkdirSync(stateDirectory, { recursive: true, mode: 0o700 }); + if (process.platform !== 'win32') chmodSync(stateDirectory, 0o700); const lockPath = `${approvalsPath}${LOCK_SUFFIX}`; const deadline = now() + timeoutMs; let fd; while (true) { try { - fd = openSync(lockPath, 'wx'); + fd = openSync( + lockPath, + fsConstants.O_WRONLY | + fsConstants.O_CREAT | + fsConstants.O_EXCL | + (fsConstants.O_NOFOLLOW || 0), + 0o600 + ); writeSync(fd, `${process.pid}\n`); break; } catch (err) { @@ -87,31 +102,72 @@ export function approvalPolicyAutoRejects(approval) { return approval?.policy === 'auto-reject'; } -function canonicalStringify(value) { - if (value === null || typeof value !== 'object') return JSON.stringify(value); - if (Array.isArray(value)) return `[${value.map(canonicalStringify).join(',')}]`; - const keys = Object.keys(value).sort(); - return `{${keys.map(k => `${JSON.stringify(k)}:${canonicalStringify(value[k])}`).join(',')}}`; +export function computeTaskApprovalHash({ + binding, + manifest, + expanded: suppliedExpanded, + workflow: suppliedWorkflow, + workflowId, + task: suppliedTask, + taskId, + cwd = process.cwd(), +} = {}) { + if (binding) return computeEffectiveTaskHash(binding); + + const expanded = suppliedExpanded || (manifest ? expandManifestShorthands(manifest) : null); + const workflows = expanded?.workflows || []; + const workflow = suppliedWorkflow || ( + workflowId + ? workflows.find(candidate => candidate.id === workflowId) + : workflows.length === 1 + ? workflows[0] + : null + ); + const compatibilityWorkflow = !workflow && suppliedTask && workflowId + ? { id: workflowId, name: workflowId, tasks: [suppliedTask] } + : workflow; + const task = suppliedTask || compatibilityWorkflow?.tasks?.find(candidate => candidate.id === taskId); + if (!compatibilityWorkflow || !task) { + throw Object.assign( + new Error('manifest/workflow/task or a prebuilt binding is required to compute an approval hash'), + { code: 'invalid_argument' } + ); + } + + return computeEffectiveTaskHash(buildEffectiveExecutionBinding({ + manifest, + expanded, + workflow: compatibilityWorkflow, + task, + cwd, + })); } -export function computeTaskApprovalHash({ workflowId, task }) { - const material = { - workflow_id: workflowId, - task_id: task.id, - shell: { - program: task.shell?.program ?? null, - args: task.shell?.args ?? [], - cwd: task.shell?.cwd ?? null, - }, - identity_ref: task.identity?.ref ?? null, - approval_policy: task.approval?.policy ?? (task.approval?.required ? 'manual' : null), - approval_risk_level: task.approval?.risk_level ?? null, - }; - return `sha256:${createHash('sha256').update(canonicalStringify(material)).digest('hex')}`; +export function approverMatchesScope(approver, scope) { + if (!scope) return true; + if (typeof approver !== 'string' || approver.length === 0) return false; + const separator = scope.indexOf(':'); + const kind = separator === -1 ? 'exact' : scope.slice(0, separator); + const expected = separator === -1 ? scope : scope.slice(separator + 1); + if (!expected) return false; + if (kind === 'principal' || kind === 'user' || kind === 'exact') { + return approver === expected; + } + if (kind === 'domain') { + const at = approver.lastIndexOf('@'); + return at > 0 && approver.slice(at + 1).toLowerCase() === expected.toLowerCase(); + } + return approver === scope; } function readApprovalsLog(approvalsPath) { if (!approvalsPath || !existsSync(approvalsPath)) return []; + if (lstatSync(approvalsPath).isSymbolicLink()) { + throw Object.assign( + new Error('Refusing to read approvals from a symbolic link'), + { code: 'approval_log_invalid' } + ); + } const content = readFileSync(approvalsPath, 'utf8').trim(); if (!content) return []; const events = []; @@ -128,9 +184,29 @@ function readApprovalsLog(approvalsPath) { return events; } +function appendApprovalEventUnlocked(event, approvalsPath) { + let descriptor; + try { + descriptor = openSync( + approvalsPath, + fsConstants.O_WRONLY | + fsConstants.O_APPEND | + fsConstants.O_CREAT | + (fsConstants.O_NOFOLLOW || 0), + 0o600 + ); + writeSync(descriptor, JSON.stringify(event) + '\n', null, 'utf8'); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } + if (process.platform !== 'win32') chmodSync(approvalsPath, 0o600); +} + function writeApprovalEvent(event, { approvalsPath }) { - mkdirSync(dirname(approvalsPath), { recursive: true }); - appendFileSync(approvalsPath, JSON.stringify(event) + '\n', 'utf8'); + return withApprovalsLock( + approvalsPath, + () => appendApprovalEventUnlocked(event, approvalsPath) + ); } function generateApprovalId() { @@ -191,6 +267,8 @@ export function listApprovals({ env = process.env, status: statusFilter, workflo signature: grant.signature ? { method: grant.signature.method, key_fingerprint: grant.signature.key_fingerprint } : null, + approver_scope: grant.approver_scope ?? null, + unsigned_explicit: grant.unsigned_explicit === true, }); } records.sort((a, b) => (a.granted_at < b.granted_at ? -1 : 1)); @@ -244,17 +322,13 @@ export function claimApproval({ }); if (!grant) return null; const consumedAt = new Date(now()).toISOString(); - appendFileSync( - paths.approvals, - JSON.stringify({ - v: APPROVAL_RECORD_VERSION, - kind: 'consume', - approval_id: grant.approval_id, - execution_id: executionId, - consumed_at: consumedAt, - }) + '\n', - 'utf8' - ); + appendApprovalEventUnlocked({ + v: APPROVAL_RECORD_VERSION, + kind: 'consume', + approval_id: grant.approval_id, + execution_id: executionId, + consumed_at: consumedAt, + }, paths.approvals); return grant; }, lockOptions); } @@ -267,6 +341,8 @@ function buildApprovalSignaturePayload(grant) { workflow_id: grant.workflow_id, task_id: grant.task_id, task_hash: grant.task_hash, + risk_level: grant.risk_level ?? null, + approver_scope: grant.approver_scope ?? null, approver: grant.approver, reason: grant.reason ?? null, granted_at: grant.granted_at, @@ -275,7 +351,11 @@ function buildApprovalSignaturePayload(grant) { } export function verifyApprovalSignature(grant, { env = process.env } = {}) { - if (!grant.signature) return { verified: null, reason: 'unsigned' }; + if (!grant.signature) { + return grant.unsigned_explicit === true + ? { verified: null, reason: 'signing explicitly disabled' } + : { verified: false, reason: 'approval record is unexpectedly unsigned' }; + } const provider = getProvider(grant.signature.method?.replace(/-signature$/, '') || 'ssh'); if (!provider) return { verified: false, reason: `unknown signer "${grant.signature.method}"` }; @@ -321,7 +401,7 @@ export function grantApproval({ taskId, approver, reason, - ttlS = DEFAULT_TTL_S, + ttlS, signer, signingKey, env = process.env, @@ -336,7 +416,8 @@ export function grantApproval({ if (!approver) { throw Object.assign(new Error('approver is required (pass --by )'), { code: 'invalid_argument' }); } - const workflows = Array.isArray(manifest.workflows) ? manifest.workflows : []; + const expanded = expandManifestShorthands(manifest); + const workflows = Array.isArray(expanded.workflows) ? expanded.workflows : []; const workflow = workflowId ? workflows.find(w => w.id === workflowId) : (workflows.length === 1 ? workflows[0] : null); @@ -369,9 +450,35 @@ export function grantApproval({ ); } - const taskHash = computeTaskApprovalHash({ workflowId: workflow.id, task }); + const approverScope = task.approval.approver_scope ?? null; + if (!approverMatchesScope(approver, approverScope)) { + throw Object.assign( + new Error(`approver "${approver}" does not satisfy approval.approver_scope "${approverScope}"`), + { code: 'approval_scope_mismatch', approver_scope: approverScope } + ); + } + + const taskTimeoutS = task.approval.timeout_s ?? null; + const effectiveTtlS = ttlS ?? taskTimeoutS ?? DEFAULT_TTL_S; + if (!Number.isInteger(effectiveTtlS) || effectiveTtlS < 1) { + throw Object.assign(new Error('approval TTL must be an integer >= 1'), { code: 'invalid_argument' }); + } + if (taskTimeoutS != null && effectiveTtlS > taskTimeoutS) { + throw Object.assign( + new Error(`approval TTL ${effectiveTtlS}s exceeds task approval.timeout_s ${taskTimeoutS}s`), + { code: 'invalid_argument' } + ); + } + + const binding = buildEffectiveExecutionBinding({ + manifest, + expanded, + workflow, + task, + }); + const taskHash = computeTaskApprovalHash({ binding }); const grantedAt = new Date(now).toISOString(); - const expiresAt = new Date(now + ttlS * 1000).toISOString(); + const expiresAt = new Date(now + effectiveTtlS * 1000).toISOString(); const approvalId = generateApprovalId(); const grant = { @@ -382,25 +489,42 @@ export function grantApproval({ task_id: task.id, task_hash: taskHash, risk_level: task.approval.risk_level ?? null, + approver_scope: approverScope, approver, reason: reason ?? null, granted_at: grantedAt, expires_at: expiresAt, signature: null, + unsigned_explicit: false, }; const provider = resolveProvider({ signer, env }); - if (provider.name !== 'none') { + const unsignedExplicit = provider.name === 'none' && (signer === 'none' || env.AGENTCLI_SIGNER === 'none'); + if (provider.name === 'none') { + if (!unsignedExplicit) { + throw Object.assign( + new Error('unsigned approvals require an explicit signer="none" selection'), + { code: 'approval_signature_invalid' } + ); + } + grant.unsigned_explicit = true; + } else { const config = provider.resolve({ env, signingKey }); - if (config) { - const payload = buildApprovalSignaturePayload(grant); - const sigResult = provider.sign(payload, config); - if (sigResult.signed) { - grant.signature = sigResult.attestation; - } else { - grant.signature = null; - } + if (!config) { + throw Object.assign( + new Error(`signing provider "${provider.name}" has no usable signing credentials`), + { code: 'approval_signature_invalid' } + ); + } + const payload = buildApprovalSignaturePayload(grant); + const sigResult = provider.sign(payload, config); + if (!sigResult.signed) { + throw Object.assign( + new Error(`approval signing failed: ${sigResult.reason || 'provider did not return a signature'}`), + { code: 'approval_signature_invalid' } + ); } + grant.signature = sigResult.attestation; } const paths = getAgentcliPaths({ env }); @@ -412,6 +536,7 @@ export function grantApproval({ task_id: task.id, task_hash: taskHash, risk_level: task.approval.risk_level ?? null, + approver_scope: approverScope, approver, reason: reason ?? null, granted_at: grantedAt, @@ -419,6 +544,7 @@ export function grantApproval({ signature: grant.signature ? { method: grant.signature.method, key_fingerprint: grant.signature.key_fingerprint } : null, + unsigned_explicit: grant.unsigned_explicit, }; } diff --git a/src/audit.js b/src/audit.js index f29295a..550f869 100644 --- a/src/audit.js +++ b/src/audit.js @@ -1,24 +1,73 @@ -import { appendFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs'; -import { createHash } from 'node:crypto'; +import { + chmodSync, + closeSync, + constants as fsConstants, + existsSync, + fstatSync, + mkdirSync, + openSync, + readFileSync, + readSync, + writeFileSync, +} from 'node:fs'; +import { randomUUID } from 'node:crypto'; import { dirname } from 'node:path'; +import process from 'node:process'; -export function generateExecutionId(workflowId, taskId, timestamp) { - return createHash('sha256') - .update(`${workflowId}:${taskId}:${timestamp}`) - .digest('hex') - .slice(0, 32); +export function generateExecutionId(_workflowId, _taskId, _timestamp) { + return randomUUID().replaceAll('-', ''); } export function writeAuditRecord(record, { auditPath }) { - mkdirSync(dirname(auditPath), { recursive: true }); - appendFileSync(auditPath, JSON.stringify(record) + '\n', 'utf8'); + const auditDirectory = dirname(auditPath); + mkdirSync(auditDirectory, { recursive: true, mode: 0o700 }); + if (process.platform !== 'win32') chmodSync(auditDirectory, 0o700); + let descriptor; + try { + descriptor = openSync( + auditPath, + fsConstants.O_RDWR | + fsConstants.O_APPEND | + fsConstants.O_CREAT | + (fsConstants.O_NOFOLLOW || 0), + 0o600 + ); + let separator = ''; + const existingSize = fstatSync(descriptor).size; + if (existingSize > 0) { + const finalByte = Buffer.allocUnsafe(1); + readSync(descriptor, finalByte, 0, 1, existingSize - 1); + if (finalByte[0] !== 0x0A) separator = '\n'; + } + writeFileSync(descriptor, separator + JSON.stringify(record) + '\n', 'utf8'); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } + if (process.platform !== 'win32') chmodSync(auditPath, 0o600); } -export function readAuditLog({ auditPath, limit } = {}) { +export function readAuditLog({ auditPath, limit, onMalformed } = {}) { if (!auditPath || !existsSync(auditPath)) return []; - const content = readFileSync(auditPath, 'utf8').trim(); - if (!content) return []; - const records = content.split('\n').map(line => JSON.parse(line)); + const content = readFileSync(auditPath, 'utf8'); + if (!content.trim()) return []; + + const records = []; + for (const [index, rawLine] of content.split(/\r?\n/).entries()) { + const line = rawLine.trim(); + if (!line) continue; + try { + const record = JSON.parse(line); + if (!record || typeof record !== 'object' || Array.isArray(record)) { + throw new TypeError('audit record must be a JSON object'); + } + records.push(record); + } catch (error) { + if (typeof onMalformed === 'function') { + onMalformed({ lineNumber: index + 1, line: rawLine, error }); + } + } + } + if (limit) return records.slice(-limit); return records; } diff --git a/src/authorization-proof/certificate.js b/src/authorization-proof/certificate.js index 089415b..153099c 100644 --- a/src/authorization-proof/certificate.js +++ b/src/authorization-proof/certificate.js @@ -3,13 +3,141 @@ * * Verifies authorization proofs backed by X.509 certificates using * Node.js built-in crypto.X509Certificate (available since Node 15). - * Validates certificate validity period, subject/issuer claims, and - * optionally verifies the certificate chain against a CA certificate. + * Validates certificate validity, exact subject/issuer claims, its chain + * against a configured CA certificate, and proof of possession over the + * canonical manifest. */ -import { X509Certificate } from 'node:crypto'; +import { + createVerify, + verify as verifySignature, + X509Certificate, +} from 'node:crypto'; +import { canonicalStringify, hashString } from '../canonical.js'; +import { resolveValueFrom } from '../command.js'; import { registerVerifier } from './index.js'; +function normalizeDigest(value) { + return typeof value === 'string' ? value.replace(/^sha256:/, '') : null; +} + +function resolveCanonicalManifest(ctx = {}) { + const source = ctx.manifest ?? ctx.manifestContent; + if (source === undefined || source === null) { + return { error: 'canonical manifest content is required for certificate proof of possession' }; + } + + let manifest; + try { + if (Buffer.isBuffer(source)) manifest = JSON.parse(source.toString('utf8')); + else if (typeof source === 'string') manifest = JSON.parse(source); + else if (typeof source === 'object' && !Array.isArray(source)) manifest = source; + else return { error: 'manifest content must be a JSON object' }; + } catch (error) { + return { error: `manifest content must be valid JSON: ${error.message}` }; + } + + const content = canonicalStringify(manifest); + const digest = hashString(content); + if (ctx.manifestDigest && normalizeDigest(ctx.manifestDigest) !== normalizeDigest(digest)) { + return { error: 'provided manifest digest does not match canonical manifest content' }; + } + return { content, digest }; +} + +function parseCertificateProof(proof) { + if (proof && typeof proof === 'object' && !Buffer.isBuffer(proof)) { + return { + certificate: proof.certificate, + signature: proof.signature, + }; + } + if (typeof proof !== 'string' && !Buffer.isBuffer(proof)) { + throw new TypeError('certificate proof must be a PEM string or JSON proof envelope'); + } + const text = Buffer.isBuffer(proof) ? proof.toString('utf8') : proof.trim(); + if (text.startsWith('{')) { + const parsed = JSON.parse(text); + return { + certificate: parsed.certificate, + signature: parsed.signature, + }; + } + return { certificate: text, signature: null }; +} + +function verifyProofOfPossession(cert, signature, manifestContent) { + if (typeof signature !== 'string' || signature.trim() === '') { + return { verified: false, reason: 'certificate proof is missing a manifest signature' }; + } + if (!manifestContent) { + return { verified: false, reason: 'canonical manifest content is required' }; + } + + let signatureBytes; + try { + const normalized = signature.replace(/\s+/g, ''); + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) { + throw new TypeError('signature is not valid base64'); + } + signatureBytes = Buffer.from(normalized, 'base64'); + } catch (error) { + return { verified: false, reason: error.message }; + } + + try { + const keyType = cert.publicKey.asymmetricKeyType; + let verified; + if (keyType === 'ed25519' || keyType === 'ed448') { + verified = verifySignature( + null, + Buffer.from(manifestContent, 'utf8'), + cert.publicKey, + signatureBytes + ); + } else if (keyType === 'rsa' || keyType === 'rsa-pss' || keyType === 'ec') { + const verifier = createVerify('SHA256'); + verifier.update(manifestContent); + verified = verifier.verify(cert.publicKey, signatureBytes); + } else { + return { verified: false, reason: `unsupported certificate key type: ${keyType}` }; + } + return verified + ? { verified: true } + : { verified: false, reason: 'manifest signature verification failed' }; + } catch (error) { + return { verified: false, reason: `manifest signature verification error: ${error.message}` }; + } +} + +export function resolveCertificateVerificationContext(profile = {}, ctx = {}) { + let caCert = ctx.caCert || profile.ca_certificate || profile.public_key || null; + let caCertError = null; + + if (!caCert && profile.ca_certificate_from) { + try { + caCert = resolveValueFrom(profile.ca_certificate_from, { + env: ctx.env, + cwd: ctx.cwd, + allowCommand: ctx.allowCommand === true, + }); + } catch (error) { + caCertError = error.message; + } + } + + const manifest = resolveCanonicalManifest(ctx); + return { + ...ctx, + caCert, + caCertError, + manifestContent: manifest.content || null, + manifestDigest: manifest.digest || null, + manifestContextError: manifest.error || null, + requireProofOfPossession: ctx.requireProofOfPossession ?? true, + }; +} + /** * Check whether a certificate subject or subjectAltName matches an expected value. * @@ -22,18 +150,16 @@ import { registerVerifier } from './index.js'; * @returns {boolean} True if the expected value matches. */ function subjectMatches(cert, expected) { - const normalizedExpected = expected.toLowerCase(); + const normalizedExpected = expected.trim().toLowerCase(); - // Check the subject DN const subject = cert.subject || ''; - if (subject.toLowerCase().includes(normalizedExpected)) { + if (subject.trim().toLowerCase() === normalizedExpected) { return true; } - // Check individual DN components for exact value match - // Subject DN format is like "CN=example\nO=Org\nOU=Unit" const subjectLines = subject.split('\n'); for (const line of subjectLines) { + if (line.trim().toLowerCase() === normalizedExpected) return true; const eqIdx = line.indexOf('='); if (eqIdx !== -1) { const value = line.slice(eqIdx + 1).trim(); @@ -43,10 +169,14 @@ function subjectMatches(cert, expected) { } } - // Check subjectAltName const san = cert.subjectAltName || ''; - if (san && san.toLowerCase().includes(normalizedExpected)) { - return true; + for (const entry of san.split(/,\s*/)) { + const normalizedEntry = entry.trim().toLowerCase(); + if (normalizedEntry === normalizedExpected) return true; + const colonIdx = entry.indexOf(':'); + if (colonIdx !== -1 && entry.slice(colonIdx + 1).trim().toLowerCase() === normalizedExpected) { + return true; + } } return false; @@ -60,16 +190,16 @@ function subjectMatches(cert, expected) { * @returns {boolean} True if the expected value matches. */ function issuerMatches(cert, expected) { - const normalizedExpected = expected.toLowerCase(); + const normalizedExpected = expected.trim().toLowerCase(); const issuer = cert.issuer || ''; - if (issuer.toLowerCase().includes(normalizedExpected)) { + if (issuer.trim().toLowerCase() === normalizedExpected) { return true; } - // Check individual DN components const issuerLines = issuer.split('\n'); for (const line of issuerLines) { + if (line.trim().toLowerCase() === normalizedExpected) return true; const eqIdx = line.indexOf('='); if (eqIdx !== -1) { const value = line.slice(eqIdx + 1).trim(); @@ -96,30 +226,53 @@ const certificateVerifier = { * @param {object} _ctx - Validation context. * @returns {{ valid: boolean, errors?: Array<{ field: string, message: string }> }} */ - validateProfile(profile, _ctx) { + validateProfile(profile, ctx = {}) { const errors = []; - if (profile.proof) { - if (!profile.proof.value_from) { + if (!profile.proof || !profile.proof.value_from) { + errors.push({ + field: 'proof', + message: 'proof must use value_from to reference the certificate proof envelope', + }); + } else { + const vf = profile.proof.value_from; + const sources = ['env', 'file', 'literal', 'command'] + .filter(source => vf[source] !== undefined); + if (sources.length !== 1) { errors.push({ - field: 'proof', - message: 'proof must use value_from to reference the certificate', + field: 'proof.value_from', + message: 'value_from must specify exactly one of env, file, literal, or command', + }); + } else if (sources[0] === 'literal') { + errors.push({ + field: 'proof.value_from.literal', + message: 'certificate proof envelopes must be stored outside the manifest to avoid a circular signature', }); - } else { - const vf = profile.proof.value_from; - if (!vf.env && !vf.file && !vf.literal && !vf.command) { - errors.push({ - field: 'proof.value_from', - message: 'value_from must specify env, file, literal, or command source', - }); - } } - } else { + } + + if ( + !profile.ca_certificate && + !profile.ca_certificate_from && + !profile.public_key && + !ctx.caCert + ) { errors.push({ - field: 'proof', - message: 'proof is required for certificate verification', + field: 'verify', + message: 'certificate verification requires ca_certificate, ca_certificate_from, or public_key', }); } + const configuredCa = profile.ca_certificate || profile.public_key; + if (configuredCa) { + try { + new X509Certificate(configuredCa); + } catch (error) { + errors.push({ + field: profile.ca_certificate ? 'ca_certificate' : 'public_key', + message: `configured CA is not a valid X.509 certificate: ${error.message}`, + }); + } + } if (profile.issuer !== undefined && profile.issuer !== null) { if (typeof profile.issuer !== 'string' || profile.issuer === '') { @@ -136,6 +289,18 @@ const certificateVerifier = { field: 'claims', message: 'claims must be an object when present', }); + } else { + for (const claim of ['subject', 'issuer']) { + if ( + profile.claims[claim] !== undefined && + (typeof profile.claims[claim] !== 'string' || profile.claims[claim].trim() === '') + ) { + errors.push({ + field: `claims.${claim}`, + message: `${claim} claim must be a non-empty string when present`, + }); + } + } } } @@ -147,9 +312,8 @@ const certificateVerifier = { /** * Verify a resolved certificate proof against the declared profile. * - * Parses the PEM-encoded certificate, validates its validity period, - * checks subject/issuer claims, and optionally verifies the certificate - * chain against a CA certificate. + * Parses the proof envelope, validates the certificate and CA chain, and + * verifies its signature over the canonical manifest. * * @param {string} proof - The resolved PEM-encoded certificate string. * @param {object} profile - The authorization proof profile. @@ -157,13 +321,29 @@ const certificateVerifier = { * @returns {object} Verification result. */ verifyProof(proof, profile, ctx) { - const context = ctx || {}; + const context = resolveCertificateVerificationContext(profile, ctx || {}); const claims = (profile && profile.claims) || {}; + let parsedProof; + try { + parsedProof = parseCertificateProof(proof); + } catch (error) { + return { + verified: false, + method: 'certificate', + reason: `failed to parse certificate proof: ${error.message}`, + claims_validated: false, + signature_verified: false, + proof_of_possession_verified: false, + manifest_digest: context.manifestDigest || null, + verified_at: new Date().toISOString(), + }; + } + // Parse the certificate let cert; try { - cert = new X509Certificate(proof); + cert = new X509Certificate(parsedProof.certificate); } catch (err) { return { verified: false, @@ -173,6 +353,7 @@ const certificateVerifier = { subject_alt_name: null, claims_validated: false, signature_verified: false, + proof_of_possession_verified: false, signature_verification_reason: `failed to parse certificate: ${err.message}`, not_before: null, not_after: null, @@ -190,6 +371,11 @@ const certificateVerifier = { let expired = false; let notYetValid = false; const validityErrors = []; + const certificateUsageValid = cert.ca !== true; + + if (!certificateUsageValid) { + validityErrors.push('presented authorization certificate must not be a CA certificate'); + } if (now > validTo) { expired = true; @@ -237,8 +423,13 @@ const certificateVerifier = { if (caCertObj) { try { - const issuedBy = cert.checkIssued(caCertObj); - if (!issuedBy) { + const caValidFrom = new Date(caCertObj.validFrom); + const caValidTo = new Date(caCertObj.validTo); + if (caCertObj.ca !== true) { + signatureReason = 'provided trust certificate is not a CA certificate'; + } else if (now < caValidFrom || now > caValidTo) { + signatureReason = 'provided CA certificate is outside its validity period'; + } else if (!cert.checkIssued(caCertObj)) { signatureReason = 'certificate was not issued by the provided CA'; } else { // Verify the cryptographic signature @@ -256,17 +447,29 @@ const certificateVerifier = { } } - // Overall verification: claims valid, not expired, not yet valid, and signature valid + if (!context.caCert && context.caCertError) { + signatureReason = `CA certificate resolution failed: ${context.caCertError}`; + } + + const possession = context.manifestContextError + ? { verified: false, reason: context.manifestContextError } + : context.requireProofOfPossession + ? verifyProofOfPossession(cert, parsedProof.signature, context.manifestContent) + : { verified: true }; + + // Overall verification requires a trusted chain and proof that the holder + // of the certificate private key signed the canonical manifest. const timeValid = !expired && !notYetValid; - const verified = claimsValid && timeValid && signatureValid; + const verified = claimsValid && timeValid && certificateUsageValid && signatureValid && possession.verified; // Build composite reason if not verified let reason = null; if (!verified) { const reasons = []; if (!claimsValid) reasons.push(...claimsErrors); - if (!timeValid) reasons.push(...validityErrors); + if (!timeValid || !certificateUsageValid) reasons.push(...validityErrors); if (!signatureValid && signatureReason) reasons.push(signatureReason); + if (!possession.verified && possession.reason) reasons.push(possession.reason); reason = reasons.join('; '); } @@ -278,7 +481,8 @@ const certificateVerifier = { subject_alt_name: cert.subjectAltName || null, claims_validated: claimsValid, signature_verified: signatureValid, - signature_verification_reason: reason, + proof_of_possession_verified: possession.verified, + signature_verification_reason: verified ? null : reason, not_before: cert.validFrom, not_after: cert.validTo, serial_number: cert.serialNumber, @@ -309,8 +513,10 @@ const certificateVerifier = { verifier: 'certificate', claims_validated: result.claims_validated, signature_verified: result.signature_verified, + proof_of_possession_verified: result.proof_of_possession_verified, fingerprint: result.fingerprint, serial_number: result.serial_number, + reason: result.signature_verification_reason || result.reason || null, }; }, }; diff --git a/src/authorization-proof/detached-signature.js b/src/authorization-proof/detached-signature.js index e6ae7e8..c0613f1 100644 --- a/src/authorization-proof/detached-signature.js +++ b/src/authorization-proof/detached-signature.js @@ -2,16 +2,18 @@ * Detached-signature authorization proof verifier. * * Verifies detached cryptographic signatures over the manifest payload - * using Node.js built-in crypto. Supports RSA and ECDSA key-based + * using Node.js built-in crypto. Supports RSA, ECDSA, and EdDSA key-based * verification, and optionally delegates to ssh-keygen for SSH-style * allowed-signers verification. */ -import { createHash, createVerify, createPublicKey } from 'node:crypto'; +import { createVerify, createPublicKey, randomUUID, verify as verifySignature } from 'node:crypto'; import { spawnSync } from 'node:child_process'; import { existsSync, writeFileSync, unlinkSync } from 'node:fs'; -import { join } from 'node:path'; +import { isAbsolute, join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; +import process from 'node:process'; +import { canonicalStringify, hashString } from '../canonical.js'; import { registerVerifier } from './index.js'; /** @@ -21,7 +23,7 @@ import { registerVerifier } from './index.js'; * OpenSSL algorithm string for use with crypto.createVerify. * * @param {string} pem - PEM-encoded public key. - * @returns {string|null} Algorithm identifier (e.g. 'RSA-SHA256', 'SHA256'), or null for EdDSA keys. + * @returns {string|null|undefined} Algorithm identifier, null for EdDSA, or undefined for unsupported keys. */ function detectAlgorithm(pem) { try { @@ -36,12 +38,82 @@ function detectAlgorithm(pem) { if (type === 'ed25519' || type === 'ed448') { return null; // Ed25519/Ed448 use sign/verify directly, not createVerify } - return 'RSA-SHA256'; // fallback + return undefined; } catch (_err) { - return 'RSA-SHA256'; // conservative default + return undefined; } } +function normalizeDigest(value) { + return typeof value === 'string' ? value.replace(/^sha256:/, '') : null; +} + +function canonicalManifestContext(ctx = {}) { + const manifestValue = ctx.manifest ?? ctx.manifestContent; + if (manifestValue === undefined || manifestValue === null) { + return { error: 'canonical manifest content is required' }; + } + + let manifest; + if (Buffer.isBuffer(manifestValue)) { + try { + manifest = JSON.parse(manifestValue.toString('utf8')); + } catch (error) { + return { error: `manifest content must be valid JSON: ${error.message}` }; + } + } else if (typeof manifestValue === 'string') { + try { + manifest = JSON.parse(manifestValue); + } catch (error) { + return { error: `manifest content must be valid JSON: ${error.message}` }; + } + } else if (typeof manifestValue === 'object' && !Array.isArray(manifestValue)) { + manifest = manifestValue; + } else { + return { error: 'manifest content must be a JSON object' }; + } + + const content = canonicalStringify(manifest); + const digest = hashString(content); + if ( + ctx.manifestDigest && + normalizeDigest(ctx.manifestDigest) !== normalizeDigest(digest) + ) { + return { error: 'provided manifest digest does not match canonical manifest content' }; + } + return { content, digest }; +} + +export function resolveDetachedSignatureVerificationContext(profile = {}, ctx = {}) { + const canonical = canonicalManifestContext(ctx); + const configuredAllowedSigners = ctx.allowedSignersPath || profile.allowed_signers || null; + const allowedSignersPath = configuredAllowedSigners && !isAbsolute(configuredAllowedSigners) + ? resolve(ctx.cwd || process.cwd(), configuredAllowedSigners) + : configuredAllowedSigners; + return { + ...ctx, + manifestContent: canonical.content || null, + manifestDigest: canonical.digest || null, + manifestContextError: canonical.error || null, + trustedKey: ctx.trustedKey || profile.public_key || null, + allowedSignersPath, + principal: ctx.principal || profile.principal || 'agentcli', + namespace: ctx.namespace || profile.namespace || 'agentcli', + }; +} + +function decodeBase64Signature(proof) { + if (Buffer.isBuffer(proof)) return proof; + if (typeof proof !== 'string' || proof.trim() === '') { + throw new TypeError('detached signature must be a non-empty base64 string or Buffer'); + } + const normalized = proof.replace(/\s+/g, ''); + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) { + throw new TypeError('detached signature is not valid base64'); + } + return Buffer.from(normalized, 'base64'); +} + /** * Verify a detached signature using ssh-keygen -Y verify. * @@ -62,7 +134,7 @@ function verifySshSignature(signature, content, options) { const tmpSigPath = join( tmpdir(), - `agentcli-detached-verify-${Date.now()}-${Math.random().toString(36).slice(2)}.sig` + `agentcli-detached-verify-${randomUUID()}.sig` ); try { @@ -111,28 +183,46 @@ const detachedSignatureVerifier = { * @param {object} _ctx - Validation context. * @returns {{ valid: boolean, errors?: Array<{ field: string, message: string }> }} */ - validateProfile(profile, _ctx) { + validateProfile(profile, ctx = {}) { const errors = []; - if (profile.proof) { - if (!profile.proof.value_from) { + if (!profile.proof || !profile.proof.value_from) { + errors.push({ + field: 'proof', + message: 'proof must use value_from to reference the detached signature', + }); + } else { + const vf = profile.proof.value_from; + const sources = ['env', 'file', 'literal', 'command'] + .filter(source => vf[source] !== undefined); + if (sources.length !== 1) { errors.push({ - field: 'proof', - message: 'proof must use value_from to reference the detached signature', + field: 'proof.value_from', + message: 'value_from must specify exactly one of env, file, literal, or command', + }); + } else if (sources[0] === 'literal') { + errors.push({ + field: 'proof.value_from.literal', + message: 'detached signatures must be stored outside the manifest to avoid a circular signature', }); - } else { - const vf = profile.proof.value_from; - if (!vf.env && !vf.file && !vf.literal && !vf.command) { - errors.push({ - field: 'proof.value_from', - message: 'value_from must specify env, file, literal, or command source', - }); - } } - } else { + } + + if ( + !profile.public_key && + !profile.allowed_signers && + !ctx.trustedKey && + !ctx.allowedSignersPath + ) { errors.push({ - field: 'proof', - message: 'proof is required for detached-signature verification', + field: 'verify', + message: 'detached-signature verification requires public_key or allowed_signers', + }); + } + if (profile.public_key && detectAlgorithm(profile.public_key) === undefined) { + errors.push({ + field: 'public_key', + message: 'public_key is not a supported RSA, ECDSA, Ed25519, or Ed448 key', }); } @@ -165,32 +255,23 @@ const detachedSignatureVerifier = { * @returns {object} Verification result. */ verifyProof(proof, profile, ctx) { - const context = ctx || {}; - - // Compute or use pre-computed manifest digest - let digest = context.manifestDigest || null; + const context = resolveDetachedSignatureVerificationContext(profile, ctx || {}); + const digest = context.manifestDigest; - // Manifest content is required for detached signature verification - if (!context.manifestContent && !digest) { + if (context.manifestContextError || !context.manifestContent) { return { verified: false, method: 'detached-signature', issuer: (profile && profile.issuer) || null, signature_verified: false, - signature_verification_reason: 'manifest content required for detached signature verification', - manifest_digest: null, + signature_verification_reason: context.manifestContextError || 'canonical manifest content is required', + manifest_digest: digest, verified_at: new Date().toISOString(), }; } - if (!digest && context.manifestContent) { - digest = createHash('sha256') - .update(context.manifestContent) - .digest('hex'); - } - // SSH-style verification path - if (context.sshVerify && context.allowedSignersPath) { + if (context.allowedSignersPath) { const sshResult = verifySshSignature( proof, context.manifestContent, @@ -214,33 +295,30 @@ const detachedSignatureVerifier = { // PEM public key verification path if (context.trustedKey) { - const algorithm = context.algorithm || detectAlgorithm(context.trustedKey); - - if (!algorithm) { - // Ed25519/Ed448 keys use a different verification API (crypto.verify) - // that does not go through createVerify. Return informative result. - return { - verified: false, - method: 'detached-signature', - issuer: (profile && profile.issuer) || null, - signature_verified: false, - signature_verification_reason: 'Ed25519/Ed448 detached signature verification requires SSH verification path', - manifest_digest: digest, - verified_at: new Date().toISOString(), - }; - } + const algorithm = context.algorithm ?? detectAlgorithm(context.trustedKey); let verificationReason = null; let signatureValid; try { - const verifier = createVerify(algorithm); - verifier.update(context.manifestContent); - - const proofData = Buffer.isBuffer(proof) ? proof.toString('base64') : proof; - signatureValid = verifier.verify(context.trustedKey, proofData, 'base64'); + const signature = decodeBase64Signature(proof); + if (algorithm === null) { + signatureValid = verifySignature( + null, + Buffer.from(context.manifestContent, 'utf8'), + context.trustedKey, + signature + ); + } else if (algorithm) { + const verifier = createVerify(algorithm); + verifier.update(context.manifestContent); + signatureValid = verifier.verify(context.trustedKey, signature); + } else { + signatureValid = false; + verificationReason = 'unsupported trusted public key type'; + } - if (!signatureValid) { + if (!signatureValid && !verificationReason) { verificationReason = 'signature verification failed'; } } catch (err) { @@ -289,6 +367,7 @@ const detachedSignatureVerifier = { manifest_digest: result.manifest_digest || null, verifier: 'detached-signature', signature_verified: result.signature_verified, + reason: result.signature_verification_reason || result.reason || null, }; }, }; diff --git a/src/authorization-proof/index.js b/src/authorization-proof/index.js index 1c148d6..a274186 100644 --- a/src/authorization-proof/index.js +++ b/src/authorization-proof/index.js @@ -70,3 +70,104 @@ export function resolveVerifier(methodName) { } return verifier; } + +/** + * Run method-specific validation and normalize malformed verifier responses + * to a closed failure. + */ +export function validateAuthorizationProofProfile(profile, ctx = {}) { + if (!profile || typeof profile !== 'object' || Array.isArray(profile)) { + return { + valid: false, + errors: [{ field: '$', message: 'authorization proof profile must be an object' }], + }; + } + if (typeof profile.method !== 'string' || profile.method.length === 0) { + return { + valid: false, + errors: [{ field: 'method', message: 'authorization proof method is required' }], + }; + } + + let verifier; + try { + verifier = resolveVerifier(profile.method); + } catch (error) { + return { + valid: false, + errors: [{ field: 'method', message: error.message }], + }; + } + + try { + const result = verifier.validateProfile(profile, ctx); + if (!result || result.valid !== true) { + return { + valid: false, + errors: Array.isArray(result?.errors) && result.errors.length > 0 + ? result.errors + : [{ field: '$', message: `verifier "${profile.method}" rejected the profile` }], + }; + } + return { valid: true, errors: [] }; + } catch (error) { + return { + valid: false, + errors: [{ field: '$', message: `profile validation failed: ${error.message}` }], + }; + } +} + +/** + * Throw a structured error when a proof profile is not executable. + */ +export function assertValidAuthorizationProofProfile(profile, ctx = {}) { + const validation = validateAuthorizationProofProfile(profile, ctx); + if (!validation.valid) { + const detail = validation.errors + .map(error => `${error.field}: ${error.message}`) + .join('; '); + throw Object.assign( + new Error(`Invalid authorization proof profile: ${detail}`), + { + code: 'authorization_proof_invalid', + validation, + } + ); + } + return resolveVerifier(profile.method); +} + +/** + * Verify a proof after validating the profile. Any malformed provider result + * is converted to verified:false so callers cannot accidentally treat it as + * a successful verification. + */ +export async function verifyAuthorizationProof(proof, profile, ctx = {}) { + const verifier = assertValidAuthorizationProofProfile(profile, ctx); + let verificationContext = ctx; + try { + if (profile.method === 'jwt') { + const { resolveJwtVerificationContext } = await import('./jwt.js'); + verificationContext = await resolveJwtVerificationContext(proof, profile, ctx); + } + const result = await verifier.verifyProof(proof, profile, verificationContext); + if (!result || result.verified !== true) { + return { + ...(result && typeof result === 'object' ? result : {}), + verified: false, + method: result?.method || profile.method, + reason: result?.reason || 'authorization proof verification did not succeed', + }; + } + return result; + } catch (error) { + return { + verified: false, + method: profile.method, + reason: `authorization proof verification failed: ${error.message}`, + manifest_digest: verificationContext.manifestDigest || null, + verified_at: new Date().toISOString(), + }; + } +} diff --git a/src/authorization-proof/jwt.js b/src/authorization-proof/jwt.js index e82f01a..ada6175 100644 --- a/src/authorization-proof/jwt.js +++ b/src/authorization-proof/jwt.js @@ -4,13 +4,13 @@ * Handles JWT verification for manifest authorization proofs using * pure Node.js built-in crypto -- no external dependencies. * - * Supports RS256 and ES256 signature verification when a trusted - * public key is provided. Without a trusted key, structural and - * claims validation is still performed but cryptographic signature - * verification is skipped. + * Supports RS256 and ES256 signature verification. Structural and claims + * validation is reported separately, but a JWT is never marked verified + * unless its cryptographic signature is verified by a trusted key. */ import { createPublicKey, createVerify } from 'node:crypto'; +import { canonicalDigest } from '../canonical.js'; import { registerVerifier } from './index.js'; const DEFAULT_JWKS_CACHE_TTL_MS = 5 * 60 * 1000; @@ -26,6 +26,7 @@ const AUDIT_SAFE_CLAIMS = [ 'step_up_policy', 'session_id', 'request_id', + 'manifest_digest', ]; const jwksCache = new Map(); @@ -296,7 +297,11 @@ async function resolveJwtTrustedKey(proof, profile, ctx = {}) { export async function resolveJwtVerificationContext(proof, profile, ctx = {}) { const context = { ...ctx, - requireSignature: ctx.requireSignature ?? profile?.verify?.required === true, + manifestDigest: ctx.manifestDigest ?? ( + ctx.manifest == null ? null : canonicalDigest(ctx.manifest) + ), + requireSignature: ctx.requireSignature ?? true, + requireManifestBinding: ctx.requireManifestBinding ?? true, }; const resolved = await resolveJwtTrustedKey(proof, profile, context); return { @@ -398,13 +403,13 @@ function validateDeclaredClaims(declaredClaims, payload) { if (!actualValue.includes(expectedValue)) { errors.push({ field: `claims.${key}`, - message: `Claim "${jwtKey}" value does not include expected "${expectedValue}"`, + message: `Claim "${jwtKey}" does not satisfy the declared value`, }); } } else if (actualValue !== expectedValue) { errors.push({ field: `claims.${key}`, - message: `Claim "${jwtKey}" expected "${expectedValue}", got "${actualValue}"`, + message: `Claim "${jwtKey}" does not satisfy the declared value`, }); } } @@ -428,7 +433,7 @@ const jwtVerifier = { * @param {object} _ctx - Validation context. * @returns {{ valid: boolean, errors?: Array<{ field: string, message: string }> }} */ - validateProfile(profile, _ctx) { + validateProfile(profile, ctx = {}) { const errors = []; if (profile.issuer !== undefined && profile.issuer !== null) { @@ -440,20 +445,25 @@ const jwtVerifier = { } } - if (profile.proof) { - if (!profile.proof.value_from) { + if (!profile.proof || !profile.proof.value_from) { + errors.push({ + field: 'proof', + message: 'proof must use value_from with env, file, literal, or command source', + }); + } else { + const vf = profile.proof.value_from; + const sources = ['env', 'file', 'literal', 'command'] + .filter(source => vf[source] !== undefined); + if (sources.length !== 1) { errors.push({ - field: 'proof', - message: 'proof must use value_from with env, file, literal, or command source', + field: 'proof.value_from', + message: 'value_from must specify exactly one of env, file, literal, or command', + }); + } else if (sources[0] === 'literal') { + errors.push({ + field: 'proof.value_from.literal', + message: 'JWT proofs must be stored outside the manifest to bind its canonical digest', }); - } else { - const vf = profile.proof.value_from; - if (!vf.env && !vf.file && !vf.literal && !vf.command) { - errors.push({ - field: 'proof.value_from', - message: 'value_from must specify env, file, literal, or command source', - }); - } } } @@ -472,6 +482,21 @@ const jwtVerifier = { field: 'jwks_uri', message: 'jwks_uri must be a non-empty string when present', }); + } else { + try { + const uri = new URL(profile.jwks_uri); + if (uri.protocol !== 'https:' && ctx.allowInsecureJwks !== true) { + errors.push({ + field: 'jwks_uri', + message: 'jwks_uri must use HTTPS', + }); + } + } catch { + errors.push({ + field: 'jwks_uri', + message: 'jwks_uri must be a valid URL', + }); + } } } @@ -481,17 +506,26 @@ const jwtVerifier = { field: 'public_key', message: 'public_key must be a non-empty string when present', }); + } else { + try { + normalizeVerificationKey(profile.public_key); + } catch (error) { + errors.push({ + field: 'public_key', + message: `public_key is not a valid verification key: ${error.message}`, + }); + } } } if ( - profile.verify?.required === true && !isNonEmptyString(profile.public_key) && - !isNonEmptyString(profile.jwks_uri) + !isNonEmptyString(profile.jwks_uri) && + !ctx.trustedKey ) { errors.push({ - field: 'verify.required', - message: 'verify.required for jwt proofs requires public_key or jwks_uri', + field: 'verify', + message: 'jwt proof verification requires public_key or jwks_uri', }); } @@ -504,8 +538,8 @@ const jwtVerifier = { * Verify a resolved JWT proof against the declared profile. * * Parses and validates JWT structure, checks expiry and not-before claims, - * validates declared claims, and optionally verifies the cryptographic - * signature when a trusted key is available. + * validates declared claims and the canonical manifest binding, then + * verifies the cryptographic signature with configured trust material. * * @param {string} proof - The resolved JWT string. * @param {object} profile - The authorization proof profile. @@ -515,6 +549,7 @@ const jwtVerifier = { verifyProof(proof, profile, ctx) { const context = ctx || {}; const signatureRequired = Boolean(context.requireSignature); + const manifestBindingRequired = context.requireManifestBinding !== false; // Validate proof is a non-empty string if (!proof || typeof proof !== 'string') { @@ -558,7 +593,7 @@ const jwtVerifier = { return { verified: false, method: 'jwt', - reason: `JWT issuer expected "${profile.issuer}", got "${payload.iss ?? '(missing)'}"`, + reason: 'JWT issuer does not match the declared issuer', claims_validated: false, signature_verified: false, }; @@ -569,7 +604,7 @@ const jwtVerifier = { return { verified: false, method: 'jwt', - reason: `JWT audience does not include expected "${profile.audience}"`, + reason: 'JWT audience does not satisfy the declared audience', claims_validated: false, signature_verified: false, }; @@ -636,6 +671,23 @@ const jwtVerifier = { } } + let manifestBound = !manifestBindingRequired; + let manifestBindingReason = null; + if (manifestBindingRequired) { + if (typeof context.manifestDigest !== 'string' || context.manifestDigest.length === 0) { + manifestBindingReason = 'trusted manifest digest is required for JWT authorization proof verification'; + } else if (typeof payload.manifest_digest !== 'string') { + manifestBindingReason = 'JWT is missing required manifest_digest claim'; + } else if ( + payload.manifest_digest.replace(/^sha256:/, '') !== + context.manifestDigest.replace(/^sha256:/, '') + ) { + manifestBindingReason = 'JWT manifest_digest claim does not match the canonical manifest'; + } else { + manifestBound = true; + } + } + // Attempt signature verification let signatureVerified = false; let signatureReason = context.trustedKeyError || 'no trusted key available for signature verification'; @@ -660,26 +712,13 @@ const jwtVerifier = { decodedClaims[claim] = payload[claim]; } } - // Include custom claims declared in the profile - if (profile.claims && typeof profile.claims === 'object') { - for (const key of Object.keys(profile.claims)) { - const jwtKey = CLAIM_MAPPINGS[key] || key; - if (payload[jwtKey] !== undefined && decodedClaims[jwtKey] === undefined) { - decodedClaims[jwtKey] = payload[jwtKey]; - } - } - } + // Arbitrary claims may be validated, but only the explicit audit-safe + // allowlist above is retained. A manifest author cannot opt a credential + // or other sensitive custom claim into audit output by naming it here. - // Determine overall verification: all checks must pass - // Structure and claims are validated at this point. - // If a trusted key was provided, signature must also verify. - // If no trusted key, claims-only validation counts as verified (signature_verified remains false). - // If a trusted key was provided, signature must also pass. - const verified = signatureRequired - ? signatureVerified - : context.trustedKey - ? signatureVerified - : true; + // Claims-only parsing is useful diagnostics, not authorization. A JWT is + // verified only after cryptographic verification by a trusted key. + const verified = signatureVerified && manifestBound; const result = { verified, @@ -689,6 +728,8 @@ const jwtVerifier = { claims_validated: true, signature_verified: signatureVerified, signature_required: signatureRequired, + manifest_binding_required: manifestBindingRequired, + manifest_bound: manifestBound, decoded_claims: decodedClaims, key_id: context.trustedKeyId || header.kid || null, key_source: context.trustedKeySource || null, @@ -700,7 +741,10 @@ const jwtVerifier = { result.signature_verification_reason = signatureReason; } if (!verified) { - result.reason = signatureReason || 'JWT verification failed'; + result.reason = [ + !signatureVerified ? signatureReason : null, + !manifestBound ? manifestBindingReason : null, + ].filter(Boolean).join('; ') || 'JWT verification failed'; } return result; @@ -727,6 +771,8 @@ const jwtVerifier = { claims_validated: result.claims_validated, signature_verified: result.signature_verified, signature_required: result.signature_required, + manifest_binding_required: result.manifest_binding_required, + manifest_bound: result.manifest_bound, decoded_claims: result.decoded_claims || null, key_id: result.key_id || null, key_source: result.key_source || null, diff --git a/src/canonical.js b/src/canonical.js new file mode 100644 index 0000000..7ac1817 --- /dev/null +++ b/src/canonical.js @@ -0,0 +1,43 @@ +import { createHash } from 'node:crypto'; + +function normalizePrimitive(value) { + if (value === undefined) return null; + if (typeof value === 'bigint') return value.toString(); + if (typeof value === 'number' && !Number.isFinite(value)) { + throw new TypeError('Canonical values must contain only finite numbers'); + } + return value; +} + +export function sortKeysDeep(value) { + if (value === null || typeof value !== 'object') { + return normalizePrimitive(value); + } + + if (Array.isArray(value)) { + return value.map(item => sortKeysDeep(item)); + } + + const sorted = {}; + for (const key of Object.keys(value).sort()) { + sorted[key] = sortKeysDeep(value[key]); + } + return sorted; +} + +export function canonicalStringify(value) { + return JSON.stringify(sortKeysDeep(value)); +} + +export function hashString(value, { prefix = true } = {}) { + const digest = createHash('sha256').update(String(value), 'utf8').digest('hex'); + return prefix ? `sha256:${digest}` : digest; +} + +export function hashNullableString(value, options) { + return value == null ? null : hashString(value, options); +} + +export function canonicalDigest(value, { prefix = true } = {}) { + return hashString(canonicalStringify(value), { prefix }); +} diff --git a/src/capabilities.js b/src/capabilities.js index b90452f..2ab6f04 100644 --- a/src/capabilities.js +++ b/src/capabilities.js @@ -28,9 +28,10 @@ export function querySchedulerCapabilities(runner) { } /** - * Merge static features with runtime capabilities. - * Static features are the floor -- runtime can upgrade false->true but never downgrade true->false. - * String values are replaced by runtime values when present. + * Merge static declarations with observed runtime capabilities. + * When a runtime reports a known feature, the observed value is authoritative. + * Static values are used only for features omitted by the runtime or when no + * runtime capability response is available. */ export function resolveEffectiveFeatures(targetName, runtimeCapabilities) { const target = TARGETS[targetName]; @@ -46,14 +47,8 @@ export function resolveEffectiveFeatures(targetName, runtimeCapabilities) { for (const [key, runtimeValue] of Object.entries(runtimeFeatures)) { if (!(key in effective)) continue; // ignore unknown keys from runtime - const staticValue = effective[key]; - - if (typeof staticValue === 'boolean') { - // Boolean: runtime can upgrade false->true, never downgrade true->false - if (runtimeValue === true) effective[key] = true; - } else if (typeof staticValue === 'string') { - // String: runtime value replaces static if present and is a string - if (typeof runtimeValue === 'string') effective[key] = runtimeValue; + if (typeof runtimeValue === 'boolean' || typeof runtimeValue === 'string') { + effective[key] = runtimeValue; } } @@ -89,6 +84,32 @@ export function validateManifestCapabilities(compiledOutput, effectiveFeatures) // validation remains execution-time only (chains are only known after a // concrete session is resolved). for (const job of compiledOutput.jobs) { + if (job.approval_required && !job.parent_id && !features.root_approval_gate) { + errors.push({ + code: 'capability_mismatch', + feature: 'root_approval_gate', + required_by: `job "${job.name || job.id}"`, + message: `Root job "${job.name || job.id}" requires manual approval but the runtime does not advertise root_approval_gate`, + }); + } + + if (job.approval_approver_scope && !features.approval_scope_enforcement) { + errors.push({ + code: 'capability_mismatch', + feature: 'approval_scope_enforcement', + required_by: `job "${job.name || job.id}"`, + message: `Job "${job.name || job.id}" declares approver scope but the runtime cannot enforce it`, + }); + } + + if (job.output_format && !features.structured_output_format) { + errors.push({ + code: 'capability_mismatch', + feature: 'structured_output_format', + required_by: `job "${job.name || job.id}"`, + message: `Job "${job.name || job.id}" declares output.format="${job.output_format}" but the runtime cannot persist that contract`, + }); + } // Check authorization hook requirement if (job.authorization || job.authorization_ref) { if (!features.authorization_hook) { diff --git a/src/cli.js b/src/cli.js index 96f4cfc..ebd03f0 100644 --- a/src/cli.js +++ b/src/cli.js @@ -1,5 +1,5 @@ import { createRequire } from 'node:module'; -import { MANIFEST_SCHEMA, MANIFEST_VERSION } from './schema.js'; +import { JSON_SCHEMAS, MANIFEST_SCHEMA, MANIFEST_VERSION } from './schema.js'; import { loadJsonInput, writeJsonOutput } from './io.js'; import { validateManifest } from './validate.js'; import { describeTarget } from './describe.js'; @@ -13,7 +13,13 @@ import { resolveEffectiveFeatures, validateManifestCapabilities, } from './capabilities.js'; -import { executeTask } from './exec.js'; +import { + evaluateTaskAuthorization, + executeTask, + inspectTaskIdentity, + validateTaskDelegation, + verifyTaskAuthorizationProof, +} from './exec.js'; import { runWorkflow } from './run.js'; import { readAuditLog } from './audit.js'; import { grantApproval, listApprovals, revokeApproval } from './approvals.js'; @@ -36,13 +42,13 @@ agentcli [args] Commands: version init [--tool program] [--output path] [--workflow-id id] [--task-id id] - schema [manifest|workflow|task|schedulerJob|standalonePlan|rpcRequest|rpcResponse|scheduler-job|standalone-plan|rpc-request|rpc-response] + schema [manifest|workflow|task|schedulerJob|standalonePlan|rpcRequest|rpcResponse|scheduler-job|standalone-plan|rpc-request|rpc-response] [--legacy] describe [manifest|workflow|task|targets|commands|rpc] targets paths validate compile [--target standalone|openclaw-scheduler] [--write path] [--explain] - apply [--db path] [--scheduler-prefix path|--scheduler-bin path] [--dry-run] [--explain] [--adopt-by id|name] [--check-capabilities] + apply [--db path] [--scheduler-prefix path|--scheduler-bin path] [--dry-run] [--explain] [--adopt-by id|name] [--check-capabilities] [--allow-proof-command] exec [--workflow id] [--dry-run] [--timeout ms] [--signer ssh|none] [--signing-key path] [--evidence-provider name] [--instance-id id] [--require-evidence] [--require-authorization] @@ -104,9 +110,193 @@ Environment: `; } +const BOOLEAN_FLAG = 'boolean'; +const VALUE_FLAG = 'value'; + +const GLOBAL_FLAGS = Object.freeze({ + json: BOOLEAN_FLAG, + pretty: BOOLEAN_FLAG, + ndjson: BOOLEAN_FLAG, + version: BOOLEAN_FLAG, + home: VALUE_FLAG, +}); + +const COMMAND_FLAGS = Object.freeze({ + init: { tool: VALUE_FLAG, output: VALUE_FLAG, 'workflow-id': VALUE_FLAG, 'task-id': VALUE_FLAG }, + schema: { legacy: BOOLEAN_FLAG }, + compile: { target: VALUE_FLAG, write: VALUE_FLAG, explain: BOOLEAN_FLAG }, + apply: { + db: VALUE_FLAG, + 'scheduler-prefix': VALUE_FLAG, + 'scheduler-bin': VALUE_FLAG, + 'dry-run': BOOLEAN_FLAG, + explain: BOOLEAN_FLAG, + 'adopt-by': VALUE_FLAG, + 'check-capabilities': BOOLEAN_FLAG, + 'allow-proof-command': BOOLEAN_FLAG, + }, + inspect: { db: VALUE_FLAG, fields: VALUE_FLAG, limit: VALUE_FLAG, sanitize: VALUE_FLAG }, + exec: { + workflow: VALUE_FLAG, + 'dry-run': BOOLEAN_FLAG, + timeout: VALUE_FLAG, + signer: VALUE_FLAG, + 'signing-key': VALUE_FLAG, + 'evidence-provider': VALUE_FLAG, + 'instance-id': VALUE_FLAG, + 'require-evidence': BOOLEAN_FLAG, + 'require-authorization': BOOLEAN_FLAG, + 'identity-debug': BOOLEAN_FLAG, + 'presentation-debug': BOOLEAN_FLAG, + 'approval-id': VALUE_FLAG, + db: VALUE_FLAG, + 'scheduler-prefix': VALUE_FLAG, + 'scheduler-bin': VALUE_FLAG, + }, + run: { + workflow: VALUE_FLAG, + root: VALUE_FLAG, + 'all-roots': BOOLEAN_FLAG, + 'dry-run': BOOLEAN_FLAG, + timeout: VALUE_FLAG, + signer: VALUE_FLAG, + 'signing-key': VALUE_FLAG, + 'evidence-provider': VALUE_FLAG, + 'instance-id': VALUE_FLAG, + 'require-evidence': BOOLEAN_FLAG, + 'require-authorization': BOOLEAN_FLAG, + 'identity-debug': BOOLEAN_FLAG, + 'presentation-debug': BOOLEAN_FLAG, + }, + audit: { limit: VALUE_FLAG }, + approve: { + workflow: VALUE_FLAG, + by: VALUE_FLAG, + reason: VALUE_FLAG, + 'ttl-s': VALUE_FLAG, + signer: VALUE_FLAG, + 'signing-key': VALUE_FLAG, + }, + approvals: { + status: VALUE_FLAG, + workflow: VALUE_FLAG, + task: VALUE_FLAG, + by: VALUE_FLAG, + reason: VALUE_FLAG, + }, + verify: { 'allowed-signers': VALUE_FLAG }, + registry: { name: VALUE_FLAG }, + import: { name: VALUE_FLAG }, + merge: { output: VALUE_FLAG }, + convert: { output: VALUE_FLAG, write: VALUE_FLAG }, + identity: { workflow: VALUE_FLAG }, + 'authorization-proof': { workflow: VALUE_FLAG }, + authorization: { workflow: VALUE_FLAG }, + whoami: { workflow: VALUE_FLAG }, + serve: { db: VALUE_FLAG }, +}); + +function argumentError(message) { + return Object.assign(new Error(message), { code: 'invalid_argument' }); +} + +function longFlag(arg) { + const separator = arg.indexOf('='); + if (separator === -1) return { key: arg.slice(2), inlineValue: undefined }; + return { key: arg.slice(2, separator), inlineValue: arg.slice(separator + 1) }; +} + +function detectCommand(argv) { + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--') return argv[index + 1]; + if (arg === '-v') return '-v'; + if (arg === '-h') return '-h'; + if (!arg.startsWith('--')) return arg; + + const { key, inlineValue } = longFlag(arg); + if (GLOBAL_FLAGS[key] === VALUE_FLAG && inlineValue === undefined) index += 1; + } + return undefined; +} + +function commandFlagSpecification(command) { + const normalizedCommand = command === '-v' ? 'version' : command === '-h' ? 'help' : command; + return { ...GLOBAL_FLAGS, ...(COMMAND_FLAGS[normalizedCommand] || {}) }; +} + +function assertPositionalCount(command, positionals) { + if (command == null) { + if (positionals.length > 0) throw argumentError(`Unexpected argument: ${positionals[0]}`); + return; + } + + const exact = { + version: 1, + '-v': 1, + help: 1, + '-h': 1, + init: 1, + targets: 1, + paths: 1, + 'skill-path': 1, + validate: 2, + compile: 2, + apply: 2, + exec: 3, + run: 2, + audit: 1, + approve: 3, + verify: 2, + import: 2, + convert: 2, + whoami: 3, + serve: 1, + }; + if (exact[command] !== undefined && positionals.length > exact[command]) { + throw argumentError(`Command "${command}" expects ${exact[command] - 1} positional argument(s); received ${positionals.length - 1}`); + } + + const ranges = { + schema: [1, 2], + describe: [1, 2], + inspect: [1, 2], + approvals: [2, 3], + signing: [2, 2], + registry: [2, 3], + merge: [3, Number.POSITIVE_INFINITY], + identity: [2, 4], + 'authorization-proof': [2, 4], + authorization: [2, 4], + evidence: [2, 3], + }; + const range = ranges[command]; + if (range && positionals.length > range[1]) { + throw argumentError(`Command "${command}" received an invalid number of positional arguments`); + } + + const subcommandCounts = { + approvals: { list: 2, revoke: 3 }, + signing: { providers: 2 }, + registry: { list: 2, add: 3, show: 3, remove: 3 }, + identity: { providers: 2, schema: 3, resolve: 4, 'validate-delegation': 4 }, + 'authorization-proof': { methods: 2, schema: 3, verify: 4 }, + authorization: { providers: 2, schema: 3, evaluate: 4 }, + evidence: { providers: 2, schema: 3 }, + }; + const expectedForSubcommand = subcommandCounts[command]?.[positionals[1]]; + if (expectedForSubcommand !== undefined && positionals.length > expectedForSubcommand) { + throw argumentError( + `Command "${command} ${positionals[1]}" expects ${expectedForSubcommand - 2} positional argument(s); received ${positionals.length - 2}` + ); + } +} + function parseArgs(argv) { const positionals = []; const flags = Object.create(null); + const detectedCommand = detectCommand(argv); + const flagSpecification = commandFlagSpecification(detectedCommand); for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; @@ -119,32 +309,49 @@ function parseArgs(argv) { continue; } - const [key, inlineValue] = arg.slice(2).split('=', 2); + const { key, inlineValue } = longFlag(arg); + const flagType = flagSpecification[key]; + if (!flagType) { + throw argumentError(`Unknown flag for ${detectedCommand || 'help'}: --${key}`); + } + if (Object.hasOwn(flags, key)) { + throw argumentError(`Flag may only be specified once: --${key}`); + } + + if (flagType === BOOLEAN_FLAG) { + if (inlineValue !== undefined) { + throw argumentError(`Boolean flag --${key} does not accept a value`); + } + flags[key] = true; + continue; + } + if (inlineValue !== undefined) { + if (inlineValue === '') throw argumentError(`--${key} requires a non-empty value`); flags[key] = inlineValue; continue; } - const next = argv[index + 1]; - if (next && !next.startsWith('--')) { - flags[key] = next; - index += 1; - } else { - flags[key] = true; + if (next === undefined || next.startsWith('--')) { + throw argumentError(`--${key} requires a value`); } + flags[key] = next; + index += 1; } + assertPositionalCount(positionals[0], positionals); return { positionals, flags }; } -function pickSchema(name) { +function pickSchema(name, { legacy = false } = {}) { const aliases = { 'scheduler-job': 'schedulerJob', 'standalone-plan': 'standalonePlan', 'rpc-request': 'rpcRequest', 'rpc-response': 'rpcResponse' }; - const schema = MANIFEST_SCHEMA[aliases[name] || name || 'manifest']; + const schemas = legacy ? MANIFEST_SCHEMA : JSON_SCHEMAS; + const schema = schemas[aliases[name] || name || 'manifest']; if (!schema) { throw Object.assign( new Error(`Unknown schema target: ${name}`), @@ -194,7 +401,8 @@ export async function runCli( cwd = process.cwd(), env = process.env, stdin = process.stdin, - stdout = process.stdout + stdout = process.stdout, + throwOnValidationFailure = false, } = {} ) { const { positionals, flags } = parseArgs(argv); @@ -228,7 +436,11 @@ export async function runCli( switch (command) { case 'schema': - return formatOutput({ ok: true, schema: pickSchema(positionals[1]) }, { mode: outputMode, pretty }); + return formatOutput({ + ok: true, + schema_format: flags.legacy ? 'agentcli-legacy' : 'json-schema-draft-2020-12', + schema: pickSchema(positionals[1], { legacy: Boolean(flags.legacy) }), + }, { mode: outputMode, pretty }); case 'describe': return formatOutput({ ok: true, description: describeTarget(positionals[1]) }, { mode: outputMode, pretty }); case 'targets': @@ -260,7 +472,11 @@ export async function runCli( } case 'validate': { const manifest = await loadJsonInput(positionals[1], { cwd, env: derivedEnv, stdin }); - return formatOutput(validateManifest(manifest), { mode: outputMode, pretty }); + const validation = validateManifest(manifest); + if (!validation.ok && throwOnValidationFailure) { + cliError('Manifest validation failed', 'validation_error', { validation }); + } + return formatOutput(validation, { mode: outputMode, pretty }); } case 'compile': { const manifest = await loadJsonInput(positionals[1], { cwd, env: derivedEnv, stdin }); @@ -319,6 +535,7 @@ export async function runCli( dbPath: flags.db || defaultDbPath, schedulerPrefix: flags['scheduler-prefix'] || defaultSchedulerPrefix, schedulerBin: flags['scheduler-bin'] || defaultSchedulerBin, + allowValueFromCommand: Boolean(flags['allow-proof-command']), cwd, env: derivedEnv }); @@ -436,11 +653,23 @@ export async function runCli( { code: 'invalid_argument' } ); } + const malformed = []; const records = readAuditLog({ auditPath: paths.audit, limit: rawLimit ? Number(rawLimit) : undefined, + onMalformed: ({ lineNumber }) => { + malformed.push({ + line_number: lineNumber, + message: 'malformed audit record skipped', + }); + }, }); - return formatOutput({ ok: true, count: records.length, records }, { mode: outputMode, pretty }); + return formatOutput({ + ok: true, + count: records.length, + records, + warnings: malformed, + }, { mode: outputMode, pretty }); } case 'approve': { const manifestInput = positionals[1]; @@ -522,14 +751,84 @@ export async function runCli( ); } const paths = getAgentcliPaths({ env: derivedEnv }); - const records = readAuditLog({ auditPath: paths.audit }); + const malformed = []; + const records = readAuditLog({ + auditPath: paths.audit, + onMalformed: ({ lineNumber }) => malformed.push(lineNumber), + }); const record = records.find(r => r.execution_id === executionId); if (!record) { + const malformedNote = malformed.length > 0 + ? ` (${malformed.length} malformed audit record(s) skipped)` + : ''; throw Object.assign( - new Error(`Execution not found in audit log: ${executionId}`), + new Error(`Execution not found in audit log: ${executionId}${malformedNote}`), { code: 'invalid_argument' } ); } + + if (record.evidence?.envelope) { + const envelope = record.evidence.envelope; + const principal = envelope.principal || record.principal_used || record.identity?.principal || null; + let verifyOptions = { principal }; + if (envelope.method === 'ssh-signature') { + if (!principal) { + return formatOutput({ + ok: true, + execution_id: executionId, + verified: false, + reason: 'no principal recorded for the evidence envelope', + source: 'evidence-envelope', + }, { mode: outputMode, pretty }); + } + let allowedSignersPath = flags['allowed-signers'] + || resolveAllowedSigners({ env: derivedEnv, statePath: paths.allowed_signers }); + if (!allowedSignersPath) { + allowedSignersPath = generateAllowedSigners({ + principal, + outputPath: paths.allowed_signers, + }); + if (!allowedSignersPath) { + return formatOutput({ + ok: true, + execution_id: executionId, + verified: false, + reason: 'no allowed_signers file and no SSH public keys found to generate one', + source: 'evidence-envelope', + }, { mode: outputMode, pretty }); + } + } + verifyOptions = { ...verifyOptions, allowedSignersPath }; + } + + const { verifyEvidenceEnvelope } = await import('./evidence/index.js'); + const { validateEvidenceRecordBinding } = await import('./evidence/payload.js'); + const verifyResult = await verifyEvidenceEnvelope(envelope, verifyOptions, { + cwd, + env: derivedEnv, + }); + const auditBinding = validateEvidenceRecordBinding(verifyResult.payload, record); + const verified = verifyResult.verified === true && auditBinding.valid; + const reason = verifyResult.reason || ( + auditBinding.valid + ? null + : `evidence envelope does not bind this audit record: ${auditBinding.errors.join('; ')}` + ); + return formatOutput({ + ok: true, + execution_id: executionId, + verified, + principal: verifyResult.principal || principal, + method: envelope.method, + key_fingerprint: verifyResult.key_fingerprint || envelope.key_fingerprint || null, + payload_digest: verifyResult.payload_digest || envelope.payload_digest || null, + envelope_version: verifyResult.envelope_version || envelope.version || null, + audit_binding_verified: auditBinding.valid, + source: 'evidence-envelope', + ...(reason ? { reason } : {}), + }, { mode: outputMode, pretty }); + } + if (!record.attestation) { return formatOutput({ ok: true, @@ -752,7 +1051,13 @@ export async function runCli( const taskId = positionals[3]; const workflowId = flags.workflow || null; if (!taskId) cliError('Usage: agentcli identity resolve [--workflow id]'); - const result = await executeTask(manifest, { workflowId, taskId, dryRun: true, identityDebug: true, cwd, env: derivedEnv }); + const result = await inspectTaskIdentity(manifest, { + workflowId, + taskId, + identityDebug: true, + cwd, + env: derivedEnv, + }); return formatOutput({ ok: true, declared_identity: result.declared_identity || result.identity, resolved_identity: result.resolved_identity || null, principal_used: result.principal_used }, { mode: outputMode, pretty }); } if (subcommand === 'validate-delegation') { @@ -760,8 +1065,14 @@ export async function runCli( const taskId = positionals[3]; const workflowId = flags.workflow || null; if (!taskId) cliError('Usage: agentcli identity validate-delegation [--workflow id]'); - const result = await executeTask(manifest, { workflowId, taskId, dryRun: true, identityDebug: true, cwd, env: derivedEnv }); - return formatOutput({ ok: true, delegation: result.resolved_identity?.delegation_validation || null }, { mode: outputMode, pretty }); + const result = await validateTaskDelegation(manifest, { + workflowId, + taskId, + identityDebug: true, + cwd, + env: derivedEnv, + }); + return formatOutput({ ok: true, delegation: result.delegation || null }, { mode: outputMode, pretty }); } return cliError('Unknown identity subcommand. Available: providers, schema, resolve, validate-delegation'); } @@ -792,8 +1103,18 @@ export async function runCli( const taskId = positionals[3]; const workflowId = flags.workflow || null; if (!taskId) cliError('Usage: agentcli authorization-proof verify [--workflow id]'); - const result = await executeTask(manifest, { workflowId, taskId, dryRun: true, cwd, env: derivedEnv }); - return formatOutput({ ok: true, authorization_proof: result.authorization_proof || null }, { mode: outputMode, pretty }); + const result = await verifyTaskAuthorizationProof(manifest, { + workflowId, + taskId, + cwd, + env: derivedEnv, + }); + return formatOutput({ + ok: true, + authorization_proof: result.authorization_proof || null, + effective_task_hash: result.effective_task_hash, + manifest_digest: result.manifest_digest, + }, { mode: outputMode, pretty }); } return cliError('Unknown authorization-proof subcommand. Available: methods, schema, verify'); } @@ -820,7 +1141,12 @@ export async function runCli( const taskId = positionals[3]; const workflowId = flags.workflow || null; if (!taskId) cliError('Usage: agentcli authorization evaluate [--workflow id]'); - const result = await executeTask(manifest, { workflowId, taskId, dryRun: true, requireAuthorization: true, cwd, env: derivedEnv }); + const result = await evaluateTaskAuthorization(manifest, { + workflowId, + taskId, + cwd, + env: derivedEnv, + }); return formatOutput({ ok: true, authorization: result.authorization || null }, { mode: outputMode, pretty }); } return cliError('Unknown authorization subcommand. Available: providers, schema, evaluate'); @@ -850,7 +1176,13 @@ export async function runCli( const taskId = positionals[2]; const workflowId = flags.workflow || null; if (!taskId) cliError('Usage: agentcli whoami [--workflow id]'); - const result = await executeTask(manifest, { workflowId, taskId, dryRun: true, identityDebug: true, cwd, env: derivedEnv }); + const result = await inspectTaskIdentity(manifest, { + workflowId, + taskId, + identityDebug: true, + cwd, + env: derivedEnv, + }); return formatOutput({ ok: true, principal_used: result.principal_used, declared_identity: result.declared_identity || result.identity, resolved_identity: result.resolved_identity || null, trust: result.trust || null }, { mode: outputMode, pretty }); } case 'serve': { diff --git a/src/command.js b/src/command.js index c2556f3..224d730 100644 --- a/src/command.js +++ b/src/command.js @@ -1,6 +1,16 @@ import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { isAbsolute, resolve } from 'node:path'; import process from 'node:process'; +const VALUE_FROM_SOURCES = ['env', 'file', 'literal', 'command']; + +function invalidValueFrom(message, cause) { + return Object.assign(new Error(message, cause ? { cause } : undefined), { + code: 'invalid_argument', + }); +} + export function shellCommandInvocation(command, platform = process.platform) { if (platform === 'win32') { return { @@ -19,6 +29,7 @@ export function resolveCommandValue( { cwd = process.cwd(), env = process.env, + commandEnv = env, timeoutMs = 30000, runner = spawnSync, platform = process.platform, @@ -28,7 +39,7 @@ export function resolveCommandValue( const invocation = shellCommandInvocation(command, platform); const result = runner(invocation.program, invocation.args, { cwd, - env, + env: commandEnv, encoding: 'utf8', timeout: timeoutMs, stdio: ['pipe', 'pipe', 'pipe'], @@ -41,3 +52,105 @@ export function resolveCommandValue( return null; } } + +/** + * Resolve a value_from descriptor without accidentally executing a command. + * + * A descriptor must contain exactly one of env, file, literal, or command. + * Command sources are disabled by default because resolution is otherwise a + * side-effectful operation hidden behind what appears to be a data lookup. + * Callers that have already crossed their approval boundary must opt in with + * allowCommand: true. + * + * @param {object} valueFrom + * @param {object} options + * @param {object} [options.env] + * @param {object} [options.commandEnv] + * @param {string} [options.cwd] + * @param {boolean} [options.allowCommand] + * @param {number} [options.timeoutMs] + * @param {Function} [options.runner] + * @param {string} [options.platform] + * @param {Function} [options.fileReader] + * @returns {string} + */ +export function resolveValueFrom( + valueFrom, + { + env = process.env, + commandEnv = env, + cwd = process.cwd(), + allowCommand = false, + timeoutMs = 30000, + runner = spawnSync, + platform = process.platform, + fileReader = readFileSync, + } = {} +) { + if (!valueFrom || typeof valueFrom !== 'object' || Array.isArray(valueFrom)) { + throw invalidValueFrom('value_from must be an object'); + } + + const sources = VALUE_FROM_SOURCES.filter(source => valueFrom[source] !== undefined); + if (sources.length !== 1) { + throw invalidValueFrom( + `value_from must specify exactly one source (${VALUE_FROM_SOURCES.join(', ')})` + ); + } + + const source = sources[0]; + const configuredValue = valueFrom[source]; + if (typeof configuredValue !== 'string' || configuredValue.length === 0) { + throw invalidValueFrom(`value_from.${source} must be a non-empty string`); + } + + if (source === 'literal') return configuredValue; + + if (source === 'env') { + if (!Object.prototype.hasOwnProperty.call(env, configuredValue)) { + throw invalidValueFrom(`Environment variable "${configuredValue}" is not set`); + } + const value = env[configuredValue]; + if (typeof value !== 'string' || value.length === 0) { + throw invalidValueFrom(`Environment variable "${configuredValue}" is empty`); + } + return value; + } + + if (source === 'file') { + const filePath = isAbsolute(configuredValue) + ? configuredValue + : resolve(cwd, configuredValue); + try { + const value = fileReader(filePath, 'utf8').trim(); + if (!value) { + throw invalidValueFrom(`value_from.file resolved to an empty file: ${filePath}`); + } + return value; + } catch (error) { + if (error?.code === 'invalid_argument') throw error; + throw invalidValueFrom(`Unable to read value_from.file: ${filePath}`, error); + } + } + + if (!allowCommand) { + throw invalidValueFrom( + 'value_from.command is disabled until the caller explicitly opts in after approval' + ); + } + + const value = resolveCommandValue(configuredValue, { + cwd, + env, + commandEnv, + timeoutMs, + runner, + platform, + }); + if (!value) { + throw invalidValueFrom('value_from.command failed or produced no output'); + } + return value; +} + +export { VALUE_FROM_SOURCES }; diff --git a/src/compiler/openclaw-scheduler.js b/src/compiler/openclaw-scheduler.js index 9d9b231..312c8be 100644 --- a/src/compiler/openclaw-scheduler.js +++ b/src/compiler/openclaw-scheduler.js @@ -9,7 +9,8 @@ import { stableId } from './shared.js'; import { expandManifestShorthands } from '../shorthand.js'; -import { SCHEDULER_FIELDS_V1, SCHEDULER_FIELDS_V02 } from '../scheduler-fields.js'; +import { canonicalDigest } from '../canonical.js'; +import { SCHEDULER_FIELDS_V1, SCHEDULER_FIELDS_V02, SCHEDULER_FIELDS_V03 } from '../scheduler-fields.js'; const TRIGGERED_SENTINEL_CRON = '0 0 31 2 *'; const TRIGGERED_SENTINEL_TZ = 'UTC'; @@ -79,6 +80,15 @@ function sanitizeIdentityDeclaration(identity) { && Object.values(presentation).some(v => v != null); return { ...identity, + subject: identity.subject + ? { + ...identity.subject, + attributes_hash: identity.subject.attributes == null + ? null + : canonicalDigest(identity.subject.attributes), + attributes: null, + } + : null, auth: identity.auth ? { ...identity.auth, @@ -95,6 +105,15 @@ function sanitizeIdentityProfile(profile) { return { ...profile, provider_config: null, + subject: profile.subject + ? { + ...profile.subject, + attributes_hash: profile.subject.attributes == null + ? null + : canonicalDigest(profile.subject.attributes), + attributes: null, + } + : null, auth: profile.auth ? { ...profile.auth, @@ -205,6 +224,25 @@ function validateSchedulerReservedValues(errors, taskPath, job) { } } +function validateSchedulerShellInputs(errors, taskPath, plan) { + if (plan.execution.payload_kind !== 'shellCommand') return; + const payload = plan.execution.payload || {}; + if (Object.keys(payload.env || {}).length > 0) { + addTargetValidationError( + errors, + `${taskPath}.shell.env`, + 'openclaw-scheduler persists shell commands; shell.env values are refused to prevent credential disclosure. Use an identity provider or runtime-managed environment instead.' + ); + } + if (payload.stdin != null) { + addTargetValidationError( + errors, + `${taskPath}.shell.stdin`, + 'openclaw-scheduler persists shell commands; inline stdin is refused because it may contain sensitive material.' + ); + } +} + export function compileManifestToScheduler(manifest, { includeExplain = false } = {}) { const validation = validateManifest(manifest); if (!validation.ok) { @@ -268,7 +306,7 @@ export function compileManifestToScheduler(manifest, { includeExplain = false } id: plan.id, source: plan.source, name: plan.name, - enabled: plan.enabled ? 1 : 0, + enabled: plan.enabled && plan.approval.policy !== 'auto-reject' ? 1 : 0, schedule_cron: isTriggered ? TRIGGERED_SENTINEL_CRON : plan.invocation.cron, schedule_tz: isTriggered ? TRIGGERED_SENTINEL_TZ : plan.invocation.tz, session_target: plan.execution.session_target, @@ -298,9 +336,12 @@ export function compileManifestToScheduler(manifest, { includeExplain = false } approval_required: plan.approval.required, approval_timeout_s: plan.approval.timeout_s ?? SCHEDULER_DEFAULT_APPROVAL_TIMEOUT_S, approval_auto: plan.approval.auto ?? SCHEDULER_DEFAULT_APPROVAL_AUTO, + approval_risk_level: plan.approval.risk_level, + approval_approver_scope: plan.approval.approver_scope, context_retrieval: plan.context.retrieval, context_retrieval_limit: plan.context.limit, ...outputPolicy, + output_format: plan.output.format, preferred_session_key: plan.session.preferred_key, auth_profile: plan.auth_profile ?? null, identity_principal: plan.identity?.principal ?? null, @@ -347,6 +388,7 @@ export function compileManifestToScheduler(manifest, { includeExplain = false } }; validateSchedulerStringLimits(targetErrors, taskPath, job); validateSchedulerReservedValues(targetErrors, taskPath, job); + validateSchedulerShellInputs(targetErrors, taskPath, plan); if (isTriggered && task.trigger?.parent) { // The effective policy here uses a 3-level fallback (child -> parent task @@ -422,9 +464,10 @@ export function compileManifestToScheduler(manifest, { includeExplain = false } target: 'openclaw-scheduler', version: '0.2', handoff: { - field_version: '2', + field_version: '3', v1_field_count: SCHEDULER_FIELDS_V1.length, v2_field_count: SCHEDULER_FIELDS_V1.length + SCHEDULER_FIELDS_V02.length, + v3_field_count: SCHEDULER_FIELDS_V1.length + SCHEDULER_FIELDS_V02.length + SCHEDULER_FIELDS_V03.length, }, jobs, ...profiles, diff --git a/src/compiler/shared.js b/src/compiler/shared.js index f48f6f5..b00628a 100644 --- a/src/compiler/shared.js +++ b/src/compiler/shared.js @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto'; import { normalizeShellExecution, renderShellExecution } from '../shell.js'; +import { canonicalDigest, canonicalStringify, hashNullableString, hashString } from '../canonical.js'; function isObjectLike(value) { return value && typeof value === 'object' && !Array.isArray(value); @@ -155,6 +156,11 @@ export function resolveIdentityV2(workflowIdentity, taskIdentity) { ...(isObjectLike(workflowAuth.inputs) ? workflowAuth.inputs : {}), ...(isObjectLike(taskAuth.inputs) ? taskAuth.inputs : {}), }; + const delegationPolicy = { + max_depth: taskDelegation.max_depth ?? workflowDelegation.max_depth ?? null, + allowed_delegators: taskDelegation.allowed_delegators ?? workflowDelegation.allowed_delegators ?? null, + require_grant_per_hop: taskDelegation.require_grant_per_hop ?? workflowDelegation.require_grant_per_hop ?? null, + }; const auth = { mode: taskAuth.mode ?? workflowAuth.mode ?? null, scopes: taskAuth.scopes ?? workflowAuth.scopes ?? null, @@ -163,11 +169,9 @@ export function resolveIdentityV2(workflowIdentity, taskIdentity) { cache: taskAuth.cache ?? workflowAuth.cache ?? null, refresh: taskAuth.refresh ?? workflowAuth.refresh ?? null, required: taskAuth.required ?? workflowAuth.required ?? null, - delegation_policy: { - max_depth: taskDelegation.max_depth ?? workflowDelegation.max_depth ?? null, - allowed_delegators: taskDelegation.allowed_delegators ?? workflowDelegation.allowed_delegators ?? null, - require_grant_per_hop: taskDelegation.require_grant_per_hop ?? workflowDelegation.require_grant_per_hop ?? null, - }, + delegation_policy: Object.values(delegationPolicy).some(value => value != null) + ? delegationPolicy + : null, provider_config: Object.keys(providerConfig).length > 0 ? providerConfig : null, inputs: Object.keys(inputs).length > 0 ? inputs : null, }; @@ -343,6 +347,11 @@ export function mergeIdentityProfile(profile, identity) { ...(isObjectLike(baseAuth.inputs) ? baseAuth.inputs : {}), ...(isObjectLike(declarationAuth.inputs) ? declarationAuth.inputs : {}), }; + const delegationPolicy = { + max_depth: declarationAuth.delegation_policy?.max_depth ?? baseAuth.delegation_policy?.max_depth ?? null, + allowed_delegators: declarationAuth.delegation_policy?.allowed_delegators ?? baseAuth.delegation_policy?.allowed_delegators ?? null, + require_grant_per_hop: declarationAuth.delegation_policy?.require_grant_per_hop ?? baseAuth.delegation_policy?.require_grant_per_hop ?? null, + }; return { ref: declaration.ref ?? base.id ?? null, @@ -365,11 +374,9 @@ export function mergeIdentityProfile(profile, identity) { cache: declarationAuth.cache ?? baseAuth.cache ?? null, refresh: declarationAuth.refresh ?? baseAuth.refresh ?? null, required: declarationAuth.required ?? baseAuth.required ?? null, - delegation_policy: { - max_depth: declarationAuth.delegation_policy?.max_depth ?? baseAuth.delegation_policy?.max_depth ?? null, - allowed_delegators: declarationAuth.delegation_policy?.allowed_delegators ?? baseAuth.delegation_policy?.allowed_delegators ?? null, - require_grant_per_hop: declarationAuth.delegation_policy?.require_grant_per_hop ?? baseAuth.delegation_policy?.require_grant_per_hop ?? null, - }, + delegation_policy: Object.values(delegationPolicy).some(value => value != null) + ? delegationPolicy + : null, provider_config: Object.keys(providerConfig).length > 0 ? providerConfig : null, inputs: Object.keys(inputs).length > 0 ? inputs : null, }, @@ -406,6 +413,11 @@ export function mergeAuthorizationProofProfile(profile, declaration) { audience: base.audience ?? null, jwks_uri: base.jwks_uri ?? null, public_key: base.public_key ?? null, + allowed_signers: base.allowed_signers ?? null, + principal: base.principal ?? null, + namespace: base.namespace ?? null, + ca_certificate: base.ca_certificate ?? null, + ca_certificate_from: base.ca_certificate_from ?? null, proof: base.proof ?? null, claims: Object.keys(claims).length > 0 ? claims : null, verify: { @@ -491,12 +503,183 @@ function resolveIntent(task) { function resolveOutput(task) { return { + format: task.output?.format ?? null, preview_bytes: task.output?.preview_bytes ?? 2000, offload: task.output?.offload ?? 'auto', retrieve: task.output?.retrieve ?? 'on-demand', }; } +function hashObject(value) { + return value && typeof value === 'object' ? canonicalDigest(value) : null; +} + +function bindIdentity(identity) { + if (!identity) return null; + return { + ref: identity.ref ?? null, + scope: identity.scope ?? null, + provider: identity.provider ?? null, + subject: identity.subject + ? { + kind: identity.subject.kind ?? null, + principal: identity.subject.principal ?? null, + display_name: identity.subject.display_name ?? null, + run_as: identity.subject.run_as ?? null, + issuer: identity.subject.issuer ?? null, + delegation_mode: identity.subject.delegation_mode ?? null, + attributes_hash: hashObject(identity.subject.attributes), + } + : null, + auth: identity.auth + ? { + mode: identity.auth.mode ?? null, + scopes: identity.auth.scopes ?? null, + audience: identity.auth.audience ?? null, + resource: identity.auth.resource ?? null, + cache: identity.auth.cache ?? null, + refresh: identity.auth.refresh ?? null, + required: identity.auth.required ?? null, + delegation_policy: identity.auth.delegation_policy ?? null, + provider_config_hash: hashObject(identity.auth.provider_config), + inputs_hash: hashObject(identity.auth.inputs), + } + : null, + trust: identity.trust ?? null, + presentation: identity.presentation ?? null, + }; +} + +function bindAuthorizationProof(proof) { + if (!proof) return null; + return { + ref: proof.ref ?? null, + method: proof.method ?? null, + issuer: proof.issuer ?? null, + audience: proof.audience ?? null, + jwks_uri: proof.jwks_uri ?? null, + public_key_hash: hashNullableString(proof.public_key), + allowed_signers: proof.allowed_signers ?? null, + principal: proof.principal ?? null, + namespace: proof.namespace ?? null, + ca_certificate_hash: hashNullableString(proof.ca_certificate), + ca_certificate_from_hash: hashObject(proof.ca_certificate_from), + proof_hash: hashObject(proof.proof), + claims_hash: hashObject(proof.claims), + verify: proof.verify ?? null, + }; +} + +function bindAuthorization(authorization) { + if (!authorization) return null; + return { + ref: authorization.ref ?? null, + provider: authorization.provider ?? null, + provider_config_hash: hashObject(authorization.provider_config), + on_error: authorization.on_error ?? null, + request: authorization.request ?? null, + decision: authorization.decision ?? null, + }; +} + +function bindEvidence(evidence) { + if (!evidence) return null; + return { + ref: evidence.ref ?? null, + provider: evidence.provider ?? null, + methods: evidence.methods ?? null, + provider_config_hash: hashObject(evidence.provider_config), + payload: evidence.payload ?? null, + verify: evidence.verify ?? null, + }; +} + +export function commandBindingForShell(shell, { cwd = process.cwd() } = {}) { + const normalized = normalizeShellExecution(shell || {}); + const envHashes = {}; + for (const key of Object.keys(normalized.env || {}).sort()) { + envHashes[key] = hashString(normalized.env[key]); + } + return { + program: normalized.program, + args_hashes: (normalized.args || []).map(arg => hashString(arg)), + args_count: (normalized.args || []).length, + cwd: normalized.cwd || cwd, + env_keys: Object.keys(envHashes), + env_hashes: envHashes, + stdin_hash: hashNullableString(normalized.stdin), + }; +} + +export function buildEffectiveExecutionBinding({ + manifest = null, + expanded = manifest, + workflow, + task, + cwd = process.cwd(), +} = {}) { + if (!workflow || !task) { + throw new TypeError('workflow and task are required to build an execution binding'); + } + + const resolvedIdentity = resolveIdentity(workflow, task); + const identityProfile = resolvedIdentity?.ref + ? expanded?.identity_profiles?.find(profile => profile.id === resolvedIdentity.ref) ?? null + : null; + const identity = mergeIdentityProfile(identityProfile, resolvedIdentity); + + const proofRef = resolveAuthorizationProof(workflow, task); + const proofProfile = proofRef?.ref + ? expanded?.authorization_proof_profiles?.find(profile => profile.id === proofRef.ref) ?? null + : null; + const proof = proofRef ? mergeAuthorizationProofProfile(proofProfile, proofRef) : null; + + const authorizationRef = resolveAuthorization(workflow, task); + const authorizationProfile = authorizationRef?.ref + ? expanded?.authorization_profiles?.find(profile => profile.id === authorizationRef.ref) ?? null + : null; + const authorization = authorizationRef + ? mergeAuthorizationProfile(authorizationProfile, authorizationRef) + : null; + + const evidenceRef = resolveEvidence(workflow, task); + const evidenceProfile = evidenceRef?.ref + ? expanded?.evidence_profiles?.find(profile => profile.id === evidenceRef.ref) ?? null + : null; + const evidence = evidenceRef ? mergeEvidenceProfile(evidenceProfile, evidenceRef) : null; + + return { + binding_version: 1, + manifest_version: expanded?.version ?? manifest?.version ?? null, + manifest_digest: expanded ? canonicalDigest(expanded) : null, + source: { workflow_id: workflow.id, task_id: task.id }, + enabled: task.enabled ?? true, + target: task.target ?? null, + command: task.shell ? commandBindingForShell(task.shell, { cwd }) : null, + prompt_hash: hashNullableString(task.prompt), + runtime: { timeout_ms: task.runtime?.timeout_ms ?? null }, + approval: approvalPolicyForTask(task), + identity: bindIdentity(identity), + contract: resolveContract(workflow, task), + authorization_proof: bindAuthorizationProof(proof), + authorization: bindAuthorization(authorization), + evidence: bindEvidence(evidence), + child_credential_policy: resolveChildCredentialPolicy(workflow, task), + verify: resolveVerify(workflow, task), + output: resolveOutput(task), + intent: resolveIntent(task), + delete_after_run: task.delete_after_run ?? null, + }; +} + +export function computeEffectiveTaskHash(binding) { + return canonicalDigest(binding); +} + +export function canonicalExecutionBindingString(binding) { + return canonicalStringify(binding); +} + function resolveBudgets(task) { return { max_iterations: task.budgets?.max_iterations ?? null, diff --git a/src/compiler/standalone.js b/src/compiler/standalone.js index 6d30a65..ad84e6d 100644 --- a/src/compiler/standalone.js +++ b/src/compiler/standalone.js @@ -1,6 +1,119 @@ import { validateManifest } from '../validate.js'; import { normalizedTaskPlan, stableId } from './shared.js'; import { expandManifestShorthands } from '../shorthand.js'; +import { canonicalDigest, hashNullableString } from '../canonical.js'; + +export const STANDALONE_FEATURES = Object.freeze({ + approvals: 'intent-only', + model_policy: 'portable', + execution_intent: 'portable', + output_hints: 'portable', + timeout_support: 'portable', + context_retrieval: 'portable', + runtime_execution: false, + identity_declaration: true, + runtime_identity_resolution: false, + evidence_generation: false, + audit_export: false, + trust_evaluation: false, + delegation_validation: false, + credential_handoff: false, + authorization_proof_verification: false, + authorization_hook: false, + root_approval_gate: false, + approval_scope_enforcement: false, + structured_output_format: false, +}); + +function sanitizeValueFrom(valueFrom) { + if (!valueFrom) return null; + if (valueFrom.env) return { env: valueFrom.env }; + if (valueFrom.file) return { file: valueFrom.file }; + return null; +} + +function sanitizeIdentity(identity) { + if (!identity) return null; + return { + ...identity, + subject: identity.subject + ? { + ...identity.subject, + attributes_hash: identity.subject.attributes == null + ? null + : canonicalDigest(identity.subject.attributes), + attributes: null, + } + : null, + auth: identity.auth + ? { ...identity.auth, provider_config: null, inputs: null } + : null, + }; +} + +function sanitizeTaskPlan(plan) { + const payload = plan.execution.payload; + const safePayload = plan.execution.payload_kind === 'shellCommand' && payload + ? { + program: payload.program, + args: payload.args, + cwd: payload.cwd, + env: null, + env_keys: Object.keys(payload.env || {}).sort(), + env_hash: canonicalDigest(payload.env || {}), + stdin: null, + stdin_hash: hashNullableString(payload.stdin), + } + : payload; + return { + ...plan, + execution: { ...plan.execution, payload: safePayload }, + identity: sanitizeIdentity(plan.identity), + authorization_proof: plan.authorization_proof + ? { + ...plan.authorization_proof, + proof: plan.authorization_proof.proof + ? { + ...plan.authorization_proof.proof, + value_from: sanitizeValueFrom(plan.authorization_proof.proof.value_from), + } + : null, + } + : null, + authorization: plan.authorization + ? { ...plan.authorization, provider_config: null } + : null, + evidence: plan.evidence + ? { ...plan.evidence, provider_config: null } + : null, + }; +} + +function sanitizeIdentityProfile(profile) { + return { + ...profile, + provider_config: null, + subject: profile.subject + ? { + ...profile.subject, + attributes_hash: profile.subject.attributes == null + ? null + : canonicalDigest(profile.subject.attributes), + attributes: null, + } + : null, + auth: profile.auth ? { ...profile.auth, provider_config: null, inputs: null } : null, + }; +} + +function sanitizeProofProfile(profile) { + return { + ...profile, + proof: profile.proof + ? { ...profile.proof, value_from: sanitizeValueFrom(profile.proof.value_from) } + : null, + }; +} export function compileManifestToStandalone(manifest, { includeExplain = false } = {}) { const validation = validateManifest(manifest); @@ -26,7 +139,7 @@ export function compileManifestToStandalone(manifest, { includeExplain = false } const edges = []; for (const task of workflow.tasks) { const plan = normalizedTaskPlan(workflow, task, taskIdToCompiledId, { namePrefix: useNamePrefix }); - tasks.push(plan); + tasks.push(sanitizeTaskPlan(plan)); if (plan.invocation.mode === 'trigger') { edges.push({ from: plan.parent_compiled_id, @@ -58,36 +171,35 @@ export function compileManifestToStandalone(manifest, { includeExplain = false } const profiles = {}; if (Array.isArray(expanded.identity_profiles) && expanded.identity_profiles.length > 0) { - profiles.identity_profiles = expanded.identity_profiles; + profiles.identity_profiles = expanded.identity_profiles.map(sanitizeIdentityProfile); } if (Array.isArray(expanded.authorization_proof_profiles) && expanded.authorization_proof_profiles.length > 0) { - profiles.authorization_proof_profiles = expanded.authorization_proof_profiles; + profiles.authorization_proof_profiles = expanded.authorization_proof_profiles.map(sanitizeProofProfile); } if (Array.isArray(expanded.authorization_profiles) && expanded.authorization_profiles.length > 0) { - profiles.authorization_profiles = expanded.authorization_profiles; + profiles.authorization_profiles = expanded.authorization_profiles.map(profile => ({ + ...profile, + provider_config: null, + })); } if (Array.isArray(expanded.evidence_profiles) && expanded.evidence_profiles.length > 0) { - profiles.evidence_profiles = expanded.evidence_profiles; + profiles.evidence_profiles = expanded.evidence_profiles.map(profile => ({ + ...profile, + provider_config: null, + })); } return { target: 'standalone', version: '0.2', capabilities: { + ...STANDALONE_FEATURES, authoring: true, planning: true, - runtime_execution: false, rpc: true, - model_policy: true, - execution_intent: true, - output_hints: true, budgets: true, identity: true, contracts: true, - identity_declaration: true, - evidence_generation: true, - trust_evaluation: true, - delegation_validation: true, }, ...profiles, workflows, diff --git a/src/convert.js b/src/convert.js index c3f9a94..1b7db98 100644 --- a/src/convert.js +++ b/src/convert.js @@ -2,13 +2,29 @@ * Manifest conversion utility -- v0.1 to v0.2. */ +import { createHash } from 'node:crypto'; +import { validateManifest } from './validate.js'; + +function shortHash(value) { + return createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 10); +} + +function identifierSlug(value, fallback) { + const slug = String(value || '') + .replace(/[^a-zA-Z0-9-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 32); + return slug || fallback; +} + /** * Generate a deterministic profile ID from an attestation string. * @param {string} attestation * @returns {string} */ function attestationProfileId(attestation) { - return `legacy-${attestation.replace(/[^a-zA-Z0-9-]/g, '-')}`; + return `legacy-${identifierSlug(attestation, 'attestation')}-${shortHash(attestation)}`; } /** @@ -21,20 +37,12 @@ function ensureAttestationProfile(converted, attestation) { const id = attestationProfileId(attestation); if (converted.authorization_proof_profiles.find(p => p.id === id)) return; - // Determine method from attestation string - let method = 'none'; - if (attestation.startsWith('oidc:') || attestation.includes('jwt')) { - method = 'jwt'; - } else if (attestation.includes('ssh') || attestation.includes('signature')) { - method = 'detached-signature'; - } else if (attestation.includes('cert') || attestation.includes('x509')) { - method = 'certificate'; - } - + // v0.1 attestation values were declarations, not verifiable proof material. + // Preserve the reference without claiming cryptographic verification. Users + // can replace this profile with a configured verifier after conversion. converted.authorization_proof_profiles.push({ id, - method, - issuer: null, + method: 'none', verify: { required: false }, }); } @@ -71,6 +79,7 @@ export function convertManifestV1toV2(manifest) { // Track unique identity configurations to generate profiles. // Key: serialized principal+run_as -> Value: profile id string const identityProfileMap = new Map(); + const identityProfileKeysById = new Map(); function ensureIdentityProfile(identity) { if (!identity) return null; @@ -85,9 +94,11 @@ export function convertManifestV1toV2(manifest) { // Generate a profile ID from the principal for readability, or fall back // to a counter-based name when no principal is present. - const id = principal - ? `converted-${principal.replace(/[^a-zA-Z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 40)}` - : `converted-identity-${identityProfileMap.size + 1}`; + const baseId = `converted-${identifierSlug(principal, 'identity')}`; + const existingKey = identityProfileKeysById.get(baseId); + const id = existingKey == null || existingKey === key + ? baseId + : `${baseId}-${shortHash(key)}`; const profile = { id, @@ -110,6 +121,7 @@ export function convertManifestV1toV2(manifest) { converted.identity_profiles.push(profile); identityProfileMap.set(key, id); + identityProfileKeysById.set(id, key); return id; } @@ -154,6 +166,12 @@ export function convertManifestV1toV2(manifest) { identity: failureProfileRef ? { ref: failureProfileRef } : null, }; if (convertedTask.on_failure.identity === null) delete convertedTask.on_failure.identity; + if (task.on_failure.identity.attestation) { + ensureAttestationProfile(converted, task.on_failure.identity.attestation); + convertedTask.on_failure.authorization_proof = { + ref: attestationProfileId(task.on_failure.identity.attestation), + }; + } } convertedWorkflow.tasks.push(convertedTask); @@ -168,5 +186,13 @@ export function convertManifestV1toV2(manifest) { if (converted.authorization_profiles.length === 0) delete converted.authorization_profiles; if (converted.evidence_profiles.length === 0) delete converted.evidence_profiles; + const validation = validateManifest(converted); + if (!validation.ok) { + throw Object.assign( + new Error(`Converted manifest failed validation: ${validation.errors.map(error => error.message).join('; ')}`), + { code: 'internal_error', validation } + ); + } + return converted; } diff --git a/src/describe.js b/src/describe.js index 74bc3d2..69282d0 100644 --- a/src/describe.js +++ b/src/describe.js @@ -121,10 +121,16 @@ export const RPC_METHODS = [ { method: 'agentcli.version', summary: 'Return package and manifest spec version.' }, { method: 'agentcli.schema', summary: 'Return a schema fragment by name.' }, { method: 'agentcli.describe', summary: 'Return descriptive metadata by topic.' }, + { method: 'agentcli.targets', summary: 'List compile targets and their declared capabilities.' }, + { method: 'agentcli.paths', summary: 'Return resolved agentcli home paths.' }, { method: 'agentcli.validate', summary: 'Validate a manifest object.' }, { method: 'agentcli.compile', summary: 'Compile a manifest object to a named target.' }, { method: 'agentcli.apply', summary: 'Apply a manifest to an OpenClaw Scheduler runtime.' }, { method: 'agentcli.inspect', summary: 'Inspect a scheduler database when available.' }, + { method: 'agentcli.audit', summary: 'Read sanitized local execution audit records.' }, + { method: 'agentcli.approvals.list', summary: 'List local approval records by status.' }, + { method: 'agentcli.registry.list', summary: 'List reusable manifests in the local registry.' }, + { method: 'agentcli.registry.show', summary: 'Return a named manifest from the local registry.' }, { method: 'agentcli.convert', summary: 'Convert a v0.1 manifest object to v0.2.' }, { method: 'agentcli.identity.providers', summary: 'List registered identity providers.' }, { method: 'agentcli.identity.schema', summary: 'Return metadata for a named identity provider.' }, @@ -132,6 +138,7 @@ export const RPC_METHODS = [ { method: 'agentcli.identity.validateDelegation', summary: 'Validate a task identity delegation chain.' }, { method: 'agentcli.authorizationProof.methods', summary: 'List registered authorization proof verifier methods.' }, { method: 'agentcli.authorizationProof.schema', summary: 'Return metadata for a named authorization proof verifier.' }, + { method: 'agentcli.authorizationProof.verify', summary: 'Verify a task authorization proof without executing the task.' }, { method: 'agentcli.authorization.providers', summary: 'List registered authorization providers.' }, { method: 'agentcli.authorization.schema', summary: 'Return metadata for a named authorization provider.' }, { method: 'agentcli.authorization.evaluate', summary: 'Evaluate authorization for a task.' }, diff --git a/src/errors.js b/src/errors.js new file mode 100644 index 0000000..4299b50 --- /dev/null +++ b/src/errors.js @@ -0,0 +1,92 @@ +export const ERROR_TYPES = Object.freeze([ + 'validation_error', + 'unknown_command', + 'invalid_argument', + 'parse_error', + 'internal_error', +]); + +export const ERROR_CODES = Object.freeze([ + ...ERROR_TYPES, + 'approval_required', + 'approval_auto_rejected', + 'approval_signature_invalid', + 'approval_scope_mismatch', + 'approval_lock_timeout', + 'approval_log_invalid', + 'policy_forbids_approval', + 'authorization_proof_failed', + 'authorization_proof_invalid', + 'unknown_verifier', + 'authorization_denied', + 'authorization_escalation_required', + 'authorization_error', + 'unknown_authorization_provider', + 'identity_resolution_failed', + 'identity_profile_invalid', + 'identity_delegation_invalid', + 'unknown_identity_provider', + 'identity_provider_error', + 'resolution_failed', + 'token_not_found', + 'token_file_empty', + 'token_file_not_found', + 'token_request_failed', + 'presentation_format_unsupported', + 'presentation_binding_invalid', + 'presentation_source_forbidden', + 'presentation_binding_missing', + 'presentation_target_unsupported', + 'presentation_target_invalid', + 'presentation_stdin_conflict', + 'evidence_failed', + 'verify_failed', + 'sandbox_unavailable', + 'sandbox_enforcement_unavailable', + 'sandbox_path_escape', + 'sandbox_path_invalid', + 'contract_violation', + 'trust_level_insufficient', + 'unsupported_capability', + 'capability_mismatch', + 'scheduler_error', + 'delegation_error', + 'no_runtime', +]); + +const KNOWN_CODES = new Set(ERROR_CODES); + +export class AgentcliError extends Error { + constructor(message, { code = 'internal_error', errorType, cause, ...extra } = {}) { + super(message, cause ? { cause } : undefined); + this.name = 'AgentcliError'; + this.code = KNOWN_CODES.has(code) ? code : 'internal_error'; + this.error_type = errorType || errorTypeForCode(this.code); + Object.assign(this, extra); + } +} + +export function errorTypeForCode(code) { + if (ERROR_TYPES.includes(code)) return code; + if (code === 'no_runtime' || code === 'unsupported_capability') return 'invalid_argument'; + if (code === 'approval_lock_timeout' || code === 'scheduler_error') return 'internal_error'; + if (KNOWN_CODES.has(code)) return 'validation_error'; + return 'internal_error'; +} + +export function makeError(message, code = 'internal_error', extra = {}) { + return new AgentcliError(message, { code, ...extra }); +} + +export function normalizeError(error) { + const source = error instanceof Error ? error : new Error(String(error)); + const requestedCode = typeof source.code === 'string' ? source.code : 'internal_error'; + const code = KNOWN_CODES.has(requestedCode) ? requestedCode : 'internal_error'; + return { + message: source.message || 'Internal error', + code, + error_type: source.error_type || errorTypeForCode(code), + ...(source.validation ? { validation: source.validation } : {}), + ...(source.cleanup_warnings ? { cleanup_warnings: source.cleanup_warnings } : {}), + }; +} diff --git a/src/evidence/index.js b/src/evidence/index.js index e6e1c53..5c4c11c 100644 --- a/src/evidence/index.js +++ b/src/evidence/index.js @@ -69,3 +69,44 @@ export function resolveEvidenceProviderForMethod(method) { } return null; } + +/** + * Verify a persisted evidence envelope using the provider named by its method. + * Unknown, malformed, or provider-error results fail closed. + */ +export async function verifyEvidenceEnvelope(envelope, options = {}, ctx = {}) { + if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) { + return { verified: false, reason: 'evidence envelope must be an object' }; + } + if (typeof envelope.method !== 'string' || envelope.method.length === 0) { + return { verified: false, reason: 'evidence envelope method is missing' }; + } + + let provider = resolveEvidenceProviderForMethod(envelope.method); + if (!provider && envelope.method === 'ssh-signature') { + await import('./ssh.js'); + provider = resolveEvidenceProviderForMethod(envelope.method); + } + if (!provider) { + return { + verified: false, + reason: `no evidence provider is registered for method "${envelope.method}"`, + }; + } + + try { + const result = await provider.verify(envelope, options, ctx); + return result?.verified === true + ? result + : { + ...(result && typeof result === 'object' ? result : {}), + verified: false, + reason: result?.reason || 'evidence verification did not succeed', + }; + } catch (error) { + return { + verified: false, + reason: `evidence verification failed: ${error.message}`, + }; + } +} diff --git a/src/evidence/payload.js b/src/evidence/payload.js index 68bb0cb..ec21c67 100644 --- a/src/evidence/payload.js +++ b/src/evidence/payload.js @@ -5,6 +5,344 @@ * serialization utilities for deterministic signing. */ +import { + canonicalDigest, + canonicalStringify, + hashNullableString, + hashString, +} from '../canonical.js'; + +export const EVIDENCE_PAYLOAD_SCHEMA = 'agentcli.evidence.payload'; +export const EVIDENCE_PAYLOAD_VERSION = 1; +const SENSITIVE_FIELD = /(?:^|_)(?:access_token|refresh_token|id_token|token|secret|password|private_key|credentials?|cookie|client_assertion|api_key|authorization_header)(?:_|$)/i; + +function redactSensitiveEvidence(value, key = '') { + if (SENSITIVE_FIELD.test(key)) { + return { + redacted: true, + value_hash: value == null ? null : canonicalDigest(value), + }; + } + if (Array.isArray(value)) { + return value.map(item => redactSensitiveEvidence(item)); + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([entryKey, entryValue]) => [ + entryKey, + redactSensitiveEvidence(entryValue, entryKey), + ]) + ); + } + return value; +} + +function withoutRawCommandInputs(command = {}) { + const { + env, + stdin, + env_hash: providedEnvHash, + stdin_hash: providedStdinHash, + args, + args_hashes: providedArgsHashes, + ...safeCommand + } = command; + + return { + ...safeCommand, + args_hashes: providedArgsHashes ?? ( + Array.isArray(args) ? args.map(value => hashString(value)) : null + ), + args_count: safeCommand.args_count ?? (Array.isArray(args) ? args.length : null), + env_hash: providedEnvHash ?? (env == null ? null : canonicalDigest(env)), + stdin_hash: providedStdinHash ?? hashNullableString(stdin), + }; +} + +function withoutRawOutputs(value = {}) { + const { + stdout, + stderr, + structured, + stdout_hash: providedStdoutHash, + stderr_hash: providedStderrHash, + structured_hash: providedStructuredHash, + ...safeValue + } = value; + return { + ...safeValue, + stdout_hash: providedStdoutHash ?? hashNullableString(stdout), + stderr_hash: providedStderrHash ?? hashNullableString(stderr), + structured_hash: providedStructuredHash ?? ( + structured == null ? null : canonicalDigest(structured) + ), + }; +} + +function requiredString(value, field) { + if (typeof value !== 'string' || value.length === 0) { + throw new TypeError(`${field} must be a non-empty string`); + } + return value; +} + +/** + * Build the complete payload used by versioned execution evidence. + * + * Raw command environment values and stdin are never retained. Their hashes + * bind the signed evidence to the exact inputs without turning the audit log + * into a secret store. + */ +export function buildCompleteEvidencePayload({ + executionId, + timestamp, + source, + manifest, + manifestDigest, + effectiveTask, + effectiveTaskHash, + declaredIdentity = null, + resolvedIdentity = null, + authorizationProof = null, + authorization = null, + actorContext = null, + contract = null, + command, + result, + verify = null, + complianceContext = {}, +} = {}) { + requiredString(executionId, 'executionId'); + requiredString(timestamp, 'timestamp'); + if (!source || typeof source !== 'object' || Array.isArray(source)) { + throw new TypeError('source must be an object'); + } + if (!command || typeof command !== 'object' || Array.isArray(command)) { + throw new TypeError('command must be an object'); + } + if (!result || typeof result !== 'object' || Array.isArray(result)) { + throw new TypeError('result must be an object'); + } + + const computedManifestDigest = manifest == null ? null : canonicalDigest(manifest); + const computedTaskHash = effectiveTask == null ? null : canonicalDigest(effectiveTask); + if (manifestDigest && computedManifestDigest && manifestDigest !== computedManifestDigest) { + throw new TypeError('manifestDigest does not match the provided manifest'); + } + if (effectiveTaskHash && computedTaskHash && effectiveTaskHash !== computedTaskHash) { + throw new TypeError('effectiveTaskHash does not match the provided effectiveTask'); + } + const resolvedManifestDigest = manifestDigest ?? computedManifestDigest; + const resolvedTaskHash = effectiveTaskHash ?? computedTaskHash; + if (!resolvedManifestDigest) { + throw new TypeError('manifest or manifestDigest is required'); + } + if (!resolvedTaskHash) { + throw new TypeError('effectiveTask or effectiveTaskHash is required'); + } + + return { + schema: EVIDENCE_PAYLOAD_SCHEMA, + version: EVIDENCE_PAYLOAD_VERSION, + execution_id: executionId, + timestamp, + source, + bindings: { + manifest_digest: resolvedManifestDigest, + effective_task_hash: resolvedTaskHash, + }, + declared_identity: redactSensitiveEvidence(declaredIdentity), + resolved_identity: redactSensitiveEvidence(resolvedIdentity), + authorization_proof: redactSensitiveEvidence(authorizationProof), + authorization: redactSensitiveEvidence(authorization), + actor_context: redactSensitiveEvidence(actorContext), + contract, + command: withoutRawCommandInputs(command), + result: withoutRawOutputs(result), + verify: verify == null ? null : withoutRawOutputs(verify), + compliance_context: complianceContext, + }; +} + +export function validateCompleteEvidencePayload(payload) { + const errors = []; + const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key); + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return { valid: false, errors: ['payload must be an object'] }; + } + if (payload.schema !== EVIDENCE_PAYLOAD_SCHEMA) { + errors.push(`schema must be "${EVIDENCE_PAYLOAD_SCHEMA}"`); + } + if (payload.version !== EVIDENCE_PAYLOAD_VERSION) { + errors.push(`version must be ${EVIDENCE_PAYLOAD_VERSION}`); + } + for (const field of ['execution_id', 'timestamp']) { + if (typeof payload[field] !== 'string' || payload[field].length === 0) { + errors.push(`${field} must be a non-empty string`); + } + } + if (!payload.source || typeof payload.source !== 'object' || Array.isArray(payload.source)) { + errors.push('source must be an object'); + } else if ( + typeof payload.source.workflow_id !== 'string' || + typeof payload.source.task_id !== 'string' || + !payload.source.workflow_id || + !payload.source.task_id + ) { + errors.push('source must contain non-empty workflow_id and task_id'); + } + if ( + !payload.bindings || + typeof payload.bindings.manifest_digest !== 'string' || + typeof payload.bindings.effective_task_hash !== 'string' + ) { + errors.push('bindings must contain manifest_digest and effective_task_hash'); + } else { + for (const field of ['manifest_digest', 'effective_task_hash']) { + if (!/^sha256:[a-f0-9]{64}$/.test(payload.bindings[field])) { + errors.push(`bindings.${field} must be a SHA-256 digest`); + } + } + } + if (!payload.command || typeof payload.command !== 'object' || Array.isArray(payload.command)) { + errors.push('command must be an object'); + } else { + if (typeof payload.command.program !== 'string' || !payload.command.program) { + errors.push('command.program must be a non-empty string'); + } + if (!Array.isArray(payload.command.args_hashes)) { + errors.push('command.args_hashes must be an array'); + } + if (!Number.isInteger(payload.command.args_count) || payload.command.args_count < 0) { + errors.push('command.args_count must be a non-negative integer'); + } + if (!hasOwn(payload.command, 'env_hash')) { + errors.push('command.env_hash is required'); + } else if (!/^sha256:[a-f0-9]{64}$/.test(payload.command.env_hash)) { + errors.push('command.env_hash must be a SHA-256 digest'); + } + if (!hasOwn(payload.command, 'stdin_hash')) { + errors.push('command.stdin_hash is required'); + } else if ( + payload.command.stdin_hash !== null && + !/^sha256:[a-f0-9]{64}$/.test(payload.command.stdin_hash) + ) { + errors.push('command.stdin_hash must be null or a SHA-256 digest'); + } + } + if (!payload.result || typeof payload.result !== 'object' || Array.isArray(payload.result)) { + errors.push('result must be an object'); + } else { + for (const field of ['exit_code', 'timed_out', 'duration_ms', 'output_hash']) { + if (!hasOwn(payload.result, field)) errors.push(`result.${field} is required`); + } + if ( + typeof payload.result.output_hash !== 'string' || + !/^sha256:[a-f0-9]{64}$/.test(payload.result.output_hash) + ) { + errors.push('result.output_hash must be a SHA-256 digest'); + } + } + if (!hasOwn(payload, 'verify')) { + errors.push('verify field is required'); + } + if (!hasOwn(payload, 'authorization_proof')) { + errors.push('authorization_proof field is required'); + } + if (!hasOwn(payload, 'authorization')) { + errors.push('authorization field is required'); + } + if (!hasOwn(payload, 'declared_identity')) { + errors.push('declared_identity field is required'); + } + if (!hasOwn(payload, 'resolved_identity')) { + errors.push('resolved_identity field is required'); + } + for (const field of ['actor_context', 'contract', 'compliance_context']) { + if (!hasOwn(payload, field)) errors.push(`${field} field is required`); + } + return { valid: errors.length === 0, errors }; +} + +/** + * Confirm that a verified signed payload belongs to its surrounding audit + * record. This prevents transplanting a valid evidence envelope onto a + * different execution record. + */ +export function validateEvidenceRecordBinding(payload, record) { + const errors = []; + const equalCanonical = (left, right) => canonicalStringify(left) === canonicalStringify(right); + if (!payload || typeof payload !== 'object' || !record || typeof record !== 'object') { + return { valid: false, errors: ['payload and audit record must be objects'] }; + } + if (payload.execution_id !== record.execution_id) { + errors.push('execution_id does not match the audit record'); + } + if (payload.timestamp !== record.timestamp) { + errors.push('timestamp does not match the audit record'); + } + if ( + payload.source?.workflow_id !== record.source?.workflow_id || + payload.source?.task_id !== record.source?.task_id + ) { + errors.push('source does not match the audit record'); + } + if (payload.bindings?.manifest_digest !== record.manifest_digest) { + errors.push('manifest digest does not match the audit record'); + } + if (payload.bindings?.effective_task_hash !== record.effective_task_hash) { + errors.push('effective task hash does not match the audit record'); + } + const recordOutputHash = record.result?.output_hash ?? record.hashes?.result ?? null; + if (payload.result?.output_hash !== recordOutputHash) { + errors.push('result output hash does not match the audit record'); + } + + const signedFieldMappings = [ + ['declared_identity', 'declared_identity'], + ['resolved_identity', 'resolved_identity'], + ['authorization_proof', 'authorization_proof'], + ['authorization', 'authorization'], + ['actor_context', 'actor_context'], + ['contract', 'contract'], + ]; + for (const [payloadField, recordField] of signedFieldMappings) { + if (!equalCanonical( + payload[payloadField], + redactSensitiveEvidence(record[recordField] ?? null) + )) { + errors.push(`${recordField} does not match the signed evidence`); + } + } + + const expectedVerify = record.verify == null ? null : withoutRawOutputs(record.verify); + if (!equalCanonical(payload.verify, expectedVerify)) { + errors.push('verify result does not match the signed evidence'); + } + + for (const field of [ + 'program', 'cwd', 'args_count', 'args_hashes', 'env_keys', 'env_hashes', + 'stdin_present', 'stdin_hash', + ]) { + const signedValue = field === 'stdin_present' && payload.command?.[field] === undefined + ? payload.command?.stdin_hash != null + : payload.command?.[field] ?? null; + if (!equalCanonical(signedValue, record.command?.[field] ?? null)) { + errors.push(`command.${field} does not match the signed evidence`); + } + } + + for (const field of [ + 'exit_code', 'signal', 'timed_out', 'duration_ms', 'stdout_bytes', + 'stderr_bytes', 'output_hash', + ]) { + if (!equalCanonical(payload.result?.[field] ?? null, record.result?.[field] ?? null)) { + errors.push(`result.${field} does not match the signed evidence`); + } + } + return { valid: errors.length === 0, errors }; +} + /** * Build a structured evidence payload from execution context. * @@ -83,39 +421,11 @@ export function buildEvidencePayload({ */ export function serializePayload(payload, format = 'canonical-json') { if (format === 'canonical-json') { - return JSON.stringify(sortKeysDeep(payload)); + return canonicalStringify(payload); } return JSON.stringify(payload); } -/** - * Recursively sort all object keys in a value. - * Arrays preserve element order; objects get alphabetically sorted keys. - * - * @param {*} value - The value to sort. - * @returns {*} A new value with all object keys sorted. - */ -function sortKeysDeep(value) { - if (value === null || value === undefined) { - return value; - } - - if (Array.isArray(value)) { - return value.map(item => sortKeysDeep(item)); - } - - if (typeof value === 'object') { - const sorted = {}; - const keys = Object.keys(value).sort(); - for (const key of keys) { - sorted[key] = sortKeysDeep(value[key]); - } - return sorted; - } - - return value; -} - /** * Collect compliance context fields from the execution context. * diff --git a/src/evidence/ssh.js b/src/evidence/ssh.js index 2bd63d9..e1f594a 100644 --- a/src/evidence/ssh.js +++ b/src/evidence/ssh.js @@ -7,13 +7,43 @@ */ import { spawnSync } from 'node:child_process'; -import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { + chmodSync, + closeSync, + constants as fsConstants, + existsSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; import { homedir, tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; +import { canonicalStringify, hashString } from '../canonical.js'; import { registerEvidenceProvider } from './index.js'; +import { validateCompleteEvidencePayload } from './payload.js'; const SSH_KEY_CANDIDATES = ['id_ed25519', 'id_ecdsa', 'id_rsa']; const NAMESPACE = 'agentcli'; +export const EVIDENCE_ENVELOPE_SCHEMA = 'agentcli.evidence.envelope'; +export const EVIDENCE_ENVELOPE_VERSION = 1; + +function signatureDocumentForEnvelope(envelope) { + return canonicalStringify({ + schema: envelope.schema, + version: envelope.version, + method: envelope.method, + key_fingerprint: envelope.key_fingerprint, + principal: envelope.principal, + namespace: envelope.namespace, + payload_format: envelope.payload_format, + payload_digest: envelope.payload_digest, + signed_payload: envelope.signed_payload, + }); +} // -- Key discovery -- @@ -76,9 +106,9 @@ export function getKeyFingerprint(keyPath) { */ export function resolveAllowedSigners({ env = process.env, statePath } = {}) { const explicit = env.AGENTCLI_ALLOWED_SIGNERS; - if (explicit && existsSync(explicit)) return explicit; + if (explicit && existsSync(explicit) && lstatSync(explicit).isFile()) return explicit; - if (statePath && existsSync(statePath)) return statePath; + if (statePath && existsSync(statePath) && lstatSync(statePath).isFile()) return statePath; return null; } @@ -103,8 +133,24 @@ export function generateAllowedSigners({ principal, homeDir = homedir(), outputP if (lines.length === 0) return null; - mkdirSync(dirname(outputPath), { recursive: true }); - writeFileSync(outputPath, lines.join('\n') + '\n', 'utf8'); + const outputDirectory = dirname(outputPath); + mkdirSync(outputDirectory, { recursive: true, mode: 0o700 }); + if (process.platform !== 'win32') chmodSync(outputDirectory, 0o700); + let descriptor; + try { + descriptor = openSync( + outputPath, + fsConstants.O_WRONLY | + fsConstants.O_CREAT | + fsConstants.O_TRUNC | + (fsConstants.O_NOFOLLOW || 0), + 0o600 + ); + writeFileSync(descriptor, lines.join('\n') + '\n', 'utf8'); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } + if (process.platform !== 'win32') chmodSync(outputPath, 0o600); return outputPath; } @@ -127,7 +173,12 @@ const sshEvidenceProvider = { const signingKey = config.key_path || undefined; const keyPath = resolveSigningKey({ env, homeDir, signingKey }); - return keyPath ? { keyPath } : null; + return keyPath + ? { + keyPath, + principal: config.principal || ctx.principal || 'agentcli', + } + : null; }, /** @@ -144,12 +195,43 @@ const sshEvidenceProvider = { return { attested: false, reason: 'no signing key available' }; } + let parsedPayload; + try { + parsedPayload = JSON.parse(payload); + } catch (error) { + return { attested: false, reason: `evidence payload must be valid JSON: ${error.message}` }; + } + const payloadValidation = validateCompleteEvidencePayload(parsedPayload); + if (!payloadValidation.valid) { + return { + attested: false, + reason: `incomplete evidence payload: ${payloadValidation.errors.join('; ')}`, + }; + } + if (canonicalStringify(parsedPayload) !== payload) { + return { attested: false, reason: 'evidence payload must use canonical JSON serialization' }; + } + + const fingerprint = getKeyFingerprint(keyPath); + const envelope = { + schema: EVIDENCE_ENVELOPE_SCHEMA, + version: EVIDENCE_ENVELOPE_VERSION, + method: 'ssh-signature', + key_fingerprint: fingerprint, + principal: config.principal || 'agentcli', + namespace: NAMESPACE, + payload_format: 'canonical-json', + payload_digest: hashString(payload), + signed_payload: payload, + }; + const signatureDocument = signatureDocumentForEnvelope(envelope); + const result = spawnSync('ssh-keygen', [ '-Y', 'sign', '-f', keyPath, '-n', NAMESPACE, ], { - input: payload, + input: signatureDocument, encoding: 'utf8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'], @@ -168,15 +250,10 @@ const sshEvidenceProvider = { return { attested: false, reason: 'ssh-keygen produced no signature' }; } - const fingerprint = getKeyFingerprint(keyPath); - return { attested: true, envelope: { - method: 'ssh-signature', - key_fingerprint: fingerprint, - namespace: NAMESPACE, - signed_payload: payload, + ...envelope, signature, }, }; @@ -195,7 +272,43 @@ const sshEvidenceProvider = { return { verified: false, reason: 'missing evidence envelope data' }; } - const { allowedSignersPath, principal } = options; + if ( + envelope.schema !== EVIDENCE_ENVELOPE_SCHEMA || + envelope.version !== EVIDENCE_ENVELOPE_VERSION || + envelope.method !== 'ssh-signature' + ) { + return { verified: false, reason: 'unsupported evidence envelope schema or version' }; + } + + if (envelope.payload_format !== 'canonical-json') { + return { verified: false, reason: 'unsupported evidence payload format' }; + } + + const payloadDigest = hashString(envelope.signed_payload); + if (envelope.payload_digest !== payloadDigest) { + return { verified: false, reason: 'evidence payload digest mismatch' }; + } + + let parsedPayload; + try { + parsedPayload = JSON.parse(envelope.signed_payload); + } catch (error) { + return { verified: false, reason: `invalid evidence payload JSON: ${error.message}` }; + } + + const payloadValidation = validateCompleteEvidencePayload(parsedPayload); + if (!payloadValidation.valid) { + return { + verified: false, + reason: `invalid signed evidence payload: ${payloadValidation.errors.join('; ')}`, + }; + } + if (canonicalStringify(parsedPayload) !== envelope.signed_payload) { + return { verified: false, reason: 'signed evidence payload is not canonical JSON' }; + } + + const { allowedSignersPath } = options; + const principal = options.principal || envelope.principal; if (!allowedSignersPath || !existsSync(allowedSignersPath)) { return { verified: false, reason: 'allowed_signers file not found' }; @@ -205,7 +318,15 @@ const sshEvidenceProvider = { return { verified: false, reason: 'no principal specified for verification' }; } - const tmpSigPath = join(tmpdir(), `agentcli-evidence-verify-${Date.now()}-${Math.random().toString(36).slice(2)}.sig`); + if (options.principal && envelope.principal && options.principal !== envelope.principal) { + return { verified: false, reason: 'evidence principal does not match expected principal' }; + } + + if (envelope.namespace !== NAMESPACE) { + return { verified: false, reason: 'evidence namespace does not match agentcli' }; + } + + const tmpSigPath = join(tmpdir(), `agentcli-evidence-verify-${randomUUID()}.sig`); try { writeFileSync(tmpSigPath, envelope.signature, 'utf8'); @@ -216,15 +337,31 @@ const sshEvidenceProvider = { '-n', envelope.namespace || NAMESPACE, '-s', tmpSigPath, ], { - input: envelope.signed_payload, + input: signatureDocumentForEnvelope(envelope), encoding: 'utf8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'], }); if (result.status === 0) { - const fingerprint = envelope.key_fingerprint || null; - return { verified: true, principal, key_fingerprint: fingerprint }; + const fingerprintMatch = `${result.stdout || ''}\n${result.stderr || ''}` + .match(/SHA256:[A-Za-z0-9+/=]+/); + const verifiedFingerprint = fingerprintMatch ? fingerprintMatch[0] : null; + if ( + envelope.key_fingerprint && + verifiedFingerprint && + envelope.key_fingerprint !== verifiedFingerprint + ) { + return { verified: false, reason: 'evidence key fingerprint does not match verified signer' }; + } + return { + verified: true, + principal, + key_fingerprint: verifiedFingerprint || envelope.key_fingerprint || null, + payload_digest: payloadDigest, + envelope_version: envelope.version, + payload: parsedPayload, + }; } return { @@ -248,7 +385,11 @@ const sshEvidenceProvider = { provider: 'ssh', method: envelope.method, attested: envelope.attested !== false, + envelope_schema: envelope.schema || null, + envelope_version: envelope.version || null, + payload_digest: envelope.payload_digest || null, key_fingerprint: envelope.key_fingerprint, + principal: envelope.principal || null, namespace: envelope.namespace, }; }, diff --git a/src/exec.js b/src/exec.js index f032704..a13dde2 100644 --- a/src/exec.js +++ b/src/exec.js @@ -1,9 +1,8 @@ -import { readFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { isAbsolute, relative, resolve as resolvePath } from 'node:path'; import { validateManifest } from './validate.js'; -import { resolveCommandValue } from './command.js'; +import { resolveValueFrom } from './command.js'; import { expandManifestShorthands } from './shorthand.js'; import { normalizeShellExecution } from './shell.js'; import { @@ -11,6 +10,8 @@ import { mergeAuthorizationProofProfile, mergeEvidenceProfile, mergeIdentityProfile, + buildEffectiveExecutionBinding, + computeEffectiveTaskHash, resolveAuthorization, resolveAuthorizationProof, resolveContract, @@ -30,6 +31,7 @@ import { computeTaskApprovalHash, claimApproval, verifyApprovalSignature, + approverMatchesScope, } from './approvals.js'; // Ensure the ssh signing provider is registered on import @@ -53,12 +55,19 @@ import { compareTrustLevels, redactSession, buildCredentialSummary } from './ide import { resolveEvidenceProvider } from './evidence/index.js'; import './evidence/none.js'; import './evidence/ssh.js'; -import { buildEvidencePayload, serializePayload, collectComplianceContext } from './evidence/payload.js'; +import { + buildCompleteEvidencePayload, + serializePayload, + collectComplianceContext, +} from './evidence/payload.js'; // v0.2 authorization proof verifiers -import { resolveVerifier } from './authorization-proof/index.js'; +import { + assertValidAuthorizationProofProfile, + verifyAuthorizationProof, +} from './authorization-proof/index.js'; import './authorization-proof/none.js'; -import { resolveJwtVerificationContext } from './authorization-proof/jwt.js'; +import './authorization-proof/jwt.js'; import './authorization-proof/detached-signature.js'; import './authorization-proof/certificate.js'; @@ -149,53 +158,47 @@ function resolvePrincipal(identity) { return `${user}@${host}`; } -/** - * Resolve a value_from indirection to a concrete string. - * - * Supports env (environment variable) and file (filesystem path) sources. - * - * @param {object} valueFrom - The value_from descriptor. - * @param {object} envObj - Environment variable map. - * @returns {string|null} The resolved value, or null if unresolvable. - */ -function resolveValueFrom(valueFrom, envObj, { cwd = process.cwd() } = {}) { - if (!valueFrom) return null; - if (valueFrom.env) return envObj[valueFrom.env] || null; - if (valueFrom.file) { - try { return readFileSync(valueFrom.file, 'utf8').trim(); } - catch { return null; } - } - if (valueFrom.literal) return valueFrom.literal; - if (valueFrom.command) { - return resolveCommandValue(valueFrom.command, { env: envObj, cwd }); - } - return null; -} - -function sortKeysDeep(value) { - if (value === null || value === undefined) { - return value; - } - - if (Array.isArray(value)) { - return value.map(item => sortKeysDeep(item)); - } - - if (typeof value === 'object') { - const sorted = {}; - for (const key of Object.keys(value).sort()) { - sorted[key] = sortKeysDeep(value[key]); +const OPERATIONAL_ENV_KEYS = new Set([ + 'PATH', 'HOME', 'TMPDIR', 'TMP', 'TEMP', + 'LANG', 'SHELL', 'USER', 'LOGNAME', 'TZ', 'TERM', + 'SystemRoot', 'WINDIR', 'ComSpec', 'PATHEXT', +]); + +function buildChildEnvironment(env, declaredEnv = {}) { + const inherited = {}; + for (const [key, value] of Object.entries(env || {})) { + if (OPERATIONAL_ENV_KEYS.has(key) || key.startsWith('LC_')) { + inherited[key] = value; } - return sorted; } + return { ...inherited, ...declaredEnv }; +} - return value; +function safeCommandMetadata(binding, shell, cwd) { + return { + program: shell.program, + cwd: shell.cwd || cwd, + args_count: binding.command?.args_count ?? shell.args.length, + args_hashes: binding.command?.args_hashes ?? [], + env_keys: binding.command?.env_keys ?? Object.keys(shell.env), + env_hashes: binding.command?.env_hashes ?? {}, + stdin_present: shell.stdin != null, + stdin_hash: binding.command?.stdin_hash ?? null, + }; } -function computeManifestDigest(manifest) { - return createHash('sha256') - .update(JSON.stringify(sortKeysDeep(manifest))) - .digest('hex'); +async function assertProviderProfileValid(provider, profile, kind, context = {}) { + if (!provider?.validateProfile) return; + const validation = await provider.validateProfile(profile, context); + if (validation?.valid === false) { + const details = Array.isArray(validation.errors) + ? validation.errors.map(error => typeof error === 'string' ? error : error.message).join('; ') + : 'provider rejected the profile'; + throw Object.assign( + new Error(`${kind} profile validation failed: ${details}`), + { code: 'validation_error', provider: provider.name, validation } + ); + } } function summarizeMaterialization(materialization) { @@ -264,7 +267,9 @@ async function cleanupProviderArtifacts(identityProviderInstance, { session = null, providerConfig = {}, env = process.env, + commandEnv = buildChildEnvironment(env), cwd = process.cwd(), + runtimeCapabilities = {}, warningPrefix = 'Credential cleanup', } = {}, warnings = []) { if (!identityProviderInstance?.cleanup) return; @@ -287,8 +292,10 @@ async function cleanupProviderArtifacts(identityProviderInstance, { const cleanupResult = await identityProviderInstance.cleanup(cleanupMaterialization, { session: effectiveSession, env, + commandEnv, cwd, provider_config: providerConfig, + runtimeCapabilities, }); for (const warning of cleanupResult?.warnings || []) { warnings.push(`${warningPrefix} warning: ${warning}`); @@ -302,7 +309,7 @@ async function cleanupProviderArtifacts(identityProviderInstance, { * Validate and resolve common execution state shared by both v0.1 and v0.2 paths. * * Performs manifest validation, workflow/task lookup, shell-target check, - * contract preflight, and signing provider resolution. + * contract preflight, without resolving providers or probing the host. * * @returns {object} All resolved common state fields. */ @@ -314,6 +321,7 @@ function resolveCommonState(manifest, { cwd = process.cwd(), env = process.env, timeoutMs, + allowUnsupportedHandoff = false, }) { if (!taskId) { throw Object.assign( @@ -359,7 +367,28 @@ function resolveCommonState(manifest, { } if (task.target?.session_target !== 'shell') { - return { requiresDelegation: true, manifest: expanded, workflow, task }; + const identity = resolveIdentity(workflow, task); + const contract = resolveContract(workflow, task); + return { + requiresDelegation: true, + manifest: expanded, + expanded, + workflow, + task, + isV2: manifest.version === '0.2' || Boolean(manifest.identity_profiles), + identity, + contract, + verify: resolveVerify(workflow, task), + auditPolicy: contract.audit ?? 'always', + shell: null, + effectiveTimeout: timeoutMs ?? task.runtime?.timeout_ms ?? null, + violations: [], + warnings: [], + signer, + explicitSigningKey, + cwd, + env, + }; } if (!task.shell) { @@ -372,14 +401,26 @@ function resolveCommonState(manifest, { const isV2 = manifest.version === '0.2' || Boolean(manifest.identity_profiles); const identity = resolveIdentity(workflow, task); + const identityDeclaration = mergeIdentityProfile( + identity.ref + ? expanded.identity_profiles?.find(profile => profile.id === identity.ref) ?? null + : null, + identity + ); + const declaredHandoff = identityDeclaration.presentation?.handoff ?? 'none'; + if (declaredHandoff !== 'none' && !allowUnsupportedHandoff) { + throw Object.assign( + new Error(`Credential handoff "${declaredHandoff}" is unsupported by the local shell runtime`), + { code: 'unsupported_capability' } + ); + } const contract = resolveContract(workflow, task); const verify = resolveVerify(workflow, task); const auditPolicy = contract.audit ?? 'always'; const shell = normalizeShellExecution(task.shell); const effectiveTimeout = timeoutMs ?? task.runtime?.timeout_ms ?? null; const { violations, warnings: preflightWarnings } = preflightContractChecks(contract, shell, { cwd }); - const sandboxCommand = prepareSandboxedShellCommand(shell, contract, { cwd, env }); - const warnings = [...preflightWarnings, ...sandboxCommand.warnings]; + const warnings = [...preflightWarnings]; if (violations.length > 0) { throw Object.assign( @@ -388,16 +429,83 @@ function resolveCommonState(manifest, { ); } - const provider = resolveProvider({ signer, env }); - const providerConfig = provider.resolve({ env, signingKey: explicitSigningKey }); - return { expanded, workflow, task, isV2, identity, contract, verify, - auditPolicy, shell, sandboxCommand, effectiveTimeout, violations, warnings, - provider, providerConfig, cwd, env, + auditPolicy, shell, effectiveTimeout, violations, warnings, + signer, explicitSigningKey, cwd, env, }; } +function buildDryRunResult(common, { binding, taskHash, timestamp, executionId }) { + const { workflow, task, contract, shell, warnings, cwd } = common; + const command = safeCommandMetadata(binding, shell, cwd); + return { + ok: true, + dry_run: true, + execution_id: executionId, + timestamp, + source: { workflow_id: workflow.id, task_id: task.id }, + effective_task_hash: taskHash, + manifest_digest: binding.manifest_digest, + identity: binding.identity, + contract, + command, + approval: { + policy: binding.approval.policy, + required: binding.approval.required === 1, + auto_reject: binding.approval.policy === 'auto-reject', + risk_level: binding.approval.risk_level, + approver_scope: binding.approval.approver_scope, + timeout_s: binding.approval.timeout_s, + }, + sandbox: { + requested: contract.sandbox ?? 'permissive', + network: contract.network ?? 'unrestricted', + evaluated: false, + }, + phases: { + authorization_proof: 'skipped', + identity_resolution: 'skipped', + authorization: 'skipped', + credential_materialization: 'skipped', + handoff: 'skipped', + signing: 'skipped', + evidence: 'skipped', + verify: 'skipped', + audit: 'skipped', + }, + warnings: [...warnings], + result: { status: 'dry_run' }, + }; +} + +function prepareLiveCommon(common, { signer, signingKey, cwd, env }) { + const sandboxCommand = prepareSandboxedShellCommand(common.shell, common.contract, { cwd, env }); + common.warnings.push(...sandboxCommand.warnings); + const provider = resolveProvider({ signer, env }); + const providerConfig = provider.resolve({ env, signingKey }); + return { ...common, sandboxCommand, provider, providerConfig }; +} + +async function executeApprovedV2(common, options) { + const approvalUsed = enforceApprovalGate({ + workflow: common.workflow, + task: common.task, + executionId: options.executionId, + approvalId: options.approvalId, + env: options.env, + binding: options.binding, + taskHash: options.taskHash, + }); + const liveCommon = prepareLiveCommon(common, { + signer: options.signer, + signingKey: options.signingKey, + cwd: options.cwd, + env: options.env, + }); + return executeV2(liveCommon, { ...options, approvalUsed }); +} + /** * Execute a shell task from a manifest. * @@ -427,8 +535,7 @@ export function executeTask(manifest, { cwd = process.cwd(), env = process.env, } = {}) { - // Resolve all common state (validation, lookup, preflight). - // This throws synchronously for all error cases shared between v0.1 and v0.2. + // Resolve only side-effect-free common state before the approval boundary. const common = resolveCommonState(manifest, { workflowId, taskId, signer, signingKey: explicitSigningKey, cwd, env, timeoutMs, @@ -441,20 +548,142 @@ export function executeTask(manifest, { }); } + const timestamp = new Date().toISOString(); + const executionId = generateExecutionId(common.workflow.id, common.task.id, timestamp); + const binding = buildEffectiveExecutionBinding({ + manifest, + expanded: common.expanded, + workflow: common.workflow, + task: common.task, + cwd, + }); + const taskHash = computeEffectiveTaskHash(binding); + + if (dryRun) { + const preview = buildDryRunResult(common, { binding, taskHash, timestamp, executionId }); + return common.isV2 ? Promise.resolve(preview) : preview; + } + + if (common.isV2) { + return executeApprovedV2(common, { + evidenceProviderOverride, + instanceId, + requireEvidence, + requireAuthorization, + identityDebug, + presentationDebug, + approvalId, + env, + cwd, + signer, + signingKey: explicitSigningKey, + binding, + taskHash, + timestamp, + executionId, + }); + } + + const approvalUsed = enforceApprovalGate({ + workflow: common.workflow, + task: common.task, + executionId, + approvalId, + env, + binding, + taskHash, + }); + const liveCommon = prepareLiveCommon(common, { + signer, + signingKey: explicitSigningKey, + cwd, + env, + }); + // v0.1 path: fully synchronous, preserves exact existing behavior + return executeV1(liveCommon, { approvalUsed, binding, taskHash, timestamp, executionId }); +} + +async function inspectTaskGovernance(manifest, mode, { + workflowId, + taskId, + instanceId, + identityDebug = false, + cwd = process.cwd(), + env = process.env, +} = {}) { + const common = resolveCommonState(manifest, { + workflowId, + taskId, + cwd, + env, + allowUnsupportedHandoff: true, + }); + const timestamp = new Date().toISOString(); + const executionId = generateExecutionId(common.workflow.id, common.task.id, timestamp); + const binding = buildEffectiveExecutionBinding({ + manifest, + expanded: common.expanded, + workflow: common.workflow, + task: common.task, + cwd, + }); + const taskHash = computeEffectiveTaskHash(binding); + if (!common.isV2) { - return executeV1(common, { dryRun, approvalId }); + if (mode === 'authorization' || mode === 'proof') { + throw Object.assign( + new Error(`${mode === 'proof' ? 'Authorization proof verification' : 'Authorization evaluation'} requires a version 0.2 manifest`), + { code: 'invalid_argument' } + ); + } + return { + ok: true, + mode, + source: { workflow_id: common.workflow.id, task_id: common.task.id }, + declared_identity: binding.identity, + resolved_identity: null, + principal_used: resolvePrincipal(common.identity), + trust: null, + delegation: null, + warnings: common.warnings, + }; } - // v0.2 path: returns a Promise (resolveSession may be async) return executeV2(common, { - dryRun, evidenceProviderOverride, instanceId, - requireEvidence, requireAuthorization, - identityDebug, presentationDebug, approvalId, env, + inspectionMode: mode, + instanceId, + requireEvidence: false, + requireAuthorization: mode === 'authorization', + identityDebug: identityDebug || mode === 'delegation', + presentationDebug: false, + env, + cwd, + approvalUsed: null, + binding, + taskHash, + timestamp, + executionId, }); } -function enforceApprovalGate({ workflow, task, executionId, approvalId, env }) { +export function inspectTaskIdentity(manifest, options = {}) { + return inspectTaskGovernance(manifest, 'identity', options); +} + +export function validateTaskDelegation(manifest, options = {}) { + return inspectTaskGovernance(manifest, 'delegation', options); +} + +export function evaluateTaskAuthorization(manifest, options = {}) { + return inspectTaskGovernance(manifest, 'authorization', options); +} + +export function verifyTaskAuthorizationProof(manifest, options = {}) { + return inspectTaskGovernance(manifest, 'proof', options); +} + +function enforceApprovalGate({ workflow, task, executionId, approvalId, env, binding, taskHash }) { if (!task.approval) return null; if (approvalPolicyAutoRejects(task.approval)) { throw Object.assign( @@ -466,11 +695,11 @@ function enforceApprovalGate({ workflow, task, executionId, approvalId, env }) { ); } if (!approvalPolicyRequiresApproval(task.approval)) return null; - const taskHash = computeTaskApprovalHash({ workflowId: workflow.id, task }); + const effectiveTaskHash = taskHash || computeTaskApprovalHash({ binding }); const grant = claimApproval({ workflowId: workflow.id, taskId: task.id, - taskHash, + taskHash: effectiveTaskHash, approvalId, executionId, env, @@ -500,12 +729,20 @@ function enforceApprovalGate({ workflow, task, executionId, approvalId, env }) { { code: 'approval_signature_invalid' } ); } + const currentScope = task.approval.approver_scope ?? null; + if ((grant.approver_scope ?? null) !== currentScope || !approverMatchesScope(grant.approver, currentScope)) { + throw Object.assign( + new Error(`Approval ${grant.approval_id} does not satisfy the task's current approver scope`), + { code: 'approval_scope_mismatch' } + ); + } return { approval_id: grant.approval_id, task_hash: grant.task_hash, approver: grant.approver, reason: grant.reason ?? null, risk_level: grant.risk_level ?? null, + approver_scope: grant.approver_scope ?? null, granted_at: grant.granted_at, expires_at: grant.expires_at, signature_verified: sigCheck.verified === true, @@ -528,6 +765,13 @@ function executeDelegated(common, options) { const effectivePrefix = schedulerPrefix || env.AGENTCLI_SCHEDULER_PREFIX || ''; const effectiveBin = schedulerBin || env.AGENTCLI_SCHEDULER_BIN || ''; + if (!dryRun && approvalPolicyAutoRejects(task.approval)) { + throw Object.assign( + new Error(`Task "${task.id}" has approval.policy="auto-reject"; runtime dispatch refused.`), + { code: 'approval_auto_rejected' } + ); + } + if (!effectivePrefix && !effectiveBin && !dryRun) { throw Object.assign( new Error( @@ -563,7 +807,7 @@ function executeDelegated(common, options) { // v0.1 execution path -- fully synchronous // --------------------------------------------------------------------------- -function executeV1(common, { dryRun, approvalId }) { +function executeV1(common, { approvalUsed, binding, taskHash, timestamp, executionId }) { const { workflow, task, identity, contract, verify, auditPolicy, shell, sandboxCommand, effectiveTimeout, warnings, provider, providerConfig, cwd, env, @@ -574,16 +818,7 @@ function executeV1(common, { dryRun, approvalId }) { let trustInfo = null; const principal = resolvePrincipal(identity); - const timestamp = new Date().toISOString(); - const executionId = generateExecutionId(workflow.id, task.id, timestamp); - - const commandMeta = { - program: shell.program, - args: shell.args, - cwd: shell.cwd || cwd, - env_keys: Object.keys(shell.env), - stdin_present: shell.stdin != null, - }; + const commandMeta = safeCommandMetadata(binding, shell, cwd); const cmdHash = commandHash(shell); @@ -611,69 +846,7 @@ function executeV1(common, { dryRun, approvalId }) { return { attestation: sigResult.attestation, attestation_note: null }; } - if (dryRun) { - const { attestation, attestation_note } = buildAndSign(); - - const record = { - execution_id: executionId, - timestamp, - source: { workflow_id: workflow.id, task_id: task.id }, - declared_identity: declaredIdentity, - resolved_identity: resolvedIdentity, - identity: { - principal: identity.principal ?? (identity.subject?.principal ?? null), - run_as: identity.run_as ?? null, - attestation_present: identity.attestation != null, - }, - principal_used: principal, - contract, - command: commandMeta, - command_hash: cmdHash, - authorization_proof: null, - authorization: null, - trust: trustInfo, - signer: provider.name, - attestation, - attestation_note, - warnings, - dry_run: true, - result: { status: 'dry_run' }, - }; - - if (auditPolicy === 'always') { - const paths = getAgentcliPaths({ env: common.env }); - writeAuditRecord(record, { auditPath: paths.audit }); - } - - return { - ok: true, - dry_run: true, - execution_id: executionId, - source: record.source, - declared_identity: declaredIdentity, - resolved_identity: resolvedIdentity, - identity, - principal_used: principal, - contract, - command: commandMeta, - command_hash: cmdHash, - authorization_proof: null, - authorization: null, - trust: trustInfo, - signer: provider.name, - attestation: attestation ? { method: attestation.method, key_fingerprint: attestation.key_fingerprint } : null, - attestation_note, - warnings, - }; - } - - const approvalUsed = enforceApprovalGate({ - workflow, task, executionId, approvalId, env: common.env, - }); - - const spawnEnv = Object.keys(shell.env).length > 0 - ? { ...process.env, ...env, ...shell.env } - : { ...process.env, ...env }; + const spawnEnv = buildChildEnvironment(env, shell.env); const spawnOpts = { cwd: shell.cwd || cwd, @@ -759,7 +932,7 @@ function executeV1(common, { dryRun, approvalId }) { let verifyResult = null; let verifyFailed = false; - if (verify && exitCode === 0 && !dryRun) { + if (verify && exitCode === 0) { verifyResult = runVerify(verify, { cwd: shell.cwd || cwd, env: spawnEnv, @@ -787,15 +960,13 @@ function executeV1(common, { dryRun, approvalId }) { source: { workflow_id: workflow.id, task_id: task.id }, declared_identity: declaredIdentity, resolved_identity: resolvedIdentity, - identity: { - principal: identity.principal ?? (identity.subject?.principal ?? null), - run_as: identity.run_as ?? null, - attestation_present: identity.attestation != null, - }, + identity: binding.identity, principal_used: principal, contract, command: commandMeta, command_hash: cmdHash, + effective_task_hash: taskHash, + manifest_digest: binding.manifest_digest, trust: trustInfo, signer: provider.name, attestation, @@ -831,9 +1002,12 @@ function executeV1(common, { dryRun, approvalId }) { source: { workflow_id: workflow.id, task_id: task.id }, declared_identity: declaredIdentity, resolved_identity: resolvedIdentity, - identity, + identity: binding.identity, principal_used: principal, contract, + command: commandMeta, + effective_task_hash: taskHash, + manifest_digest: binding.manifest_digest, result, verify: verifyResult, trust: trustInfo, @@ -850,35 +1024,80 @@ function executeV1(common, { dryRun, approvalId }) { // v0.2 execution path -- async (returns Promise) // --------------------------------------------------------------------------- -async function executeV2(common, { - dryRun, +async function executeV2(common, options) { + const cleanupState = {}; + let primaryError = null; + const warningStart = common.warnings.length; + try { + return await executeV2Core(common, options, cleanupState); + } catch (error) { + primaryError = error; + throw error; + } finally { + await cleanupProviderArtifacts(cleanupState.identityProviderInstance, { + materialization: cleanupState.materialization, + session: cleanupState.identitySession, + providerConfig: cleanupState.providerConfig || {}, + env: common.env, + commandEnv: cleanupState.commandEnv, + cwd: common.cwd, + runtimeCapabilities: cleanupState.runtimeCapabilities, + }, common.warnings); + await cleanupProviderArtifacts(cleanupState.identityProviderInstance, { + session: cleanupState.handoffResult?.session ?? null, + providerConfig: cleanupState.providerConfig || {}, + env: common.env, + commandEnv: cleanupState.commandEnv, + cwd: common.cwd, + runtimeCapabilities: cleanupState.runtimeCapabilities, + warningPrefix: 'Credential handoff cleanup', + }, common.warnings); + if (primaryError && common.warnings.length > warningStart) { + primaryError.cleanup_warnings = common.warnings.slice(warningStart); + } + } +} + +async function executeV2Core(common, { evidenceProviderOverride, instanceId, requireEvidence, requireAuthorization, identityDebug: includeIdentityDebug, presentationDebug: includePresentationDebug, - approvalId, env, -}) { + approvalUsed, + binding, + taskHash, + timestamp, + executionId, + inspectionMode = null, +}, cleanupState) { const { expanded, workflow, task, identity, contract, verify, auditPolicy, shell, sandboxCommand, effectiveTimeout, warnings, provider, providerConfig, cwd, } = common; - const timestamp = new Date().toISOString(); - const executionId = generateExecutionId(workflow.id, task.id, timestamp); - - const commandMeta = { - program: shell.program, - args: shell.args, - cwd: shell.cwd || cwd, - env_keys: Object.keys(shell.env), - stdin_present: shell.stdin != null, - }; - - const cmdHash = commandHash(shell); - const manifestDigest = computeManifestDigest(expanded); + const authorizationCommand = shell + ? { + program: shell.program, + args: shell.args, + cwd: shell.cwd || cwd, + env_keys: Object.keys(shell.env), + stdin_present: shell.stdin != null, + } + : { + session_target: task.target?.session_target ?? null, + payload_kind: task.target?.payload_kind ?? null, + }; + const commandMeta = shell + ? safeCommandMetadata(binding, shell, cwd) + : binding.command; + + const cmdHash = shell + ? commandHash(shell) + : binding.command?.digest ?? binding.manifest_digest; + const manifestDigest = binding.manifest_digest; const identityDeclaration = mergeIdentityProfile( identity.ref ? expanded.identity_profiles?.find(profile => profile.id === identity.ref) ?? null @@ -888,12 +1107,21 @@ async function executeV2(common, { const authorizationProof = resolveAuthorizationProof(workflow, task); const authorization = resolveAuthorization(workflow, task); const evidence = resolveEvidence(workflow, task); + const runtimeCapabilities = { + credentialRefresh: false, + credentialCache: false, + credentialHandoff: false, + }; + const identityCommandEnv = buildChildEnvironment(env, shell?.env || {}); + cleanupState.commandEnv = identityCommandEnv; + cleanupState.runtimeCapabilities = runtimeCapabilities; // ------------------------------------------------------------------ // Phase 1: Authorization Proof Verification // ------------------------------------------------------------------ let authorizationProofSummary = null; + const inspectIdentityOnly = inspectionMode === 'identity' || inspectionMode === 'delegation'; const authorizationProofDeclaration = authorizationProof?.ref ? mergeAuthorizationProofProfile( expanded.authorization_proof_profiles?.find(profile => profile.id === authorizationProof.ref) ?? null, @@ -901,108 +1129,84 @@ async function executeV2(common, { ) : null; const proofRef = authorizationProofDeclaration?.ref ?? null; - if (proofRef) { - if (authorizationProofDeclaration) { - const verifier = resolveVerifier(authorizationProofDeclaration.method || 'none'); - const verifyRequired = authorizationProofDeclaration.verify?.required === true; - - let proofValue = null; - if (authorizationProofDeclaration.proof?.value_from) { - proofValue = resolveValueFrom(authorizationProofDeclaration.proof.value_from, env, { cwd }); - } + if (!inspectIdentityOnly && proofRef && authorizationProofDeclaration) { + const proofBindingEnvironment = { + AGENTCLI_MANIFEST_DIGEST: manifestDigest, + AGENTCLI_EFFECTIVE_TASK_HASH: taskHash, + }; + const proofEnv = { ...env, ...proofBindingEnvironment }; + const verifier = assertValidAuthorizationProofProfile( + authorizationProofDeclaration, + { env: proofEnv, cwd } + ); + const method = authorizationProofDeclaration.method || 'none'; + const mustVerify = method !== 'none' || authorizationProofDeclaration.verify?.required === true; + let verificationResult; + try { + const proofValue = authorizationProofDeclaration.proof?.value_from + ? resolveValueFrom(authorizationProofDeclaration.proof.value_from, { + env: proofEnv, + commandEnv: buildChildEnvironment(env, proofBindingEnvironment), + cwd, + allowCommand: true, + }) + : null; + verificationResult = proofValue + ? await verifyAuthorizationProof(proofValue, authorizationProofDeclaration, { + manifest: expanded, + manifestDigest, + env: proofEnv, + cwd, + }) + : { verified: false, method, reason: 'proof value not available' }; + } catch (error) { + verificationResult = { + verified: false, + method, + reason: error.message, + }; + } - if (proofValue) { - let verificationContext = { - env, - manifestDigest, - }; - if (authorizationProofDeclaration.method === 'jwt') { - verificationContext = await resolveJwtVerificationContext( - proofValue, - authorizationProofDeclaration, - verificationContext, - ); - } - const verifyResult = await verifier.verifyProof( - proofValue, - authorizationProofDeclaration, - verificationContext, - ); - authorizationProofSummary = verifier.describeVerification(verifyResult, {}); - - if (verifyRequired && !verifyResult.verified) { - const failRecord = { - execution_id: executionId, - timestamp, - source: { workflow_id: workflow.id, task_id: task.id }, - declared_identity: { - provider: identityDeclaration.provider || 'none', - subject: { - principal: identityDeclaration.subject?.principal || null, - kind: identityDeclaration.subject?.kind || null, - issuer: identityDeclaration.subject?.issuer || null, - }, - trust_level: identityDeclaration.trust?.level || null, - }, - actor_context: buildActorContext({ - identityDeclaration, - authorizationProofSummary, - principal: resolvePrincipal(identityDeclaration), - target: task.target, - }), - authorization_proof: authorizationProofSummary, - resolved_identity: null, - result: null, - warnings, - }; - const paths = getAgentcliPaths({ env }); - writeAuditRecord(failRecord, { auditPath: paths.audit }); - throw Object.assign( - new Error(`Authorization proof verification failed: ${verifyResult.reason || 'verification failed'}`), - { code: 'authorization_proof_failed' } - ); - } - } else if (verifyRequired) { - const failRecord = { + authorizationProofSummary = verifier.describeVerification(verificationResult, {}); + if (!verificationResult.verified && mustVerify) { + if (auditPolicy !== 'none' && !inspectionMode) { + const paths = getAgentcliPaths({ env }); + writeAuditRecord({ execution_id: executionId, timestamp, source: { workflow_id: workflow.id, task_id: task.id }, - declared_identity: { - provider: identityDeclaration.provider || 'none', - subject: { - principal: identityDeclaration.subject?.principal || null, - kind: identityDeclaration.subject?.kind || null, - issuer: identityDeclaration.subject?.issuer || null, - }, - trust_level: identityDeclaration.trust?.level || null, - }, - actor_context: buildActorContext({ - identityDeclaration, - authorizationProofSummary: { - method: authorizationProofDeclaration.method, - verified: false, - reason: 'proof value not available', - }, - principal: resolvePrincipal(identityDeclaration), - target: task.target, - }), - authorization_proof: { - method: authorizationProofDeclaration.method, - verified: false, - reason: 'proof value not available' - }, - resolved_identity: null, + identity: binding.identity, + authorization_proof: authorizationProofSummary, + command: commandMeta, + effective_task_hash: taskHash, + manifest_digest: manifestDigest, result: null, warnings, - }; - const paths = getAgentcliPaths({ env }); - writeAuditRecord(failRecord, { auditPath: paths.audit }); - throw Object.assign( - new Error(`Authorization proof value not available for "${proofRef}"`), - { code: 'authorization_proof_failed' } - ); + }, { auditPath: paths.audit }); } + throw Object.assign( + new Error(`Authorization proof verification failed: ${verificationResult.reason || 'verification failed'}`), + { code: 'authorization_proof_failed' } + ); + } + } + + if (inspectionMode === 'proof') { + if (!proofRef) { + throw Object.assign( + new Error('Authorization proof verification requires a resolved authorization_proof block'), + { code: 'invalid_argument' } + ); } + return { + ok: true, + mode: inspectionMode, + source: { workflow_id: workflow.id, task_id: task.id }, + authorization_proof: authorizationProofSummary, + effective_task_hash: taskHash, + manifest_digest: manifestDigest, + warnings, + }; } // ------------------------------------------------------------------ @@ -1024,8 +1228,16 @@ async function executeV2(common, { const providerName = identityDeclaration.provider || 'none'; const idProvider = resolveIdentityProvider(providerName); identityProviderInstance = idProvider; + cleanupState.identityProviderInstance = idProvider; + cleanupState.providerConfig = identityDeclaration.auth?.provider_config || {}; try { + await assertProviderProfileValid(idProvider, identityDeclaration, 'Identity', { + env, + commandEnv: identityCommandEnv, + cwd, + runtimeCapabilities, + }); identitySession = normalizeIdentitySessionResult( await idProvider.resolveSession( { @@ -1034,10 +1246,20 @@ async function executeV2(common, { scope: identityDeclaration.scope ?? null, task_timeout_s: effectiveTimeout != null ? Math.max(1, Math.ceil(effectiveTimeout / 1000)) : null, }, - { env, cwd } + { env, commandEnv: identityCommandEnv, cwd, runtimeCapabilities } ), providerName ); + cleanupState.identitySession = identitySession; + if (identitySession.delegation_validation?.valid === false) { + throw Object.assign( + new Error(`Identity provider "${providerName}" returned an invalid delegation chain`), + { + code: 'identity_resolution_failed', + delegation_validation: identitySession.delegation_validation, + } + ); + } resolvedIdentity = idProvider.describeSession(identitySession, { env }); } catch (resolveError) { // Write resolution failure audit record @@ -1152,8 +1374,26 @@ async function executeV2(common, { const stepUpContext = buildStepUpContext(authorizationProofSummary); let authorizationDecision = null; + if (inspectIdentityOnly) { + const safeDelegation = identityDebug?.session?.delegation_validation + ?? resolvedIdentity?.delegation_validation + ?? null; + return { + ok: true, + mode: inspectionMode, + source: { workflow_id: workflow.id, task_id: task.id }, + declared_identity: declaredIdentity, + resolved_identity: resolvedIdentity, + principal_used: principal, + trust: trustInfo, + delegation: safeDelegation, + warnings, + ...(includeIdentityDebug ? { identity_debug: identityDebug } : {}), + }; + } + function writePreExecutionFailureAuditRecord(failureKey, failureValue) { - if (auditPolicy === 'none') return; + if (auditPolicy === 'none' || inspectionMode) return; const paths = getAgentcliPaths({ env }); writeAuditRecord({ execution_id: executionId, @@ -1179,8 +1419,8 @@ async function executeV2(common, { // Phase 4: Trust Level Enforcement // ------------------------------------------------------------------ - if (contract.required_trust_level && trustInfo) { - const effectiveLevel = trustInfo.effective_level || null; + if (contract.required_trust_level) { + const effectiveLevel = trustInfo?.effective_level || null; const enforcement = contract.trust_enforcement || 'none'; if (!effectiveLevel) { if (enforcement === 'advisory') { @@ -1218,7 +1458,12 @@ async function executeV2(common, { if (trustError.code === 'trust_level_insufficient') { throw trustError; } - // Unknown trust levels: treat as warning + if (enforcement === 'strict') { + throw Object.assign( + new Error(`Trust level comparison failed under strict enforcement: ${trustError.message}`), + { code: 'trust_level_insufficient', cause: trustError } + ); + } warnings.push(`Trust level comparison failed: ${trustError.message}`); } } @@ -1238,12 +1483,13 @@ async function executeV2(common, { if (authRef) { if (authorizationDeclaration) { const authProvider = resolveAuthorizationProvider(authorizationDeclaration.provider || 'none'); + await assertProviderProfileValid(authProvider, authorizationDeclaration, 'Authorization', { env, cwd }); const includeFields = authorizationDeclaration.request?.include || ['identity', 'contract', 'command']; const authRequest = normalizeAuthorizationRequest({ source: { workflow_id: workflow.id, task_id: task.id }, identity: { principal, trust_level: trustInfo?.effective_level }, contract, - command: commandMeta, + command: authorizationCommand, actor: actorContext, stepUp: stepUpContext, resource: null, @@ -1254,31 +1500,40 @@ async function executeV2(common, { const rawDecisionValue = typeof rawDecision === 'object' && rawDecision !== null ? (rawDecision.decision ?? rawDecision.result ?? rawDecision.value ?? rawDecision) : rawDecision; - const normalized = normalizeDecision(rawDecisionValue, authorizationDeclaration.decision || {}); + const decisionConfig = authorizationDeclaration.decision || {}; + const hasExplicitMapping = ['allow_values', 'deny_values', 'escalate_values'] + .some(key => Array.isArray(decisionConfig[key]) && decisionConfig[key].length > 0); + const normalized = !hasExplicitMapping && ['permit', 'deny', 'require-escalation'].includes(rawDecisionValue) + ? { decision: rawDecisionValue, original_value: rawDecisionValue, mapped: true } + : normalizeDecision(rawDecisionValue, decisionConfig); authorizationDecision = authProvider.describeDecision( { ...(typeof rawDecision === 'object' && rawDecision !== null ? rawDecision : {}), decision: normalized.decision }, {} ); if (normalized.decision === 'deny') { - writePreExecutionFailureAuditRecord('authorization_error', { - code: 'authorization_denied', - message: 'Authorization denied', - }); - throw Object.assign( - new Error('Authorization denied'), - { code: 'authorization_denied' } - ); + if (inspectionMode !== 'authorization') { + writePreExecutionFailureAuditRecord('authorization_error', { + code: 'authorization_denied', + message: 'Authorization denied', + }); + throw Object.assign( + new Error('Authorization denied'), + { code: 'authorization_denied' } + ); + } } if (normalized.decision === 'require-escalation') { - writePreExecutionFailureAuditRecord('authorization_error', { - code: 'authorization_escalation_required', - message: 'Authorization requires escalation', - }); - throw Object.assign( - new Error('Authorization requires escalation'), - { code: 'authorization_escalation_required' } - ); + if (inspectionMode !== 'authorization') { + writePreExecutionFailureAuditRecord('authorization_error', { + code: 'authorization_escalation_required', + message: 'Authorization requires escalation', + }); + throw Object.assign( + new Error('Authorization requires escalation'), + { code: 'authorization_escalation_required' } + ); + } } } } else if (requireAuthorization) { @@ -1288,6 +1543,21 @@ async function executeV2(common, { ); } + if (inspectionMode === 'authorization') { + return { + ok: true, + mode: inspectionMode, + source: { workflow_id: workflow.id, task_id: task.id }, + declared_identity: declaredIdentity, + resolved_identity: resolvedIdentity, + principal_used: principal, + trust: trustInfo, + authorization_proof: authorizationProofSummary, + authorization: authorizationDecision, + warnings, + }; + } + // ------------------------------------------------------------------ // Signing helper (shared by dry-run and live execution) // ------------------------------------------------------------------ @@ -1321,13 +1591,16 @@ async function executeV2(common, { // ------------------------------------------------------------------ let materialization = null; - const spawnEnv = Object.keys(shell.env).length > 0 - ? { ...process.env, ...common.env, ...shell.env } - : { ...process.env, ...common.env }; + const spawnEnv = { ...identityCommandEnv }; if (identitySession && identityProviderInstance) { const presentation = identityDeclaration.presentation || {}; - materialization = identityProviderInstance.materialize(identitySession, presentation, { env }); + materialization = await identityProviderInstance.materialize( + identitySession, + presentation, + { env, commandEnv: identityCommandEnv, cwd, runtimeCapabilities } + ); + cleanupState.materialization = materialization; // Merge materialized env vars into spawn environment if (materialization && materialization.env_vars) { Object.assign(spawnEnv, materialization.env_vars); @@ -1341,23 +1614,12 @@ async function executeV2(common, { let handoffResult = null; const declaredHandoff = identityDeclaration.presentation?.handoff || 'none'; if (declaredHandoff !== 'none' && identitySession && identityProviderInstance) { - if (identityProviderInstance.prepareHandoff && identityProviderInstance.capabilities?.handoff_modes?.includes(declaredHandoff)) { - try { - handoffResult = await identityProviderInstance.prepareHandoff( - identitySession, - { - mode: declaredHandoff, - target_scope: identityDeclaration.scope ?? identityDeclaration.auth?.scopes?.[0] ?? null, - parent_profile: identityDeclaration, - }, - { env, cwd } - ); - } catch (err) { - warnings.push(`Credential handoff (${declaredHandoff}) failed: ${err.message}`); - } - } else { - warnings.push(`Credential handoff "${declaredHandoff}" requested but provider does not support it`); - } + throw Object.assign( + new Error( + `Credential handoff "${declaredHandoff}" cannot be enforced by the local shell runtime` + ), + { code: 'unsupported_capability' } + ); } const presentationDebug = includePresentationDebug @@ -1367,96 +1629,6 @@ async function executeV2(common, { } : null; - // ------------------------------------------------------------------ - // Dry-run exit point - // ------------------------------------------------------------------ - - if (dryRun) { - const identityProviderConfig = identityDeclaration.auth?.provider_config || {}; - await cleanupProviderArtifacts(identityProviderInstance, { - materialization, - session: identitySession, - providerConfig: identityProviderConfig, - env, - cwd, - }, warnings); - await cleanupProviderArtifacts(identityProviderInstance, { - session: handoffResult?.session ?? null, - providerConfig: identityProviderConfig, - env, - cwd, - warningPrefix: 'Credential handoff cleanup', - }, warnings); - - const { attestation, attestation_note } = buildAndSign(); - - const record = { - execution_id: executionId, - timestamp, - source: { workflow_id: workflow.id, task_id: task.id }, - declared_identity: declaredIdentity, - resolved_identity: resolvedIdentity, - identity: identityDeclaration, - principal_used: principal, - actor_context: actorContext, - step_up: stepUpContext, - authorization_proof: authorizationProofSummary, - authorization: authorizationDecision, - contract, - command: commandMeta, - command_hash: cmdHash, - trust: trustInfo, - hashes: { command: cmdHash, result: null }, - handoff: { mode: declaredHandoff, prepared: handoffPrepared(handoffResult) }, - signer: provider.name, - attestation, - attestation_note, - warnings, - dry_run: true, - result: { status: 'dry_run' }, - }; - - if (auditPolicy === 'always') { - const paths = getAgentcliPaths({ env }); - writeAuditRecord(record, { auditPath: paths.audit }); - } - - return { - ok: true, - dry_run: true, - execution_id: executionId, - source: record.source, - declared_identity: declaredIdentity, - resolved_identity: resolvedIdentity, - identity: identityDeclaration, - principal_used: principal, - actor_context: actorContext, - step_up: stepUpContext, - contract, - command: commandMeta, - command_hash: cmdHash, - authorization_proof: authorizationProofSummary, - authorization: authorizationDecision, - trust: trustInfo, - hashes: { command: cmdHash, result: null }, - handoff: { mode: declaredHandoff, prepared: handoffPrepared(handoffResult) }, - signer: provider.name, - attestation: attestation ? { method: attestation.method, key_fingerprint: attestation.key_fingerprint } : null, - attestation_note, - warnings, - ...(identityDebug ? { identity_debug: identityDebug } : {}), - ...(presentationDebug ? { presentation_debug: presentationDebug } : {}), - }; - } - - // ------------------------------------------------------------------ - // Live execution: spawn the process - // ------------------------------------------------------------------ - - const approvalUsed = enforceApprovalGate({ - workflow, task, executionId, approvalId, env, - }); - const spawnOpts = { cwd: shell.cwd || cwd, env: spawnEnv, @@ -1468,8 +1640,14 @@ async function executeV2(common, { spawnOpts.timeout = effectiveTimeout; } - if (shell.stdin != null) { - spawnOpts.input = shell.stdin; + if (shell.stdin != null && materialization?.stdin != null) { + throw Object.assign( + new Error('Both shell.stdin and identity presentation target stdin; refusing ambiguous input'), + { code: 'validation_error' } + ); + } + if (shell.stdin != null || materialization?.stdin != null) { + spawnOpts.input = shell.stdin ?? materialization.stdin; } const startMs = Date.now(); @@ -1524,10 +1702,6 @@ async function executeV2(common, { const { attestation, attestation_note } = buildAndSign(); - // ------------------------------------------------------------------ - // Phase 6: Evidence - // ------------------------------------------------------------------ - let evidenceMetadata = null; const evidenceDeclaration = evidence?.ref ? mergeEvidenceProfile( @@ -1536,119 +1710,149 @@ async function executeV2(common, { ) : null; const evidRef = evidenceDeclaration?.ref ?? null; - if (evidRef) { - if (evidenceDeclaration) { + + const auditResult = { + exit_code: exitCode, + signal, + timed_out: timedOut, + duration_ms: durationMs, + stdout_bytes: result.stdout_bytes, + stderr_bytes: result.stderr_bytes, + output_hash: result.output_hash, + structured_present: structured != null, + }; + + // ------------------------------------------------------------------ + // Post-execution verify phase. Evidence is generated afterwards so its + // signed payload binds this verification outcome as well as command output. + // ------------------------------------------------------------------ + + let verifyResult = null; + let verifyFailed = false; + if (verify && exitCode === 0) { + verifyResult = runVerify(verify, { + cwd: shell.cwd || cwd, + env: spawnEnv, + sandboxCommand, + }); + if (!verifyResult.passed) { + if (verify.on_failure === 'warn') { + warnings.push(`Verify command failed (exit ${verifyResult.exit_code}): ${verifyResult.stderr || verifyResult.stdout || '(no output)'}`); + } else { + verifyFailed = true; + } + } + } + + // ------------------------------------------------------------------ + // Phase 6: Complete, versioned evidence + // ------------------------------------------------------------------ + + const evidenceRequired = requireEvidence || evidenceDeclaration?.verify?.required === true; + try { + if (evidRef && evidenceDeclaration) { const evProvider = resolveEvidenceProvider({ evidenceProvider: evidenceProviderOverride || evidenceDeclaration.provider, - env + env, }); const evConfig = evProvider.resolve(evidenceDeclaration.provider_config || {}, { env }); - const bindTargets = evidenceDeclaration.payload?.bind || ['execution_id', 'command', 'result']; - const complianceCtx = collectComplianceContext({ compliance_context: {} }, evidenceDeclaration.payload?.context || {}); - const evPayload = buildEvidencePayload({ + const complianceCtx = collectComplianceContext( + { compliance_context: {} }, + evidenceDeclaration.payload?.context || {} + ); + const evPayload = buildCompleteEvidencePayload({ executionId, timestamp, source: { workflow_id: workflow.id, task_id: task.id }, + manifestDigest: binding.manifest_digest, + effectiveTaskHash: taskHash, declaredIdentity, resolvedIdentity, authorizationProof: authorizationProofSummary, authorization: authorizationDecision, actorContext, contract, - command: commandMeta, - result: { - exit_code: exitCode, - duration_ms: durationMs, - stdout_bytes: result.stdout_bytes, - stderr_bytes: result.stderr_bytes, - structured_present: structured != null, - output_hash: result.output_hash, + command: { + ...binding.command, + env: spawnEnv, + stdin: spawnOpts.input ?? null, }, + result, + verify: verifyResult, complianceContext: complianceCtx, - bindTargets, }); - const serialized = serializePayload(evPayload, evidenceDeclaration.payload?.format || 'canonical-json'); + const serialized = serializePayload( + evPayload, + evidenceDeclaration.payload?.format || 'canonical-json' + ); const attestResult = evProvider.attest(serialized, evConfig || {}, { env }); if (attestResult.attested) { - evidenceMetadata = evProvider.describe(attestResult.envelope, {}); + evidenceMetadata = { + ...evProvider.describe(attestResult.envelope, {}), + envelope: attestResult.envelope, + }; } else { - evidenceMetadata = { provider: evProvider.name, attested: false, reason: attestResult.reason }; + evidenceMetadata = { + provider: evProvider.name, + attested: false, + reason: attestResult.reason, + envelope: null, + }; } - - if (requireEvidence && !attestResult.attested) { + if (evidenceRequired && !attestResult.attested) { throw Object.assign( new Error(`Evidence required but attestation failed: ${attestResult.reason}`), { code: 'evidence_failed' } ); } + } else if (evidenceRequired) { + throw Object.assign( + new Error('Evidence verification is required but no evidence block resolved'), + { code: 'evidence_failed' } + ); } - } else if (requireEvidence) { - throw Object.assign( - new Error('--require-evidence specified but no evidence block resolved'), - { code: 'invalid_argument' } - ); - } - - // ------------------------------------------------------------------ - // Post-execution verify phase - // - // Runs AFTER evidence attestation. Evidence proves what the command did - // (exit status, output hashes); verify is an operator-local check that - // the expected deliverable exists. These are complementary, not sequential - // dependencies. If end-to-end proof including verify is needed, extend the - // evidence payload rather than reordering phases. - // ------------------------------------------------------------------ - - let verifyResult = null; - let verifyFailed = false; - if (verify && exitCode === 0) { - verifyResult = runVerify(verify, { - cwd: shell.cwd || cwd, - env: spawnEnv, - sandboxCommand, - }); - if (!verifyResult.passed) { - if (verify.on_failure === 'warn') { - warnings.push(`Verify command failed (exit ${verifyResult.exit_code}): ${verifyResult.stderr || verifyResult.stdout || '(no output)'}`); - } else { - verifyFailed = true; - } + } catch (evidenceError) { + const evidenceFailure = { + code: evidenceError.code || 'evidence_failed', + message: evidenceError.message, + }; + if (auditPolicy !== 'none') { + const paths = getAgentcliPaths({ env }); + writeAuditRecord({ + execution_id: executionId, + timestamp, + source: { workflow_id: workflow.id, task_id: task.id }, + declared_identity: declaredIdentity, + resolved_identity: resolvedIdentity, + principal_used: principal, + actor_context: actorContext, + authorization_proof: authorizationProofSummary, + authorization: authorizationDecision, + trust: trustInfo, + contract, + command: commandMeta, + identity: binding.identity, + effective_task_hash: taskHash, + manifest_digest: binding.manifest_digest, + verify: verifyResult, + evidence: evidenceMetadata, + evidence_error: evidenceFailure, + warnings, + dry_run: false, + result: auditResult, + approval_used: approvalUsed, + }, { auditPath: paths.audit }); } + if (!evidenceError.code) evidenceError.code = 'evidence_failed'; + throw evidenceError; } const effectiveOk = exitCode === 0 && !verifyFailed; - const identityProviderConfig = identityDeclaration.auth?.provider_config || {}; - await cleanupProviderArtifacts(identityProviderInstance, { - materialization, - session: identitySession, - providerConfig: identityProviderConfig, - env, - cwd, - }, warnings); - await cleanupProviderArtifacts(identityProviderInstance, { - session: handoffResult?.session ?? null, - providerConfig: identityProviderConfig, - env, - cwd, - warningPrefix: 'Credential handoff cleanup', - }, warnings); - // ------------------------------------------------------------------ // Phase 7: Enhanced Audit Record // ------------------------------------------------------------------ - const auditResult = { - exit_code: exitCode, - signal, - timed_out: timedOut, - duration_ms: durationMs, - stdout_bytes: result.stdout_bytes, - stderr_bytes: result.stderr_bytes, - output_hash: result.output_hash, - structured_present: structured != null, - }; - const shouldAudit = auditPolicy === 'always' || (auditPolicy === 'on-failure' && !effectiveOk); @@ -1671,7 +1875,9 @@ async function executeV2(common, { hashes: { command: cmdHash, result: `sha256:${outputHash}` }, handoff: { mode: declaredHandoff, prepared: handoffPrepared(handoffResult) }, evidence: evidenceMetadata, - identity: identityDeclaration, + identity: binding.identity, + effective_task_hash: taskHash, + manifest_digest: binding.manifest_digest, command_hash: cmdHash, signer: provider.name, attestation, @@ -1711,11 +1917,14 @@ async function executeV2(common, { source: { workflow_id: workflow.id, task_id: task.id }, declared_identity: declaredIdentity, resolved_identity: resolvedIdentity, - identity: identityDeclaration, + identity: binding.identity, principal_used: principal, actor_context: actorContext, step_up: stepUpContext, contract, + command: commandMeta, + effective_task_hash: taskHash, + manifest_digest: binding.manifest_digest, result, verify: verifyResult, authorization_proof: authorizationProofSummary, diff --git a/src/home.js b/src/home.js index f9cd3c7..0fa72ca 100644 --- a/src/home.js +++ b/src/home.js @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; @@ -37,7 +37,8 @@ function readBundledSample() { export function ensureAgentcliHome({ env = process.env, homeDir = homedir(), force = false } = {}) { const paths = getAgentcliPaths({ env, homeDir }); for (const dir of [paths.root, paths.manifests, paths.output, paths.state, paths.registry]) { - mkdirSync(dir, { recursive: true }); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + if (process.platform !== 'win32') chmodSync(dir, 0o700); } const created = []; @@ -58,15 +59,26 @@ Typical flow: 3. Run: agentcli compile --target openclaw-scheduler --explain 4. Run: agentcli apply --db ~/.openclaw/scheduler/scheduler.db --scheduler-prefix ~/.openclaw/scheduler --dry-run `; - writeFileSync(paths.readme, readme, 'utf8'); + writeFileSync(paths.readme, readme, { encoding: 'utf8', mode: 0o600 }); + if (process.platform !== 'win32') chmodSync(paths.readme, 0o600); created.push(paths.readme); } if (force || !existsSync(paths.sampleManifest)) { - writeFileSync(paths.sampleManifest, `${readBundledSample().trim()}\n`, 'utf8'); + writeFileSync(paths.sampleManifest, `${readBundledSample().trim()}\n`, { + encoding: 'utf8', + mode: 0o600, + }); + if (process.platform !== 'win32') chmodSync(paths.sampleManifest, 0o600); created.push(paths.sampleManifest); } + if (process.platform !== 'win32') { + for (const file of [paths.readme, paths.sampleManifest]) { + if (existsSync(file)) chmodSync(file, 0o600); + } + } + return { ok: true, paths, diff --git a/src/identity/entra-agent-id.js b/src/identity/entra-agent-id.js index 04f7e5b..fff22ac 100644 --- a/src/identity/entra-agent-id.js +++ b/src/identity/entra-agent-id.js @@ -51,7 +51,7 @@ function isValidGuidIfApplicable(value) { * @param {object} [env] - Environment variable map, defaults to process.env. * @returns {string|null} The resolved value, or null if unresolvable. */ -function resolveValueFrom(valueFrom, env = process.env, { cwd = process.cwd() } = {}) { +function resolveValueFrom(valueFrom, env = process.env, { cwd = process.cwd(), commandEnv = env } = {}) { if (!valueFrom) return null; if (valueFrom.env) { return env[valueFrom.env] || null; @@ -64,7 +64,7 @@ function resolveValueFrom(valueFrom, env = process.env, { cwd = process.cwd() } } } if (valueFrom.command) { - return resolveCommandValue(valueFrom.command, { env, cwd }); + return resolveCommandValue(valueFrom.command, { env, commandEnv, cwd }); } return null; } @@ -84,7 +84,7 @@ function resolveValueFrom(valueFrom, env = process.env, { cwd = process.cwd() } * @param {string} blueprintAppId - The blueprint application ID (used for IMDS resource). * @returns {Promise} The resolved client assertion, or null. */ -async function resolveClientAssertion(profile, env, blueprintAppId, cwd) { +async function resolveClientAssertion(profile, env, blueprintAppId, cwd, commandEnv = env) { // 1. Environment variable const envAssertion = env.AGENTCLI_ENTRA_CLIENT_ASSERTION; if (typeof envAssertion === 'string' && envAssertion.length > 0) { @@ -96,7 +96,7 @@ async function resolveClientAssertion(profile, env, blueprintAppId, cwd) { // 2. inputs.client_assertion.value_from if (inputs.client_assertion && inputs.client_assertion.value_from) { - const resolved = resolveValueFrom(inputs.client_assertion.value_from, env, { cwd }); + const resolved = resolveValueFrom(inputs.client_assertion.value_from, env, { cwd, commandEnv }); if (resolved) return resolved; } @@ -105,7 +105,7 @@ async function resolveClientAssertion(profile, env, blueprintAppId, cwd) { return providerConfig.client_assertion; } if (providerConfig.client_assertion && typeof providerConfig.client_assertion === 'object' && providerConfig.client_assertion.value_from) { - const resolved = resolveValueFrom(providerConfig.client_assertion.value_from, env, { cwd }); + const resolved = resolveValueFrom(providerConfig.client_assertion.value_from, env, { cwd, commandEnv }); if (resolved) return resolved; } @@ -277,6 +277,7 @@ const entraAgentIdProvider = { async resolveSession(request, ctx) { const env = (ctx && ctx.env) || process.env; const cwd = (ctx && ctx.cwd) || process.cwd(); + const commandEnv = (ctx && ctx.commandEnv) || env; const profile = request.profile || {}; const providerConfig = (profile.auth && profile.auth.provider_config) || {}; const auth = profile.auth || {}; @@ -293,7 +294,7 @@ const entraAgentIdProvider = { const subject = profile.subject || {}; // 1. Resolve client assertion - const clientAssertion = await resolveClientAssertion(profile, env, blueprintAppId, cwd); + const clientAssertion = await resolveClientAssertion(profile, env, blueprintAppId, cwd, commandEnv); if (!clientAssertion) { const err = new Error( @@ -429,11 +430,13 @@ const entraAgentIdProvider = { } const env = (ctx && ctx.env) || process.env; + const cwd = (ctx && ctx.cwd) || process.cwd(); + const commandEnv = (ctx && ctx.commandEnv) || env; // Resolve client assertion for the downscope request const clientAssertion = await resolveClientAssertion({ auth: { provider_config: assertions }, - }, env, blueprintAppId); + }, env, blueprintAppId, cwd, commandEnv); if (!clientAssertion) { return { prepared: false, reason: 'client assertion not available for downscope token request' }; diff --git a/src/identity/file-bearer.js b/src/identity/file-bearer.js index 7076afd..6c5bcb5 100644 --- a/src/identity/file-bearer.js +++ b/src/identity/file-bearer.js @@ -37,7 +37,7 @@ function tempFilePath(prefix) { * @param {object} env - Environment variables object. * @returns {string|undefined} The resolved value, or undefined. */ -function resolveValueFrom(valueFrom, env, { cwd = process.cwd() } = {}) { +function resolveValueFrom(valueFrom, env, { cwd = process.cwd(), commandEnv = env } = {}) { if (!valueFrom || typeof valueFrom !== 'object') return undefined; if (valueFrom.env) { const val = env[valueFrom.env]; @@ -51,29 +51,26 @@ function resolveValueFrom(valueFrom, env, { cwd = process.cwd() } = {}) { } } if (valueFrom.command) { - return resolveCommandValue(valueFrom.command, { env, cwd }) ?? undefined; + return resolveCommandValue(valueFrom.command, { env, commandEnv, cwd }) ?? undefined; } return undefined; } /** * Check if a file's permissions indicate it is world-readable. - * Returns a warning string if world-readable, or null otherwise. + * Returns true if world-readable, false if private, or null when the mode + * cannot be inspected. Source paths are never returned in provider output. * * @param {string} filePath - Path to check. - * @returns {string|null} Warning message, or null if permissions are acceptable. + * @returns {boolean|null} */ function checkWorldReadable(filePath) { try { const stats = statSync(filePath); - const othersRead = stats.mode & 0o004; - if (othersRead) { - return `Token file "${filePath}" is world-readable (mode ${(stats.mode & 0o777).toString(8)}). Consider restricting permissions to 0600.`; - } + return Boolean(stats.mode & 0o004); } catch (_e) { - // If stat fails, skip the permission check; existence check handles missing files. + return null; } - return null; } const fileBearerProvider = { @@ -149,6 +146,7 @@ const fileBearerProvider = { resolveSession(request, ctx) { const env = (ctx && ctx.env) || process.env; const cwd = (ctx && ctx.cwd) || process.cwd(); + const commandEnv = (ctx && ctx.commandEnv) || env; const profile = request.profile || {}; const providerConfig = (profile.auth && profile.auth.provider_config) || {}; const inputs = (profile.auth && profile.auth.inputs) || {}; @@ -158,7 +156,7 @@ const fileBearerProvider = { let tokenFilePath = providerConfig.token_file || undefined; if (!tokenFilePath && inputs.token_file && inputs.token_file.value_from) { - tokenFilePath = resolveValueFrom(inputs.token_file.value_from, env, { cwd }); + tokenFilePath = resolveValueFrom(inputs.token_file.value_from, env, { cwd, commandEnv }); } if (!tokenFilePath || typeof tokenFilePath !== 'string' || tokenFilePath.length === 0) { @@ -186,10 +184,10 @@ const fileBearerProvider = { // Check permissions and collect warnings const providerAssertions = {}; - const permWarning = checkWorldReadable(tokenFilePath); - if (permWarning) { - providerAssertions.permission_warning = permWarning; - } + const worldReadable = checkWorldReadable(tokenFilePath); + providerAssertions.token_source = 'file'; + providerAssertions.token_file_permissions_checked = worldReadable !== null; + providerAssertions.token_file_world_readable = worldReadable === true; // Read and trim the token const token = readFileSync(tokenFilePath, 'utf8').trim(); @@ -208,8 +206,6 @@ const fileBearerProvider = { const subject = profile.subject || {}; const auth = profile.auth || {}; - providerAssertions.token_file = tokenFilePath; - return { provider: 'file-bearer', subject: { diff --git a/src/identity/index.js b/src/identity/index.js index 96685a4..30ca871 100644 --- a/src/identity/index.js +++ b/src/identity/index.js @@ -17,6 +17,16 @@ */ import { stripeApiKeyProvider } from './stripe-api-key.js'; +import { + cleanupMaterializedCredentials, + combineValidationResults, + describeCredentialSession, + enforceDelegationPolicy, + materializeCredentialBindings, + sanitizeProviderError, + validateCommonIdentityProfile, + validateSecureEndpoint, +} from './session.js'; const providers = new Map(); @@ -28,6 +38,356 @@ const REQUIRED_METHODS = [ 'cleanup', ]; +const REQUIRED_CAPABILITY_ARRAYS = [ + 'auth_modes', + 'credential_types', + 'presentation_kinds', + 'handoff_modes', + 'trust_levels', + 'approval_mechanisms', +]; +const HARDENED_PROVIDER = Symbol('agentcli.hardenedIdentityProvider'); +const cleanupResults = new WeakMap(); + +function isThenable(value) { + return value !== null && typeof value === 'object' && typeof value.then === 'function'; +} + +function endpointValidation(provider, profile) { + const config = profile?.auth?.provider_config || {}; + const errors = []; + for (const [key, path] of [ + ['token_endpoint', 'auth.provider_config.token_endpoint'], + ['api_base', 'auth.provider_config.api_base'], + ['authority', 'auth.provider_config.authority'], + ]) { + if (config[key] != null) errors.push(...validateSecureEndpoint(config[key], path)); + } + return errors.length === 0 ? { valid: true } : { valid: false, errors }; +} + +function sanitizeValidationResult(result, profile, ctx) { + if (result?.valid !== false) return { valid: true }; + const errors = (result.errors || [result.error || 'profile validation failed']).map(error => + sanitizeProviderError( + { message: typeof error === 'string' ? error : error?.message }, + { profile, env: ctx?.env } + ).message + ); + return { valid: false, errors }; +} + +function finalizeResolvedSession(provider, result, profile, ctx) { + if (result?.ok === false) { + const safe = sanitizeProviderError( + { message: result.error, code: 'identity_resolution_failed', transient: result.transient }, + { profile, env: ctx?.env } + ); + return { ...result, error: safe.message }; + } + + const session = result?.ok === true && result.session ? result.session : result; + if (!session || typeof session !== 'object') { + throw Object.assign( + new Error(`Identity provider "${provider.name}" returned an invalid credential session`), + { code: 'identity_resolution_failed' } + ); + } + + const policy = profile?.auth?.delegation_policy || {}; + const delegation = provider.validateDelegation + ? provider.validateDelegation(session.delegation_chain || [], policy, ctx) + : enforceDelegationPolicy(session, policy); + if (delegation?.valid === false) { + const safeDelegation = describeCredentialSession({ + credentials: {}, + delegation_validation: delegation, + }).delegation_validation; + throw Object.assign( + new Error(`Identity provider "${provider.name}" returned a delegation chain that violates policy`), + { code: 'identity_delegation_invalid', delegation_validation: safeDelegation } + ); + } + session.delegation_validation = { + ...(session.delegation_validation || {}), + ...(delegation || {}), + valid: true, + }; + + return result?.ok === true && result.session ? { ...result, session } : session; +} + +function wrapProviderSecurity(provider) { + if (provider[HARDENED_PROVIDER]) return provider; + + const originalValidate = provider.validateProfile.bind(provider); + const originalResolve = provider.resolveSession.bind(provider); + const originalCleanup = provider.cleanup.bind(provider); + const originalDelegation = typeof provider.validateDelegation === 'function' + ? provider.validateDelegation.bind(provider) + : null; + const originalRefresh = typeof provider.refreshSession === 'function' + ? provider.refreshSession.bind(provider) + : null; + const originalHandoff = typeof provider.prepareHandoff === 'function' + ? provider.prepareHandoff.bind(provider) + : null; + + // Shared materialization supports stdin for providers that already support + // generic env/file presentation. Stripe intentionally remains env-only. + if (provider.name !== 'stripe-api-key' && + provider.capabilities.presentation_kinds.some(kind => kind === 'env' || kind === 'file') && + !provider.capabilities.presentation_kinds.includes('stdin')) { + provider.capabilities.presentation_kinds.push('stdin'); + } + + provider.validateProfile = function validateHardenedProfile(profile, ctx = {}) { + const common = validateCommonIdentityProfile(provider, profile, ctx); + const endpoints = endpointValidation(provider, profile); + let own; + try { + // Existing OIDC validators use allowInsecure for local mock servers. + // The common endpoint validation above still rejects every non-loopback + // HTTP endpoint, so this cannot enable remote plaintext transport. + own = originalValidate(profile, { ...ctx, allowInsecure: true }); + } catch (error) { + own = { valid: false, errors: [sanitizeProviderError(error, { profile, env: ctx?.env }).message] }; + } + if (isThenable(own)) { + return own + .then(result => sanitizeValidationResult(combineValidationResults(common, endpoints, result), profile, ctx)) + .catch(error => ({ + valid: false, + errors: [sanitizeProviderError(error, { profile, env: ctx?.env }).message], + })); + } + return sanitizeValidationResult(combineValidationResults(common, endpoints, own), profile, ctx); + }; + + if (originalDelegation) { + provider.validateDelegation = function validateHardenedDelegation(chain, policy, ctx) { + let providerResult; + try { + providerResult = originalDelegation(chain, policy, ctx); + } catch (error) { + return { + valid: false, + errors: [sanitizeProviderError(error, { env: ctx?.env }).message], + }; + } + const common = enforceDelegationPolicy({ delegation_chain: chain }, policy); + if (isThenable(providerResult)) { + return providerResult.then(result => ({ + ...common, + ...result, + valid: common.valid && result?.valid !== false, + errors: [...(common.errors || []), ...(result?.errors || [])], + })); + } + return { + ...common, + ...(providerResult || {}), + valid: common.valid && providerResult?.valid !== false, + errors: [...(common.errors || []), ...(providerResult?.errors || [])], + }; + }; + } + + provider.resolveSession = function resolveHardenedSession(request, ctx = {}) { + const profile = request?.profile || {}; + const validation = provider.validateProfile(profile, ctx); + + const resolveAfterValidation = validated => { + if (validated?.valid === false) { + throw Object.assign( + new Error(`Identity profile for provider "${provider.name}" is invalid: ${(validated.errors || []).join('; ')}`), + { code: 'identity_profile_invalid', validation: validated } + ); + } + let result; + try { + result = originalResolve(request, ctx); + } catch (error) { + throw sanitizeProviderError(error, { profile, env: ctx?.env }); + } + if (isThenable(result)) { + return result + .then(value => finalizeResolvedSession(provider, value, profile, ctx)) + .catch(error => { throw sanitizeProviderError(error, { profile, env: ctx?.env }); }); + } + return finalizeResolvedSession(provider, result, profile, ctx); + }; + + return isThenable(validation) + ? validation.then(resolveAfterValidation) + : resolveAfterValidation(validation); + }; + + provider.describeSession = function describeHardenedSession(session) { + return describeCredentialSession(session); + }; + + provider.materialize = function materializeHardenedSession(session, presentation = {}, _ctx = {}) { + const stripeDefault = provider.name === 'stripe-api-key' + ? [{ + source: 'credentials.api_key.value', + target: { kind: 'env', name: 'STRIPE_API_KEY' }, + required: true, + redact: true, + }] + : []; + const explicit = Array.isArray(presentation?.bindings) ? presentation.bindings : []; + const effectivePresentation = provider.name === 'stripe-api-key' + ? { ...presentation, bindings: [...stripeDefault, ...explicit] } + : presentation; + const result = materializeCredentialBindings(session, effectivePresentation, { + allowedTargetKinds: provider.capabilities.presentation_kinds, + tempPrefix: `agentcli-${provider.name}`, + }); + + if (provider.name === 'stripe-api-key' && session?.provider_assertions?.key_strategy === 'dynamic') { + result.cleanup_required = true; + Object.defineProperty(result, 'session', { + value: session, + enumerable: false, + configurable: false, + writable: false, + }); + } + return result; + }; + + provider.cleanup = function cleanupHardenedSession(materialization, ctx = {}) { + if (materialization && typeof materialization === 'object') { + const existing = cleanupResults.get(materialization); + if (existing) return existing; + } + + const localResult = cleanupMaterializedCredentials(materialization); + if (provider.name !== 'stripe-api-key') { + if ((localResult.warnings || []).length === 0 && materialization && typeof materialization === 'object') { + cleanupResults.set(materialization, localResult); + } + return localResult; + } + + let providerResult; + try { + providerResult = originalCleanup(materialization, ctx); + } catch (error) { + providerResult = { + cleaned: true, + warnings: [sanitizeProviderError(error, { env: ctx?.env }).message], + }; + } + const combine = result => { + const warnings = [ + ...(localResult.warnings || []), + ...(result?.warnings || []).map(warning => + sanitizeProviderError({ message: warning }, { env: ctx?.env }).message + ), + ]; + return warnings.length > 0 ? { cleaned: true, warnings } : { cleaned: true }; + }; + if (isThenable(providerResult)) { + const operation = providerResult + .then(combine) + .then(result => { + if ((result.warnings || []).length > 0) cleanupResults.delete(materialization); + else cleanupResults.set(materialization, result); + return result; + }) + .catch(error => { + cleanupResults.delete(materialization); + throw error; + }); + if (materialization && typeof materialization === 'object') cleanupResults.set(materialization, operation); + return operation; + } + const result = combine(providerResult); + if ((result.warnings || []).length === 0 && materialization && typeof materialization === 'object') { + cleanupResults.set(materialization, result); + } + return result; + }; + + if (originalRefresh) { + provider.refreshSession = function refreshHardenedSession(session, ctx = {}) { + let result; + try { + result = originalRefresh(session, ctx); + } catch (error) { + throw sanitizeProviderError(error, { env: ctx?.env }); + } + const finalize = value => finalizeResolvedSession( + provider, + value, + ctx.profile || { auth: { delegation_policy: ctx.delegation_policy || {} } }, + ctx + ); + return isThenable(result) + ? result.then(finalize).catch(error => { throw sanitizeProviderError(error, { env: ctx?.env }); }) + : finalize(result); + }; + } + + if (originalHandoff) { + provider.prepareHandoff = function prepareHardenedHandoff(session, handoff, ctx = {}) { + let result; + try { + result = originalHandoff(session, handoff, ctx); + } catch (error) { + throw sanitizeProviderError(error, { profile: handoff?.parent_profile, env: ctx?.env }); + } + const finalize = value => { + if (value && typeof value === 'object' && value.prepared !== true) { + const safe = { ...value }; + if (typeof safe.error === 'string') { + safe.error = sanitizeProviderError( + { message: safe.error }, + { profile: handoff?.parent_profile, env: ctx?.env } + ).message; + } + if (typeof safe.reason === 'string') { + safe.reason = sanitizeProviderError( + { message: safe.reason }, + { profile: handoff?.parent_profile, env: ctx?.env } + ).message; + } + return safe; + } + if (value?.prepared && value.session) { + const delegationPolicy = handoff?.parent_profile?.auth?.delegation_policy || {}; + const providerPolicy = { + ...delegationPolicy, + scope_hierarchy: handoff?.parent_profile?.auth?.provider_config?.scope_hierarchy || {}, + }; + const validation = provider.validateDelegation + ? provider.validateDelegation( + value.session.delegation_chain || [], + providerPolicy, + ctx + ) + : enforceDelegationPolicy(value.session, delegationPolicy); + if (validation?.valid === false) { + throw Object.assign(new Error('Prepared credential handoff violates delegation policy'), { + code: 'identity_delegation_invalid', + delegation_validation: validation, + }); + } + } + return value; + }; + return isThenable(result) + ? result.then(finalize).catch(error => { throw sanitizeProviderError(error, { env: ctx?.env }); }) + : finalize(result); + }; + } + + Object.defineProperty(provider, HARDENED_PROVIDER, { value: true }); + return provider; +} + /** * Register an identity provider. * @@ -46,6 +406,18 @@ export function registerProvider(provider) { throw new Error(`Identity provider "${provider.name}" must have a capabilities object`); } + for (const capability of REQUIRED_CAPABILITY_ARRAYS) { + if (!Array.isArray(provider.capabilities[capability])) { + throw new Error(`Identity provider "${provider.name}" capability ${capability} must be an array`); + } + } + if (typeof provider.capabilities.refreshable !== 'boolean') { + throw new Error(`Identity provider "${provider.name}" capability refreshable must be a boolean`); + } + if (typeof provider.capabilities.delegation !== 'boolean') { + throw new Error(`Identity provider "${provider.name}" capability delegation must be a boolean`); + } + for (const method of REQUIRED_METHODS) { if (typeof provider[method] !== 'function') { throw new Error(`Identity provider "${provider.name}" must implement ${method}()`); @@ -73,7 +445,7 @@ export function registerProvider(provider) { ); } - providers.set(provider.name, provider); + providers.set(provider.name, wrapProviderSecurity(provider)); } /** diff --git a/src/identity/oidc-client-credentials.js b/src/identity/oidc-client-credentials.js index 1c6e6d3..ca11550 100644 --- a/src/identity/oidc-client-credentials.js +++ b/src/identity/oidc-client-credentials.js @@ -24,7 +24,7 @@ import { resolveSourcePath, formatMaterializationValue, buildCredentialSummary } * @param {object} [env] - Environment variable map, defaults to process.env. * @returns {string|null} The resolved value, or null if unresolvable. */ -function resolveValueFrom(valueFrom, env = process.env, { cwd = process.cwd() } = {}) { +function resolveValueFrom(valueFrom, env = process.env, { cwd = process.cwd(), commandEnv = env } = {}) { if (!valueFrom) return null; if (valueFrom.env) { return env[valueFrom.env] || null; @@ -37,7 +37,7 @@ function resolveValueFrom(valueFrom, env = process.env, { cwd = process.cwd() } } } if (valueFrom.command) { - return resolveCommandValue(valueFrom.command, { env, cwd }); + return resolveCommandValue(valueFrom.command, { env, commandEnv, cwd }); } return null; } @@ -52,7 +52,7 @@ function resolveValueFrom(valueFrom, env = process.env, { cwd = process.cwd() } * @param {object} env - Environment variable map. * @returns {string|null} The resolved client secret, or null if unresolvable. */ -function resolveClientSecret(profile, env, cwd) { +function resolveClientSecret(profile, env, cwd, commandEnv = env) { const providerConfig = (profile.auth && profile.auth.provider_config) || {}; const inputs = (profile.auth && profile.auth.inputs) || {}; @@ -63,13 +63,13 @@ function resolveClientSecret(profile, env, cwd) { // provider_config.client_secret: value_from object if (providerConfig.client_secret && typeof providerConfig.client_secret === 'object' && providerConfig.client_secret.value_from) { - const resolved = resolveValueFrom(providerConfig.client_secret.value_from, env, { cwd }); + const resolved = resolveValueFrom(providerConfig.client_secret.value_from, env, { cwd, commandEnv }); if (resolved) return resolved; } // inputs.client_secret.value_from if (inputs.client_secret && inputs.client_secret.value_from) { - const resolved = resolveValueFrom(inputs.client_secret.value_from, env, { cwd }); + const resolved = resolveValueFrom(inputs.client_secret.value_from, env, { cwd, commandEnv }); if (resolved) return resolved; } @@ -168,6 +168,7 @@ const oidcClientCredentialsProvider = { async resolveSession(request, ctx) { const env = (ctx && ctx.env) || process.env; const cwd = (ctx && ctx.cwd) || process.cwd(); + const commandEnv = (ctx && ctx.commandEnv) || env; const profile = request.profile || {}; const providerConfig = (profile.auth && profile.auth.provider_config) || {}; const auth = profile.auth || {}; @@ -175,7 +176,7 @@ const oidcClientCredentialsProvider = { const tokenEndpoint = providerConfig.token_endpoint; const clientId = providerConfig.client_id; - const clientSecret = resolveClientSecret(profile, env, cwd); + const clientSecret = resolveClientSecret(profile, env, cwd, commandEnv); const trustLevel = (profile.trust && profile.trust.level) || 'supervised'; const subject = profile.subject || {}; diff --git a/src/identity/oidc-token-exchange.js b/src/identity/oidc-token-exchange.js index 25a74f3..901960f 100644 --- a/src/identity/oidc-token-exchange.js +++ b/src/identity/oidc-token-exchange.js @@ -25,7 +25,7 @@ import { resolveSourcePath, formatMaterializationValue, buildCredentialSummary } * @param {object} [env] - Environment variable map, defaults to process.env. * @returns {string|null} The resolved value, or null if unresolvable. */ -function resolveValueFrom(valueFrom, env = process.env, { cwd = process.cwd() } = {}) { +function resolveValueFrom(valueFrom, env = process.env, { cwd = process.cwd(), commandEnv = env } = {}) { if (!valueFrom) return null; if (valueFrom.env) { return env[valueFrom.env] || null; @@ -38,7 +38,7 @@ function resolveValueFrom(valueFrom, env = process.env, { cwd = process.cwd() } } } if (valueFrom.command) { - return resolveCommandValue(valueFrom.command, { env, cwd }); + return resolveCommandValue(valueFrom.command, { env, commandEnv, cwd }); } return null; } @@ -53,7 +53,7 @@ function resolveValueFrom(valueFrom, env = process.env, { cwd = process.cwd() } * @param {object} env - Environment variable map. * @returns {string|null} The resolved subject token, or null if unresolvable. */ -function resolveSubjectToken(profile, env, cwd) { +function resolveSubjectToken(profile, env, cwd, commandEnv = env) { const providerConfig = (profile.auth && profile.auth.provider_config) || {}; const inputs = (profile.auth && profile.auth.inputs) || {}; @@ -67,7 +67,7 @@ function resolveSubjectToken(profile, env, cwd) { // inputs.subject_token.value_from: resolve indirection if (inputs.subject_token && inputs.subject_token.value_from) { - const resolved = resolveValueFrom(inputs.subject_token.value_from, env, { cwd }); + const resolved = resolveValueFrom(inputs.subject_token.value_from, env, { cwd, commandEnv }); if (resolved) return resolved; } @@ -84,7 +84,7 @@ function resolveSubjectToken(profile, env, cwd) { * @param {object} env - Environment variable map. * @returns {string|null} The resolved client secret, or null if unresolvable. */ -function resolveClientSecret(profile, env, cwd) { +function resolveClientSecret(profile, env, cwd, commandEnv = env) { const providerConfig = (profile.auth && profile.auth.provider_config) || {}; const inputs = (profile.auth && profile.auth.inputs) || {}; @@ -95,13 +95,13 @@ function resolveClientSecret(profile, env, cwd) { // provider_config.client_secret: value_from object if (providerConfig.client_secret && typeof providerConfig.client_secret === 'object' && providerConfig.client_secret.value_from) { - const resolved = resolveValueFrom(providerConfig.client_secret.value_from, env, { cwd }); + const resolved = resolveValueFrom(providerConfig.client_secret.value_from, env, { cwd, commandEnv }); if (resolved) return resolved; } // inputs.client_secret.value_from if (inputs.client_secret && inputs.client_secret.value_from) { - const resolved = resolveValueFrom(inputs.client_secret.value_from, env, { cwd }); + const resolved = resolveValueFrom(inputs.client_secret.value_from, env, { cwd, commandEnv }); if (resolved) return resolved; } @@ -201,6 +201,7 @@ const oidcTokenExchangeProvider = { async resolveSession(request, ctx) { const env = (ctx && ctx.env) || process.env; const cwd = (ctx && ctx.cwd) || process.cwd(); + const commandEnv = (ctx && ctx.commandEnv) || env; const profile = request.profile || {}; const providerConfig = (profile.auth && profile.auth.provider_config) || {}; const auth = profile.auth || {}; @@ -249,7 +250,7 @@ const oidcTokenExchangeProvider = { }); // 1. Resolve subject token - const subjectToken = resolveSubjectToken(profile, env, cwd); + const subjectToken = resolveSubjectToken(profile, env, cwd, commandEnv); if (!subjectToken) { if (required) { throw Object.assign( @@ -261,7 +262,7 @@ const oidcTokenExchangeProvider = { } // 2. Resolve optional client secret - const clientSecret = resolveClientSecret(profile, env, cwd); + const clientSecret = resolveClientSecret(profile, env, cwd, commandEnv); // 3. Build the token exchange request per RFC 8693 const params = new URLSearchParams(); diff --git a/src/identity/session.js b/src/identity/session.js index 10fcaa6..6f49fd3 100644 --- a/src/identity/session.js +++ b/src/identity/session.js @@ -1,170 +1,642 @@ /** - * Credential session utilities. + * Shared identity-provider security primitives. * - * Helpers for navigating, redacting, summarizing, and formatting - * credential sessions produced by identity providers. + * Provider implementations intentionally keep credential acquisition details + * local, while this module owns the invariants that must be identical across + * providers: structural validation, audit redaction, presentation, cleanup, + * and delegation-policy enforcement. */ -/** - * Canonical trust level ordering, from least to most privileged. - */ +import { + chmodSync, + closeSync, + fchmodSync, + mkdtempSync, + openSync, + rmdirSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; + export const TRUST_LEVELS = ['untrusted', 'restricted', 'supervised', 'autonomous']; +const VALID_AUTH_MODES = ['none', 'service', 'delegated', 'on-behalf-of', 'impersonation', 'exchange']; +const VALID_CACHE_MODES = ['none', 'memory', 'state']; +const VALID_REFRESH_MODES = ['never', 'manual', 'auto']; +const VALID_HANDOFF_MODES = ['none', 'downscope', 'transaction-token']; +const VALID_PRESENTATION_KINDS = ['env', 'file', 'stdin', 'none']; +const VALID_FORMATS = ['raw', 'json', 'base64']; +const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; +const SENSITIVE_KEY = /(?:^|_)(?:access_token|refresh_token|subject_token|id_token|secret|password|passphrase|api_key|access_key|private_key|client_assertion|credential)(?:_|$)/i; +const SENSITIVE_PATH_KEY = /^(?:token_file|svid_file|private_key_file|client_secret_file|assertion_file)$/i; +const JWT_LIKE = /\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g; +const STRIPE_KEY_LIKE = /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9_-]+\b/g; +const AWS_ACCESS_KEY_LIKE = /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g; + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function containsControlCharacter(value) { + return [...String(value)].some(character => { + const code = character.codePointAt(0); + return code <= 0x1f || code === 0x7f; + }); +} + +function identityError(code, message, details = {}) { + return Object.assign(new Error(message), { code, ...details }); +} + +function pushTypeError(errors, path, value, type) { + if (value != null && typeof value !== type) { + errors.push(`${path} must be ${type === 'object' ? 'an object' : `a ${type}`}`); + } +} + +function runtimeCapability(ctx, name) { + return ctx?.runtimeCapabilities?.[name] === true || ctx?.runtime_capabilities?.[name] === true; +} + /** - * Navigate a session object using a dot-delimited path. - * - * Returns the value at the path, or undefined if any segment does not resolve. - * Paths are case-sensitive. Array indexing is not supported. - * - * @param {object} session - The session object to navigate. - * @param {string} path - Dot-delimited path (e.g. 'credentials.access_token.value'). - * @returns {*} The resolved value, or undefined. + * Validate provider-independent profile semantics against advertised provider + * and runtime capabilities. No credential source is read by this function. */ +export function validateCommonIdentityProfile(provider, profile, ctx = {}) { + const errors = []; + if (!isObject(profile)) { + return { valid: false, errors: ['identity profile must be an object'] }; + } + + const capabilities = provider?.capabilities || {}; + const auth = profile.auth == null ? {} : profile.auth; + const subject = profile.subject == null ? {} : profile.subject; + const trust = profile.trust == null ? {} : profile.trust; + const presentation = profile.presentation == null ? {} : profile.presentation; + + if (profile.provider != null && profile.provider !== provider?.name) { + errors.push(`profile.provider must be "${provider?.name}"`); + } + + if (!isObject(auth)) errors.push('auth must be an object'); + if (!isObject(subject)) errors.push('subject must be an object'); + if (!isObject(trust)) errors.push('trust must be an object'); + if (!isObject(presentation)) errors.push('presentation must be an object'); + if (errors.length > 0) return { valid: false, errors }; + + const authMode = auth.mode ?? null; + if (authMode != null && !VALID_AUTH_MODES.includes(authMode)) { + errors.push(`auth.mode must be one of: ${VALID_AUTH_MODES.join(', ')}`); + } else if (authMode != null && !(capabilities.auth_modes || []).includes(authMode)) { + errors.push(`auth.mode "${authMode}" is not supported by provider "${provider.name}"`); + } + + if (auth.required != null && typeof auth.required !== 'boolean') { + errors.push('auth.required must be a boolean'); + } + if (auth.scopes != null && !Array.isArray(auth.scopes)) { + errors.push('auth.scopes must be an array'); + } else if (Array.isArray(auth.scopes) && + auth.scopes.some(scope => typeof scope !== 'string' || scope.length === 0)) { + errors.push('auth.scopes must contain only non-empty strings'); + } + pushTypeError(errors, 'auth.audience', auth.audience, 'string'); + pushTypeError(errors, 'auth.resource', auth.resource, 'string'); + if (auth.inputs != null && !isObject(auth.inputs)) errors.push('auth.inputs must be an object'); + if (auth.provider_config != null && !isObject(auth.provider_config)) { + errors.push('auth.provider_config must be an object'); + } + + const cacheMode = auth.cache ?? 'none'; + if (!VALID_CACHE_MODES.includes(cacheMode)) { + errors.push(`auth.cache must be one of: ${VALID_CACHE_MODES.join(', ')}`); + } else if (cacheMode !== 'none') { + const providerSupportsCache = (capabilities.cache_modes || []).includes(cacheMode); + if (!providerSupportsCache || (!ctx?.structural && !runtimeCapability(ctx, 'credentialCache'))) { + errors.push(`auth.cache "${cacheMode}" is unsupported by the active provider/runtime`); + } + } + + const refreshMode = auth.refresh ?? 'never'; + if (!VALID_REFRESH_MODES.includes(refreshMode)) { + errors.push(`auth.refresh must be one of: ${VALID_REFRESH_MODES.join(', ')}`); + } else if (refreshMode !== 'never') { + if (capabilities.refreshable !== true || + (!ctx?.structural && !runtimeCapability(ctx, 'credentialRefresh'))) { + errors.push(`auth.refresh "${refreshMode}" is unsupported by the active provider/runtime`); + } + } + + const trustLevel = trust.level ?? null; + if (trustLevel != null && !TRUST_LEVELS.includes(trustLevel)) { + errors.push(`trust.level must be one of: ${TRUST_LEVELS.join(', ')}`); + } else if (trustLevel != null && !(capabilities.trust_levels || []).includes(trustLevel)) { + errors.push(`trust.level "${trustLevel}" is not supported by provider "${provider.name}"`); + } + + const delegationMode = subject.delegation_mode ?? 'none'; + if (!['none', 'on-behalf-of', 'impersonation'].includes(delegationMode)) { + errors.push('subject.delegation_mode must be one of: none, on-behalf-of, impersonation'); + } + const delegationPolicy = auth.delegation_policy ?? null; + const delegationPolicyDeclared = isObject(delegationPolicy) && Object.values(delegationPolicy) + .some(value => value != null && (!Array.isArray(value) || value.length > 0)); + const delegationRequested = delegationMode !== 'none' || delegationPolicyDeclared || + ['delegated', 'on-behalf-of', 'impersonation', 'exchange'].includes(authMode); + if (delegationRequested && capabilities.delegation !== true) { + errors.push(`delegation is not supported by provider "${provider.name}"`); + } + if (delegationPolicy != null) { + if (!isObject(delegationPolicy)) { + errors.push('auth.delegation_policy must be an object'); + } else { + if (delegationPolicy.max_depth != null && + (!Number.isInteger(delegationPolicy.max_depth) || delegationPolicy.max_depth < 1)) { + errors.push('auth.delegation_policy.max_depth must be an integer greater than zero'); + } + if (delegationPolicy.allowed_delegators != null && + (!Array.isArray(delegationPolicy.allowed_delegators) || + delegationPolicy.allowed_delegators.some(value => typeof value !== 'string' || value.length === 0))) { + errors.push('auth.delegation_policy.allowed_delegators must be an array of non-empty strings'); + } + if (delegationPolicy.require_grant_per_hop != null && + typeof delegationPolicy.require_grant_per_hop !== 'boolean') { + errors.push('auth.delegation_policy.require_grant_per_hop must be a boolean'); + } + } + } + + const handoffMode = presentation.handoff ?? 'none'; + if (!VALID_HANDOFF_MODES.includes(handoffMode)) { + errors.push(`presentation.handoff must be one of: ${VALID_HANDOFF_MODES.join(', ')}`); + } else if (!(capabilities.handoff_modes || []).includes(handoffMode)) { + errors.push(`presentation.handoff "${handoffMode}" is not supported by provider "${provider.name}"`); + } else if (handoffMode !== 'none' && + !ctx?.structural && + !runtimeCapability(ctx, 'credentialHandoff')) { + errors.push(`presentation.handoff "${handoffMode}" is unsupported by the active runtime`); + } + + if (presentation.default_redaction != null && typeof presentation.default_redaction !== 'boolean') { + errors.push('presentation.default_redaction must be a boolean'); + } + if (presentation.cleanup != null && !['always', 'on-success', 'on-failure', 'never'].includes(presentation.cleanup)) { + errors.push('presentation.cleanup must be one of: always, on-success, on-failure, never'); + } + + if (presentation.bindings != null && !Array.isArray(presentation.bindings)) { + errors.push('presentation.bindings must be an array'); + } else { + let stdinBindings = 0; + for (const [index, binding] of (presentation.bindings || []).entries()) { + const path = `presentation.bindings[${index}]`; + if (!isObject(binding)) { + errors.push(`${path} must be an object`); + continue; + } + if (typeof binding.source !== 'string' || binding.source.length === 0) { + errors.push(`${path}.source must be a non-empty string`); + } else if (binding.source === 'provider_assertions' || + binding.source.startsWith('provider_assertions.') || + binding.source === 'delegation_chain' || + binding.source.startsWith('delegation_chain.')) { + errors.push(`${path}.source cannot reference audit-only session data`); + } + if (!isObject(binding.target)) { + errors.push(`${path}.target must be an object`); + continue; + } + const kind = binding.target.kind; + if (!VALID_PRESENTATION_KINDS.includes(kind)) { + errors.push(`${path}.target.kind must be one of: ${VALID_PRESENTATION_KINDS.join(', ')}`); + } else if (kind !== 'none' && !(capabilities.presentation_kinds || []).includes(kind)) { + errors.push(`${path}.target.kind "${kind}" is not supported by provider "${provider.name}"`); + } + if (kind === 'env' && !ENV_NAME.test(binding.target.name || '')) { + errors.push(`${path}.target.name must be a valid environment variable name`); + } + if (kind === 'file' && binding.target.name != null && !isSafeCredentialFilename(binding.target.name)) { + errors.push(`${path}.target.name must be a safe filename without path components`); + } + if (kind === 'file' && binding.target.expose_as != null && !ENV_NAME.test(binding.target.expose_as)) { + errors.push(`${path}.target.expose_as must be a valid environment variable name`); + } + if (kind === 'stdin') stdinBindings += 1; + if (binding.required != null && typeof binding.required !== 'boolean') { + errors.push(`${path}.required must be a boolean`); + } + if (binding.redact != null && typeof binding.redact !== 'boolean') { + errors.push(`${path}.redact must be a boolean`); + } + if (binding.format != null && !VALID_FORMATS.includes(binding.format)) { + errors.push(`${path}.format must be one of: ${VALID_FORMATS.join(', ')}`); + } + } + if (stdinBindings > 1) errors.push('presentation.bindings may contain at most one stdin target'); + } + + return errors.length === 0 ? { valid: true } : { valid: false, errors }; +} + +export function combineValidationResults(...results) { + const errors = []; + for (const result of results) { + if (result?.valid === false) { + for (const error of result.errors || [result.error || 'profile validation failed']) { + errors.push(typeof error === 'string' ? error : (error?.message || 'profile validation failed')); + } + } + } + return errors.length === 0 ? { valid: true } : { valid: false, errors }; +} + +export function assertValidIdentityProfile(provider, profile, validation) { + if (validation?.valid !== false) return; + throw identityError( + 'identity_profile_invalid', + `Identity profile for provider "${provider.name}" is invalid: ${(validation.errors || []).join('; ')}`, + { validation } + ); +} + +/** Return true only for local loopback host names and addresses. */ +export function isLoopbackHostname(hostname) { + const normalized = String(hostname || '').replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase(); + if (normalized === 'localhost' || normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') return true; + const octets = normalized.split('.'); + return octets.length === 4 && octets.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255) && Number(octets[0]) === 127; +} + +/** Validate a user-configurable network endpoint without permitting fail-open HTTP. */ +export function validateSecureEndpoint(value, path = 'endpoint', { allowLoopbackHttp = true } = {}) { + if (typeof value !== 'string' || value.trim() === '') { + return [`${path} must be a non-empty URL`]; + } + try { + const parsed = new URL(value); + if (parsed.username || parsed.password) return [`${path} must not contain URL credentials`]; + if (parsed.protocol === 'https:') return []; + if (parsed.protocol === 'http:' && allowLoopbackHttp && isLoopbackHostname(parsed.hostname)) return []; + return [`${path} must use HTTPS; HTTP is allowed only for loopback endpoints`]; + } catch { + return [`${path} must be a valid URL`]; + } +} + export function resolveSourcePath(session, path) { if (!session || typeof path !== 'string' || path === '') return undefined; - const segments = path.split('.'); let current = session; - for (const segment of segments) { - if (current === null || current === undefined || typeof current !== 'object') { - return undefined; - } + if (current === null || current === undefined || typeof current !== 'object') return undefined; current = current[segment]; } - return current; } -/** - * Return a deep copy of the session with all leaf values under `credentials` - * replaced with '[REDACTED]'. Structure is preserved. - * - * @param {object} session - The credential session. - * @returns {object} Redacted copy. - */ -export function redactSession(session) { - const copy = structuredClone(session); - if (copy.credentials && typeof copy.credentials === 'object') { - redactObject(copy.credentials); - } - return copy; +function sanitizeNonSecretString(value) { + return String(value) + .replace(JWT_LIKE, '[REDACTED]') + .replace(STRIPE_KEY_LIKE, '[REDACTED]') + .replace(AWS_ACCESS_KEY_LIKE, '[REDACTED]'); } -/** - * Recursively replace all leaf values in an object with '[REDACTED]'. - * @param {object} obj - */ -function redactObject(obj) { - for (const key of Object.keys(obj)) { - const val = obj[key]; - if (val !== null && typeof val === 'object' && !Array.isArray(val)) { - redactObject(val); - } else if (Array.isArray(val)) { - obj[key] = val.map(item => { - if (item !== null && typeof item === 'object') { - const clone = structuredClone(item); - redactObject(clone); - return clone; - } - return '[REDACTED]'; - }); - } else { - obj[key] = '[REDACTED]'; +function redactRecursively(value, { force = false } = {}) { + if (force) { + if (Array.isArray(value)) return value.map(item => redactRecursively(item, { force: true })); + if (isObject(value)) { + const result = {}; + for (const [childKey, childValue] of Object.entries(value)) { + result[childKey] = redactRecursively(childValue, { force: true, key: childKey }); + } + return result; + } + return '[REDACTED]'; + } + if (Array.isArray(value)) return value.map(item => redactRecursively(item)); + if (!isObject(value)) return typeof value === 'string' ? sanitizeNonSecretString(value) : value; + + const result = {}; + for (const [childKey, childValue] of Object.entries(value)) { + const childForce = childKey === 'credentials' || childKey === 'derived_credentials' || + childKey === 'child_credentials' || SENSITIVE_KEY.test(childKey) || SENSITIVE_PATH_KEY.test(childKey); + if (!childForce && typeof childValue === 'string' && /(?:endpoint|api_base|authority|url|uri)$/i.test(childKey)) { + try { + const parsed = new URL(childValue); + parsed.username = ''; + parsed.password = ''; + parsed.search = ''; + parsed.hash = ''; + result[childKey] = parsed.toString(); + continue; + } catch { + // Preserve non-URL identifiers after credential-pattern scrubbing. + } } + result[childKey] = redactRecursively(childValue, { force: childForce }); } + return result; +} + +/** Return a deep audit-safe representation of a credential session. */ +export function redactSession(session) { + return redactRecursively(structuredClone(session || {})); +} + +export function describeCredentialSession(session) { + const described = redactSession(session); + described.credential_summary = buildCredentialSummary(session || {}); + return described; } -/** - * Build a summary of credential types and earliest expiration from a session. - * - * @param {object} session - The credential session. - * @returns {{ credential_types: string[], expires_at: string|null }} - */ export function buildCredentialSummary(session) { const types = []; let earliestExpiry = null; - - if (session.credentials && typeof session.credentials === 'object') { - for (const [key, cred] of Object.entries(session.credentials)) { - types.push(cred.kind || key); - if (cred.expires_at) { - if (earliestExpiry === null || new Date(cred.expires_at) < new Date(earliestExpiry)) { - earliestExpiry = cred.expires_at; + if (isObject(session?.credentials)) { + for (const [key, credential] of Object.entries(session.credentials)) { + types.push(credential?.kind || key); + if (credential?.expires_at) { + if (earliestExpiry === null || new Date(credential.expires_at) < new Date(earliestExpiry)) { + earliestExpiry = credential.expires_at; } } } } - return { credential_types: types, expires_at: earliestExpiry }; } -/** - * Check whether any credential in the session has expired. - * - * @param {object} session - The credential session. - * @returns {boolean} True if at least one credential has an expires_at in the past. - */ export function isSessionExpired(session) { - if (!session.credentials || typeof session.credentials !== 'object') return false; - + if (!isObject(session?.credentials)) return false; const now = Date.now(); - for (const cred of Object.values(session.credentials)) { - if (cred.expires_at && new Date(cred.expires_at).getTime() <= now) { - return true; - } - } - - return false; + return Object.values(session.credentials).some(credential => + credential?.expires_at && new Date(credential.expires_at).getTime() <= now + ); } -/** - * Format a value according to a materialization binding format. - * - * @param {*} value - The value to format. - * @param {string} format - One of 'raw', 'json', 'base64'. - * @returns {string} The formatted value. - */ export function formatMaterializationValue(value, format) { - switch (format) { + const effectiveFormat = format || ((value !== null && typeof value === 'object') ? 'json' : 'raw'); + switch (effectiveFormat) { case 'json': return JSON.stringify(value); case 'base64': return Buffer.from(String(value)).toString('base64'); case 'raw': - default: return String(value); + default: + throw identityError('presentation_format_unsupported', 'Unsupported credential presentation format'); } } -/** - * Validate that a trust level string is one of the canonical values. - * - * @param {string} level - The trust level to validate. - * @returns {{ valid: boolean, error?: string }} - */ -export function validateTrustLevel(level) { - if (TRUST_LEVELS.includes(level)) { - return { valid: true }; +function isSafeCredentialFilename(name) { + return typeof name === 'string' && name.length > 0 && name.length <= 255 && + name !== '.' && name !== '..' && basename(name) === name && + !name.includes('/') && !name.includes('\\') && !containsControlCharacter(name); +} + +function secureWriteCredentialFile(directory, name, contents) { + const path = join(directory, name); + let descriptor = null; + let failure = null; + try { + descriptor = openSync(path, 'wx', 0o600); + fchmodSync(descriptor, 0o600); + writeFileSync(descriptor, contents, { encoding: 'utf8' }); + } catch (error) { + failure = error; + } finally { + if (descriptor !== null) { + try { + closeSync(descriptor); + } catch (error) { + failure ||= error; + } + } } - return { valid: false, error: `Invalid trust level "${level}". Must be one of: ${TRUST_LEVELS.join(', ')}` }; + if (failure) { + try { + unlinkSync(path); + } catch { + // The file may never have been created. The private parent directory is + // still removed by the caller's failure cleanup. + } + throw failure; + } + return path; } /** - * Compare two trust levels using canonical ordering. - * - * @param {string} a - First trust level. - * @param {string} b - Second trust level. - * @returns {number} -1 if a < b, 0 if a === b, 1 if a > b. + * Materialize explicit credential bindings using private temp directories and + * strict target validation. This function never reads outside the session. */ +export function materializeCredentialBindings(session, presentation = {}, { + allowedTargetKinds = ['env', 'file', 'stdin'], + defaultBindings = [], + tempPrefix = 'agentcli-credential', +} = {}) { + const bindings = Array.isArray(presentation?.bindings) && presentation.bindings.length > 0 + ? presentation.bindings + : defaultBindings; + const envVars = {}; + const tempFiles = []; + const tempDirectories = []; + let stdin = null; + + try { + for (const [index, binding] of bindings.entries()) { + if (!isObject(binding) || typeof binding.source !== 'string' || !isObject(binding.target)) { + throw identityError('presentation_binding_invalid', `Credential presentation binding ${index} is invalid`); + } + const source = binding.source; + if (source === 'provider_assertions' || source.startsWith('provider_assertions.') || + source === 'delegation_chain' || source.startsWith('delegation_chain.')) { + throw identityError('presentation_source_forbidden', 'Credential presentation cannot use audit-only session data'); + } + + const rawValue = resolveSourcePath(session, source); + if (rawValue === undefined || rawValue === null) { + if (binding.required === true) { + throw identityError('presentation_binding_missing', `Required credential presentation binding ${index} is unavailable`); + } + continue; + } + + const kind = binding.target.kind; + if (kind === 'none') continue; + if (!allowedTargetKinds.includes(kind)) { + throw identityError('presentation_target_unsupported', `Credential presentation target "${kind || 'unspecified'}" is unsupported`); + } + const formatted = formatMaterializationValue(rawValue, binding.format); + + if (kind === 'env') { + if (!ENV_NAME.test(binding.target.name || '')) { + throw identityError('presentation_target_invalid', 'Credential environment target name is invalid'); + } + envVars[binding.target.name] = formatted; + } else if (kind === 'stdin') { + if (stdin !== null) { + throw identityError('presentation_stdin_conflict', 'Only one credential binding may target stdin'); + } + stdin = formatted; + } else if (kind === 'file') { + const requestedName = binding.target.name ?? `credential-${index}`; + if (!isSafeCredentialFilename(requestedName)) { + throw identityError('presentation_target_invalid', 'Credential file target name is invalid'); + } + const prefix = String(binding.target.prefix || tempPrefix).replace(/[^A-Za-z0-9._-]/g, '-').slice(0, 80) || tempPrefix; + const directory = mkdtempSync(join(tmpdir(), `${prefix}-`)); + chmodSync(directory, 0o700); + tempDirectories.push(directory); + const path = secureWriteCredentialFile(directory, requestedName, formatted); + tempFiles.push({ path, directory, binding_source: source, name: requestedName }); + if (binding.target.expose_as != null) { + if (!ENV_NAME.test(binding.target.expose_as)) { + throw identityError('presentation_target_invalid', 'Credential file exposure environment name is invalid'); + } + envVars[binding.target.expose_as] = path; + } + } + } + } catch (error) { + cleanupMaterializedCredentials({ temp_files: tempFiles, temp_directories: tempDirectories }); + throw error; + } + + return { + materialized: bindings.length > 0, + cleanup_required: tempFiles.length > 0, + env_vars: envVars, + temp_files: tempFiles, + temp_directories: tempDirectories, + stdin, + }; +} + +/** Remove provider-created files and directories. Repeated calls are safe. */ +export function cleanupMaterializedCredentials(materialization) { + const warnings = []; + const directories = new Set(materialization?.temp_directories || []); + for (const entry of materialization?.temp_files || []) { + const path = typeof entry === 'string' ? entry : entry?.path; + if (entry?.directory) directories.add(entry.directory); + if (!path) continue; + try { + unlinkSync(path); + } catch (error) { + if (error?.code !== 'ENOENT') warnings.push('Failed to delete a temporary credential file'); + } + } + for (const directory of [...directories].reverse()) { + try { + rmdirSync(directory); + } catch (error) { + if (error?.code !== 'ENOENT') warnings.push('Failed to delete a temporary credential directory'); + } + } + return { cleaned: true, warnings }; +} + +/** Enforce provider-independent delegation constraints on a resolved chain. */ +export function enforceDelegationPolicy(session, policy = {}) { + const chain = Array.isArray(session?.delegation_chain) ? session.delegation_chain : []; + const maxDepth = Number.isInteger(policy?.max_depth) ? policy.max_depth : null; + const allowed = Array.isArray(policy?.allowed_delegators) ? new Set(policy.allowed_delegators) : null; + const requireGrant = policy?.require_grant_per_hop !== false; + const failures = []; + const seen = new Set(); + + if (maxDepth !== null && chain.length > maxDepth) failures.push('delegation chain exceeds max_depth'); + for (const [index, hop] of chain.entries()) { + const principal = hop?.principal; + const transition = `${principal || '(unknown)'}\u0000${hop?.grant || '(none)'}`; + if (seen.has(transition)) failures.push(`delegation chain contains a repeated hop at index ${index}`); + seen.add(transition); + if (allowed && allowed.size > 0 && (!principal || !allowed.has(principal))) { + failures.push(`delegator at index ${index} is not allowed`); + } + if (requireGrant && (typeof hop?.grant !== 'string' || hop.grant.length === 0 || hop.validated === false)) { + failures.push(`delegation hop ${index} lacks a validated grant`); + } + } + + return { + valid: failures.length === 0, + depth: chain.length, + acyclic: !failures.some(failure => failure.includes('repeated hop')), + all_grants_present: !failures.some(failure => failure.includes('grant')), + errors: failures, + }; +} + +function collectKnownSecrets(value, env, secrets, parentKey = '') { + if (Array.isArray(value)) { + for (const item of value) collectKnownSecrets(item, env, secrets, parentKey); + return; + } + if (!isObject(value)) return; + for (const [key, child] of Object.entries(value)) { + if (key === 'value_from' && isObject(child)) { + if (typeof child.env === 'string' && typeof env?.[child.env] === 'string') secrets.add(env[child.env]); + if (typeof child.env === 'string') secrets.add(child.env); + if (typeof child.file === 'string') secrets.add(child.file); + if (typeof child.command === 'string') secrets.add(child.command); + if (typeof child.literal === 'string') secrets.add(child.literal); + continue; + } + if ((key.endsWith('_env') || key === 'env') && typeof child === 'string') { + secrets.add(child); + if (typeof env?.[child] === 'string') secrets.add(env[child]); + } + if ((key.endsWith('_file') || key === 'file' || key.endsWith('_command') || key === 'command') && + typeof child === 'string') { + secrets.add(child); + } + if (/(?:endpoint|api_base|authority|url|uri)$/i.test(key) && typeof child === 'string') { + try { + const parsed = new URL(child); + if (parsed.username || parsed.password || parsed.search || parsed.hash) secrets.add(child); + } catch { + secrets.add(child); + } + } + if ((SENSITIVE_KEY.test(key) && !/(?:strategy|id|file|env|command|endpoint|uri|url)$/i.test(key)) && typeof child === 'string') { + secrets.add(child); + } + collectKnownSecrets(child, env, secrets, key || parentKey); + } +} + +/** Return a runtime error with credential values removed from its message. */ +export function sanitizeProviderError(error, { profile, env } = {}) { + const secrets = new Set(); + collectKnownSecrets(profile, env, secrets); + let message = String(error?.message || 'Identity provider operation failed'); + for (const secret of secrets) { + if (secret && secret.length > 2) message = message.split(secret).join('[REDACTED]'); + } + message = message + .replace(JWT_LIKE, '[REDACTED]') + .replace(STRIPE_KEY_LIKE, '[REDACTED]') + .replace(AWS_ACCESS_KEY_LIKE, '[REDACTED]') + .replace(/((?:token|secret|password|assertion|api[_-]?key)\s*[=:]\s*)[^\s,;]+/gi, '$1[REDACTED]') + .replace(/(returned HTTP \d+)(?::[\s\S]*)/i, '$1') + .replace(/(failed \(HTTP \d+\))(?::[\s\S]*)/i, '$1'); + return identityError(error?.code || 'identity_provider_error', message, { + transient: error?.transient === true, + }); +} + +export function validateTrustLevel(level) { + return TRUST_LEVELS.includes(level) + ? { valid: true } + : { valid: false, error: `Invalid trust level "${level}". Must be one of: ${TRUST_LEVELS.join(', ')}` }; +} + export function compareTrustLevels(a, b) { const indexA = TRUST_LEVELS.indexOf(a); const indexB = TRUST_LEVELS.indexOf(b); - if (indexA === -1) throw new Error(`Unknown trust level: "${a}"`); if (indexB === -1) throw new Error(`Unknown trust level: "${b}"`); - - if (indexA < indexB) return -1; - if (indexA > indexB) return 1; - return 0; + return Math.sign(indexA - indexB); } diff --git a/src/identity/spiffe-jwt-svid.js b/src/identity/spiffe-jwt-svid.js index 0304375..d347f6d 100644 --- a/src/identity/spiffe-jwt-svid.js +++ b/src/identity/spiffe-jwt-svid.js @@ -1,333 +1,376 @@ /** * SPIFFE JWT-SVID identity provider. * - * Acquires JWT-SVIDs (SPIFFE Verifiable Identity Documents) from the - * SPIFFE Workload API or from file-mounted projected volumes. SPIFFE - * (Secure Production Identity Framework for Everyone) provides - * cryptographically verifiable workload identities. Supports reading - * JWT-SVIDs from file paths (common in Kubernetes with SPIRE agent - * projected volumes) or via the SPIFFE Workload API socket. Uses the - * global fetch() API available in Node >= 22 (no external dependencies). + * This provider deliberately supports only file-mounted JWT-SVIDs. The SPIFFE + * Workload API is a gRPC Unix-socket protocol and must be integrated through a + * conforming client, not an ad-hoc HTTP endpoint. File-mounted tokens are + * accepted only after audience, lifetime, subject, and signature verification. */ -import { readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { randomBytes } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { createPublicKey, verify as verifySignature } from 'node:crypto'; import { registerProvider } from './index.js'; -import { resolveSourcePath, formatMaterializationValue, buildCredentialSummary } from './session.js'; +import { + buildCredentialSummary, + cleanupMaterializedCredentials, + materializeCredentialBindings, + redactSession, +} from './session.js'; + +const SUPPORTED_ALGORITHMS = new Map([ + ['RS256', 'RSA-SHA256'], + ['RS384', 'RSA-SHA384'], + ['RS512', 'RSA-SHA512'], +]); +const sessionContexts = new WeakMap(); + +function providerError(code, message) { + return Object.assign(new Error(message), { code }); +} -/** - * Generate a unique temporary file path for credential materialization. - * - * @param {string} prefix - Filename prefix. - * @returns {string} Absolute path to a temp file. - */ -function tempFilePath(prefix) { - const rand = randomBytes(12).toString('hex'); - return join(tmpdir(), `${prefix}-${Date.now()}-${rand}`); +function decodeJsonSegment(segment, label) { + if (typeof segment !== 'string' || segment.length === 0 || !/^[A-Za-z0-9_-]+$/.test(segment)) { + throw providerError('spiffe_svid_invalid', `JWT-SVID ${label} is malformed`); + } + try { + const decoded = Buffer.from(segment, 'base64url').toString('utf8'); + const value = JSON.parse(decoded); + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('not an object'); + return value; + } catch { + throw providerError('spiffe_svid_invalid', `JWT-SVID ${label} is not valid JSON`); + } } -/** - * Decode a base64url-encoded string to a UTF-8 string. - * - * Handles the base64url alphabet (- and _ instead of + and /) and - * missing padding characters. - * - * @param {string} str - Base64url-encoded string. - * @returns {string} Decoded UTF-8 string. - */ -function base64urlDecode(str) { - // Convert base64url to standard base64 - let base64 = str.replace(/-/g, '+').replace(/_/g, '/'); - // Add padding if necessary - const padding = base64.length % 4; - if (padding === 2) { - base64 += '=='; - } else if (padding === 3) { - base64 += '='; +function parseJwtSvid(token) { + if (typeof token !== 'string') throw providerError('spiffe_svid_invalid', 'JWT-SVID must be a string'); + const segments = token.split('.'); + if (segments.length !== 3 || segments.some(segment => segment.length === 0)) { + throw providerError('spiffe_svid_invalid', 'JWT-SVID must contain three non-empty segments'); + } + const [encodedHeader, encodedClaims, encodedSignature] = segments; + if (!/^[A-Za-z0-9_-]+$/.test(encodedSignature)) { + throw providerError('spiffe_svid_invalid', 'JWT-SVID signature is malformed'); } - return Buffer.from(base64, 'base64').toString('utf8'); + const signature = Buffer.from(encodedSignature, 'base64url'); + if (signature.length === 0) throw providerError('spiffe_svid_invalid', 'JWT-SVID signature is empty'); + return { + header: decodeJsonSegment(encodedHeader, 'header'), + claims: decodeJsonSegment(encodedClaims, 'claims'), + signature, + signingInput: `${encodedHeader}.${encodedClaims}`, + }; } -/** - * Parse JWT claims from a JWT token string without signature verification. - * - * Extracts and decodes the payload segment of a JWT token. Does NOT - * verify the signature -- that is the responsibility of the SPIFFE - * trust bundle verifier at the consuming service. - * - * @param {string} jwt - The JWT token string. - * @returns {{ header: object, payload: object }} Decoded JWT header and payload. - * @throws {Error} If the JWT format is invalid. - */ -function parseJwtClaims(jwt) { - if (typeof jwt !== 'string' || jwt.length === 0) { - throw new Error('JWT token is empty or not a string'); +function parseTrustDocument(value, label) { + if (value && typeof value === 'object') return value; + if (typeof value !== 'string' || value.trim() === '') { + throw providerError('spiffe_trust_invalid', `${label} is empty`); + } + try { + return JSON.parse(value); + } catch { + throw providerError('spiffe_trust_invalid', `${label} is not valid JSON`); } +} + +function assertStrongRsaKey(key) { + if (key.asymmetricKeyType !== 'rsa' || (key.asymmetricKeyDetails?.modulusLength || 0) < 2048) { + throw providerError('spiffe_trust_invalid', 'JWT-SVID trust key must be an RSA public key of at least 2048 bits'); + } + return key; +} + +function selectJwk(document, header) { + const keys = Array.isArray(document?.keys) ? document.keys : [document]; + const candidates = keys.filter(key => key && typeof key === 'object' && key.kty === 'RSA'); + const key = header.kid + ? candidates.find(candidate => candidate.kid === header.kid) + : candidates.length === 1 ? candidates[0] : null; + if (!key) { + throw providerError( + 'spiffe_trust_invalid', + header.kid ? 'No trusted JWT-SVID key matches the token key id' : 'JWT-SVID trust set is ambiguous without a key id' + ); + } + if (key.use != null && key.use !== 'sig') { + throw providerError('spiffe_trust_invalid', 'Selected JWT-SVID key is not authorized for signatures'); + } + if (key.alg != null && key.alg !== header.alg) { + throw providerError('spiffe_trust_invalid', 'Selected JWT-SVID key does not permit the token algorithm'); + } + if (key.d != null || (Array.isArray(key.key_ops) && !key.key_ops.includes('verify'))) { + throw providerError('spiffe_trust_invalid', 'Selected JWT-SVID key must be a public verification key'); + } + return assertStrongRsaKey(createPublicKey({ key, format: 'jwk' })); +} - const parts = jwt.split('.'); - if (parts.length !== 3) { - throw new Error(`Invalid JWT format: expected 3 dot-separated parts, got ${parts.length}`); +function validateJwksStructure(value) { + const document = parseTrustDocument(value, 'JWT-SVID JWKS'); + const keys = Array.isArray(document?.keys) ? document.keys : [document]; + if (keys.length === 0) throw providerError('spiffe_trust_invalid', 'JWT-SVID JWKS contains no keys'); + for (const key of keys) { + if (!key || typeof key !== 'object' || key.kty !== 'RSA') { + throw providerError('spiffe_trust_invalid', 'JWT-SVID JWKS supports RSA signing keys only'); + } + if (key.d != null || (Array.isArray(key.key_ops) && !key.key_ops.includes('verify'))) { + throw providerError('spiffe_trust_invalid', 'JWT-SVID JWKS must contain public verification keys only'); + } + assertStrongRsaKey(createPublicKey({ key, format: 'jwk' })); + } +} + +function resolveTrustKey(config, header) { + if (typeof config.public_key_pem === 'string' && config.public_key_pem.trim()) { + if (/BEGIN (?:RSA )?PRIVATE KEY/.test(config.public_key_pem)) { + throw providerError('spiffe_trust_invalid', 'JWT-SVID trust material must not contain a private key'); + } + return { key: assertStrongRsaKey(createPublicKey(config.public_key_pem)), source: 'public_key_pem' }; + } + if (typeof config.public_key_file === 'string' && config.public_key_file.trim()) { + const pem = readFileSync(config.public_key_file, 'utf8'); + if (/BEGIN (?:RSA )?PRIVATE KEY/.test(pem)) { + throw providerError('spiffe_trust_invalid', 'JWT-SVID trust material must not contain a private key'); + } + return { + key: assertStrongRsaKey(createPublicKey(pem)), + source: 'public_key_file', + }; + } + if (config.jwks != null) { + return { key: selectJwk(parseTrustDocument(config.jwks, 'JWT-SVID JWKS'), header), source: 'jwks' }; } + if (typeof config.jwks_file === 'string' && config.jwks_file.trim()) { + const document = parseTrustDocument(readFileSync(config.jwks_file, 'utf8'), 'JWT-SVID JWKS file'); + return { key: selectJwk(document, header), source: 'jwks_file' }; + } + throw providerError('spiffe_trust_required', 'JWT-SVID cryptographic trust material is required'); +} - let header; +function validateAudience(claim, expected) { + const audiences = Array.isArray(claim) ? claim : [claim]; + return audiences.some(value => typeof value === 'string' && value === expected); +} + +function validateSpiffeId(value) { + if (typeof value !== 'string' || !value.startsWith('spiffe://')) return false; try { - header = JSON.parse(base64urlDecode(parts[0])); - } catch (err) { - throw new Error(`Failed to decode JWT header: ${err.message}`, { cause: err }); + const parsed = new URL(value); + return parsed.protocol === 'spiffe:' && parsed.hostname.length > 0 && + parsed.username === '' && parsed.password === '' && parsed.search === '' && parsed.hash === ''; + } catch { + return false; + } +} + +function verifyJwtSvid(token, config, expectedAudience, { clockToleranceS = 30 } = {}) { + const parsed = parseJwtSvid(token); + const algorithm = SUPPORTED_ALGORITHMS.get(parsed.header.alg); + if (!algorithm) { + throw providerError('spiffe_algorithm_unsupported', 'JWT-SVID uses an unsupported signature algorithm'); + } + if (parsed.header.typ != null && parsed.header.typ !== 'JWT') { + throw providerError('spiffe_svid_invalid', 'JWT-SVID typ header must be JWT when present'); + } + + const now = Math.floor(Date.now() / 1000); + if (!Number.isFinite(parsed.claims.exp) || parsed.claims.exp <= now - clockToleranceS) { + throw providerError('spiffe_svid_expired', 'JWT-SVID is expired or has no valid expiration'); + } + if (parsed.claims.nbf != null && + (!Number.isFinite(parsed.claims.nbf) || parsed.claims.nbf > now + clockToleranceS)) { + throw providerError('spiffe_svid_not_active', 'JWT-SVID is not active yet'); + } + if (parsed.claims.iat != null && + (!Number.isFinite(parsed.claims.iat) || parsed.claims.iat > now + clockToleranceS)) { + throw providerError('spiffe_svid_invalid', 'JWT-SVID issuance time is in the future'); + } + if (!validateAudience(parsed.claims.aud, expectedAudience)) { + throw providerError('spiffe_audience_mismatch', 'JWT-SVID audience does not match the requested audience'); + } + if (!validateSpiffeId(parsed.claims.sub)) { + throw providerError('spiffe_svid_invalid', 'JWT-SVID subject is not a valid SPIFFE ID'); + } + if (!validateSpiffeId(parsed.claims.iss)) { + throw providerError('spiffe_svid_invalid', 'JWT-SVID issuer is not a valid SPIFFE trust-domain URI'); + } + if (config.expected_issuer != null && parsed.claims.iss !== config.expected_issuer) { + throw providerError('spiffe_issuer_mismatch', 'JWT-SVID issuer does not match the configured issuer'); } - let payload; + let trust; + try { + trust = resolveTrustKey(config, parsed.header); + } catch (error) { + if (error?.code) throw error; + throw providerError('spiffe_trust_invalid', 'JWT-SVID trust material could not be loaded'); + } + let verified; try { - payload = JSON.parse(base64urlDecode(parts[1])); - } catch (err) { - throw new Error(`Failed to decode JWT payload: ${err.message}`, { cause: err }); + verified = verifySignature( + algorithm, + Buffer.from(parsed.signingInput, 'ascii'), + trust.key, + parsed.signature + ); + } catch { + throw providerError('spiffe_signature_invalid', 'JWT-SVID signature verification failed'); } + if (!verified) throw providerError('spiffe_signature_invalid', 'JWT-SVID signature verification failed'); + return { ...parsed, trustSource: trust.source }; +} - return { header, payload }; +function emptySession(profile) { + const trustLevel = profile?.trust?.level || 'supervised'; + return { + provider: 'spiffe-jwt-svid', + subject: { + principal: profile?.subject?.principal || null, + issuer: profile?.subject?.issuer || null, + run_as: profile?.subject?.run_as || null, + }, + instance: null, + trust: { declared_level: trustLevel, effective_level: trustLevel }, + delegation_chain: [], + delegation_validation: { + valid: true, + depth: 0, + acyclic: true, + all_grants_present: true, + }, + credentials: {}, + provider_assertions: { acquisition_method: 'file', signature_verified: false }, + refresh: { supported: true, expires_at: null }, + handoff: { mode: 'none', prepared: false }, + }; } const spiffeJwtSvidProvider = { name: 'spiffe-jwt-svid', capabilities: { - auth_modes: ['service'], - credential_types: ['access_token'], - presentation_kinds: ['env', 'file'], + auth_modes: ['service', 'delegated'], + credential_types: ['jwt_svid'], + presentation_kinds: ['env', 'file', 'stdin'], handoff_modes: ['none'], refreshable: true, - delegation: false, - trust_levels: ['untrusted', 'restricted', 'supervised', 'autonomous'], + delegation: true, + trust_levels: ['restricted', 'supervised', 'autonomous'], approval_mechanisms: [], }, - /** - * Validate a profile for the spiffe-jwt-svid provider. - * - * Checks that the profile declares an audience. The workload API socket - * is optional and defaults to the SPIFFE_ENDPOINT_SOCKET environment - * variable. The svid_file path is an alternative acquisition method. - * - * @param {object} profile - The identity profile. - * @param {object} [_ctx] - Resolution context. - * @returns {{ valid: boolean, errors?: string[] }} - */ - validateProfile(profile, _ctx) { + validateProfile(profile) { const errors = []; - const providerConfig = (profile.auth && profile.auth.provider_config) || {}; - const auth = profile.auth || {}; + const auth = profile?.auth || {}; + const config = auth.provider_config || {}; + const required = auth.required !== false; + const audience = auth.audience || config.audience; - const audience = providerConfig.audience || auth.audience; - if (typeof audience !== 'string' || audience.length === 0) { - errors.push( - 'Audience is required: provide auth.provider_config.audience or auth.audience ' + - 'as a non-empty string (e.g. "spiffe://example.org/my-service")' - ); + if (config.workload_api_socket != null) { + errors.push('auth.provider_config.workload_api_socket is unsupported; mount a JWT-SVID file instead'); } - - if (providerConfig.workload_api_socket !== undefined && providerConfig.workload_api_socket !== null) { - if (typeof providerConfig.workload_api_socket !== 'string' || providerConfig.workload_api_socket.length === 0) { - errors.push( - 'auth.provider_config.workload_api_socket, when specified, must be a non-empty string ' + - '(path to the SPIFFE Workload API Unix domain socket)' - ); - } + if (config.jwks_uri != null) { + errors.push('auth.provider_config.jwks_uri is unsupported; provide local trust material instead'); } - - if (providerConfig.svid_file !== undefined && providerConfig.svid_file !== null) { - if (typeof providerConfig.svid_file !== 'string' || providerConfig.svid_file.length === 0) { - errors.push( - 'auth.provider_config.svid_file, when specified, must be a non-empty string ' + - '(path to a file containing the JWT-SVID)' - ); - } + if (required && (typeof config.svid_file !== 'string' || config.svid_file.trim() === '')) { + errors.push('auth.provider_config.svid_file is required for file-mounted JWT-SVID acquisition'); + } else if (config.svid_file != null && typeof config.svid_file !== 'string') { + errors.push('auth.provider_config.svid_file must be a string'); } - - if (errors.length > 0) { - return { valid: false, errors }; + if (required && (typeof audience !== 'string' || audience.trim() === '')) { + errors.push('auth.audience or auth.provider_config.audience is required for JWT-SVID verification'); } - return { valid: true }; - }, - - /** - * Resolve a credential session by acquiring a JWT-SVID. - * - * Attempts to acquire the JWT-SVID in order of preference: - * 1. Read from a file path (svid_file) -- common with Kubernetes projected volumes - * 2. Contact the SPIFFE Workload API via the configured or default socket - * - * The SPIFFE Workload API is a Unix domain socket gRPC service. Since - * gRPC requires external dependencies, this provider uses the file-based - * approach as the primary mechanism. The Workload API socket path is - * recorded for diagnostic purposes. - * - * @param {object} request - The session request containing the profile and instanceId. - * @param {object} [ctx] - Resolution context. ctx.env defaults to process.env. - * @returns {Promise} A credential session. - */ - async resolveSession(request, ctx) { - const env = (ctx && ctx.env) || process.env; - const profile = request.profile || {}; - const providerConfig = (profile.auth && profile.auth.provider_config) || {}; - const auth = profile.auth || {}; - const required = auth.required !== false; - - const audience = providerConfig.audience || auth.audience; - const svidFile = providerConfig.svid_file || null; - const workloadApiSocket = providerConfig.workload_api_socket || env.SPIFFE_ENDPOINT_SOCKET || null; - - const trustLevel = (profile.trust && profile.trust.level) || 'supervised'; - const subject = profile.subject || {}; - - const buildEmptySession = () => ({ - provider: 'spiffe-jwt-svid', - subject: { - principal: subject.principal || null, - issuer: subject.issuer || null, - run_as: subject.run_as || null, - }, - instance: request.instanceId ? { id: request.instanceId, source: 'operator' } : null, - trust: { - declared_level: trustLevel, - effective_level: trustLevel, - }, - delegation_chain: [], - delegation_validation: { - valid: true, - depth: 0, - acyclic: true, - all_grants_present: true, - }, - credentials: {}, - provider_assertions: { - audience, - svid_file: svidFile, - workload_api_socket: workloadApiSocket, - }, - refresh: { - supported: true, - expires_at: null, - }, - handoff: { - mode: 'none', - prepared: false, - }, - }); - - // Strategy 1: Read JWT-SVID from a file path - let jwtSvid = null; - let acquisitionMethod = null; - - if (svidFile) { - try { - jwtSvid = readFileSync(svidFile, 'utf8').trim(); - if (jwtSvid.length === 0) { - jwtSvid = null; - } else { - acquisitionMethod = 'file'; - } - } catch { - // File not readable -- fall through to socket approach - jwtSvid = null; - } + const trustSources = ['public_key_pem', 'public_key_file', 'jwks', 'jwks_file'] + .filter(key => config[key] != null); + if (required && trustSources.length !== 1) { + errors.push('exactly one local JWT-SVID trust source is required'); + } else if (trustSources.length > 1) { + errors.push('configure only one JWT-SVID trust source'); } - - // Strategy 2: Try the SPIFFE Workload API via HTTP - // The SPIRE Agent exposes a REST-like API at the Unix domain socket. - // Standard fetch() cannot connect to Unix domain sockets, but some - // SPIFFE implementations expose an HTTP endpoint. Attempt to reach it - // if the socket path looks like a TCP endpoint (http:// or https://). - if (!jwtSvid && workloadApiSocket) { - if (workloadApiSocket.startsWith('http://') || workloadApiSocket.startsWith('https://')) { - // TCP-based Workload API endpoint (e.g., Envoy SDS sidecar or - // SPIRE Agent configured with a TCP listener) + if (config.public_key_pem != null) { + if (typeof config.public_key_pem !== 'string' || config.public_key_pem.trim() === '') { + errors.push('auth.provider_config.public_key_pem must be a non-empty PEM string'); + } else { try { - const apiUrl = new URL('/v1/auth/jwt-svids', workloadApiSocket); - const response = await fetch(apiUrl.toString(), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ audience: [audience] }), - signal: AbortSignal.timeout(10000), - }); - - if (response.ok) { - const data = await response.json(); - // SPIRE REST API returns { svids: [{ spiffe_id, svid, ... }] } - if (data.svids && data.svids.length > 0 && data.svids[0].svid) { - jwtSvid = data.svids[0].svid; - acquisitionMethod = 'workload-api-http'; - } - } + if (/BEGIN (?:RSA )?PRIVATE KEY/.test(config.public_key_pem)) throw new Error('private key'); + assertStrongRsaKey(createPublicKey(config.public_key_pem)); } catch { - // HTTP endpoint not reachable -- fall through to error + errors.push('auth.provider_config.public_key_pem must be a public RSA key of at least 2048 bits'); } } - // Unix domain socket path (e.g., /run/spire/agent/sockets/api.sock) - // Standard Node fetch() cannot connect to UDS without a custom agent. - // This is a known limitation documented below. The svid_file approach - // is the recommended alternative. } - - if (!jwtSvid) { - const err = new Error( - 'SPIFFE workload API not available. Set SPIFFE_ENDPOINT_SOCKET or provide svid_file path.' - ); - err.code = 'spiffe_unavailable'; - - if (required) { - throw err; + if (config.public_key_file != null && + (typeof config.public_key_file !== 'string' || config.public_key_file.trim() === '')) { + errors.push('auth.provider_config.public_key_file must be a non-empty file path'); + } + if (config.jwks != null) { + try { + validateJwksStructure(config.jwks); + } catch (error) { + errors.push(error.message); } - return buildEmptySession(); } + if (config.jwks_file != null && + (typeof config.jwks_file !== 'string' || config.jwks_file.trim() === '')) { + errors.push('auth.provider_config.jwks_file must be a non-empty file path'); + } + if (config.clock_tolerance_s != null && + (!Number.isInteger(config.clock_tolerance_s) || config.clock_tolerance_s < 0 || config.clock_tolerance_s > 300)) { + errors.push('auth.provider_config.clock_tolerance_s must be an integer from 0 through 300'); + } + return errors.length === 0 ? { valid: true } : { valid: false, errors }; + }, - // Parse the JWT-SVID to extract claims - let claims; - try { - const parsed = parseJwtClaims(jwtSvid); - claims = parsed.payload; - } catch (parseErr) { - const err = new Error( - `Failed to parse JWT-SVID: ${parseErr.message}` - ); - err.code = 'spiffe_unavailable'; - err.cause = parseErr; + resolveSession(request, ctx = {}) { + const profile = request?.profile || {}; + const auth = profile.auth || {}; + const config = auth.provider_config || {}; + const required = auth.required !== false; + const audience = auth.audience || config.audience; - if (required) { - throw err; - } - return buildEmptySession(); + const trustConfigured = ['public_key_pem', 'public_key_file', 'jwks', 'jwks_file'] + .some(key => config[key] != null); + if (!config.svid_file || (!required && (!audience || !trustConfigured))) return emptySession(profile); + let token; + try { + token = readFileSync(config.svid_file, 'utf8').trim(); + } catch { + if (!required) return emptySession(profile); + throw providerError('spiffe_svid_unavailable', 'File-mounted JWT-SVID could not be read'); + } + if (!token) { + if (!required) return emptySession(profile); + throw providerError('spiffe_svid_unavailable', 'File-mounted JWT-SVID is empty'); } - // Extract SPIFFE-specific claims - const spiffeId = claims.sub || null; - const jwtAudience = claims.aud - ? (Array.isArray(claims.aud) ? claims.aud : [claims.aud]) - : [audience]; - const expiresAt = claims.exp - ? new Date(claims.exp * 1000).toISOString() - : null; - const issuedAt = claims.iat - ? new Date(claims.iat * 1000).toISOString() - : null; - const issuer = claims.iss || null; - - return { + let verified; + try { + verified = verifyJwtSvid(token, { + ...config, + expected_issuer: profile?.subject?.issuer || config.issuer || null, + }, audience, { + clockToleranceS: config.clock_tolerance_s ?? 30, + }); + } catch (error) { + if (!required) return emptySession(profile); + throw error; + } + const claims = verified.claims; + const trustLevel = profile?.trust?.level || 'supervised'; + const principal = profile?.subject?.principal || claims.sub; + const session = { provider: 'spiffe-jwt-svid', subject: { - principal: subject.principal || spiffeId, - issuer: subject.issuer || issuer, - run_as: subject.run_as || null, - }, - instance: request.instanceId ? { id: request.instanceId, source: 'operator' } : null, - trust: { - declared_level: trustLevel, - effective_level: trustLevel, + principal, + issuer: profile?.subject?.issuer || claims.iss || null, + run_as: profile?.subject?.run_as || null, }, + instance: request?.instanceId ? { id: request.instanceId, source: 'operator' } : null, + trust: { declared_level: trustLevel, effective_level: trustLevel }, delegation_chain: [{ - kind: subject.kind || 'service', - principal: subject.principal || spiffeId || 'spiffe-workload', + kind: 'workload', + principal, grant: 'jwt-svid', validated: true, }], @@ -338,171 +381,71 @@ const spiffeJwtSvidProvider = { all_grants_present: true, }, credentials: { - access_token: { + jwt_svid: { kind: 'jwt-svid', - value: jwtSvid, - audience: jwtAudience.length === 1 ? jwtAudience[0] : jwtAudience, - scopes: auth.scopes || [], - expires_at: expiresAt, + value: token, + audience, + expires_at: new Date(claims.exp * 1000).toISOString(), }, }, provider_assertions: { - spiffe_id: spiffeId, - audience: jwtAudience, - issuer, - issued_at: issuedAt, - acquisition_method: acquisitionMethod, - svid_file: svidFile, - workload_api_socket: workloadApiSocket, + spiffe_id: claims.sub, + issuer: claims.iss || null, + audience, + issued_at: Number.isFinite(claims.iat) ? new Date(claims.iat * 1000).toISOString() : null, + acquisition_method: 'file', + signature_verified: true, + jwt_alg: verified.header.alg, + jwt_kid: verified.header.kid || null, + trust_source: verified.trustSource, }, refresh: { supported: true, - expires_at: expiresAt, - }, - handoff: { - mode: 'none', - prepared: false, + expires_at: new Date(claims.exp * 1000).toISOString(), }, + handoff: { mode: 'none', prepared: false }, }; + sessionContexts.set(session, { request, ctx }); + return session; }, - /** - * Refresh a credential session by re-reading the JWT-SVID. - * - * SPIFFE JWT-SVIDs are short-lived and automatically rotated by the - * SPIRE agent. Re-reading the svid_file or re-requesting from the - * workload API will return the current valid SVID. - * - * @param {object} session - The current credential session. - * @param {object} [ctx] - Resolution context. - * @returns {Promise} A refreshed credential session. - */ - async refreshSession(session, ctx) { - const svidFile = session.provider_assertions && session.provider_assertions.svid_file; - const workloadApiSocket = session.provider_assertions && session.provider_assertions.workload_api_socket; - const audience = session.provider_assertions && session.provider_assertions.audience; - const primaryAudience = Array.isArray(audience) ? audience[0] : audience; - - return spiffeJwtSvidProvider.resolveSession({ - profile: { - auth: { - provider_config: { - audience: primaryAudience, - svid_file: svidFile, - workload_api_socket: workloadApiSocket, - }, - }, - trust: session.trust ? { level: session.trust.declared_level } : undefined, - subject: session.subject, - }, - instanceId: session.instance && session.instance.id, - }, ctx); - }, - - /** - * Describe a session for audit purposes. Redacts the JWT-SVID value - * and includes a credential summary. - * - * @param {object} session - The credential session. - * @param {object} _ctx - Resolution context. - * @returns {object} Audit-safe session description. - */ - describeSession(session, _ctx) { - const described = structuredClone(session); - - if (described.credentials && described.credentials.access_token) { - described.credentials.access_token.value = '[REDACTED]'; + refreshSession(session, ctx = {}) { + const prior = sessionContexts.get(session); + if (!prior) { + throw providerError('spiffe_refresh_unavailable', 'JWT-SVID refresh requires the original in-process profile context'); } + return this.resolveSession(prior.request, { ...prior.ctx, ...ctx }); + }, + describeSession(session) { + const described = redactSession(session); described.credential_summary = buildCredentialSummary(session); - return described; }, - /** - * Materialize credentials for tool consumption. - * - * Processes each binding in the presentation, resolving source paths - * from the session and writing them to the specified target (env var - * or temp file). - * - * @param {object} session - The credential session. - * @param {object} presentation - Presentation descriptor with bindings array. - * @param {object} _ctx - Resolution context. - * @returns {object} Materialization result with env_vars, temp_files, and cleanup metadata. - */ - materialize(session, presentation, _ctx) { - const envVars = {}; - const tempFiles = []; - const bindings = (presentation && presentation.bindings) || []; - - for (const binding of bindings) { - const source = binding.source; - const target = binding.target || {}; - const format = binding.format || 'raw'; - - const rawValue = resolveSourcePath(session, source); - if (rawValue === undefined) continue; - - const formatted = formatMaterializationValue(rawValue, format); - - switch (target.kind) { - case 'env': { - const envName = target.name; - if (envName) { - envVars[envName] = formatted; - } - break; - } - - case 'file': { - const prefix = target.prefix || 'agentcli-spiffe-cred'; - const filePath = tempFilePath(prefix); - mkdirSync(tmpdir(), { recursive: true }); - writeFileSync(filePath, formatted, { mode: 0o600 }); - tempFiles.push({ path: filePath, binding_source: source }); - break; - } + materialize(session, presentation) { + return materializeCredentialBindings(session, presentation, { + allowedTargetKinds: this.capabilities.presentation_kinds, + tempPrefix: 'agentcli-spiffe-jwt-svid', + }); + }, - case 'none': - default: - break; - } - } + cleanup(materialization) { + return cleanupMaterializedCredentials(materialization); + }, + validateDelegation(chain, policy = {}) { + const entries = Array.isArray(chain) ? chain : []; + const maxDepth = Number.isInteger(policy.max_depth) ? policy.max_depth : 1; return { - materialized: true, - cleanup_required: tempFiles.length > 0, - env_vars: envVars, - temp_files: tempFiles, - stdin: null, + valid: entries.length <= maxDepth && entries.every(entry => entry?.validated === true), + depth: entries.length, + acyclic: true, + all_grants_present: entries.every(entry => typeof entry?.grant === 'string' && entry.grant.length > 0), }; }, - - /** - * Clean up materialized state by deleting temporary files. - * - * @param {object} materialization - The materialization result from materialize(). - * @param {object} _ctx - Resolution context. - * @returns {{ cleaned: boolean, warnings: string[] }} - */ - cleanup(materialization, _ctx) { - const warnings = []; - const files = (materialization && materialization.temp_files) || []; - - for (const entry of files) { - const filePath = typeof entry === 'string' ? entry : entry.path; - try { - unlinkSync(filePath); - } catch (err) { - warnings.push(`Failed to delete temp file "${filePath}": ${err.message}`); - } - } - - return { cleaned: true, warnings }; - }, }; registerProvider(spiffeJwtSvidProvider); -export { spiffeJwtSvidProvider }; +export { spiffeJwtSvidProvider, verifyJwtSvid }; diff --git a/src/identity/stripe-api-key.js b/src/identity/stripe-api-key.js index c53ed7e..de6609a 100644 --- a/src/identity/stripe-api-key.js +++ b/src/identity/stripe-api-key.js @@ -28,6 +28,19 @@ import { URL } from 'node:url'; // usage grows beyond a handful of scopes, add a max-entries cap. // --------------------------------------------------------------------------- const commandCache = new Map(); +const standaloneCleanupResults = new WeakMap(); +const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; + +function isLoopbackHostname(hostname) { + const normalized = String(hostname || '').replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase(); + if (normalized === 'localhost' || normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') return true; + const octets = normalized.split('.'); + return octets.length === 4 && octets.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255) && Number(octets[0]) === 127; +} + +function stripeIdentityError(code, message) { + return Object.assign(new Error(message), { code }); +} /** * Purge expired entries from the command cache. Called before reads @@ -70,9 +83,10 @@ function envFingerprint(env) { */ function commandSourceCacheKey(command, opts) { const cwdPart = opts && opts.cwd != null ? String(opts.cwd) : ''; + const effectiveEnv = opts && (opts.commandEnv || opts.env); let envPart = '@inherit'; - if (opts && opts.env != null && typeof opts.env === 'object' && opts.env !== process.env) { - envPart = envFingerprint(opts.env); + if (effectiveEnv != null && typeof effectiveEnv === 'object' && effectiveEnv !== process.env) { + envPart = envFingerprint(effectiveEnv); } return `${command}\0${cwdPart}\0${envPart}`; } @@ -106,7 +120,7 @@ function resolveCommandSource(command, ttlMs, opts) { maxBuffer: 1024 * 1024, shell: true, cwd: opts && opts.cwd, - env: opts && opts.env, + env: opts && (opts.commandEnv || opts.env), encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], }); @@ -115,7 +129,7 @@ function resolveCommandSource(command, ttlMs, opts) { return { ok: false, transient: false, - error: `Command returned empty output: ${command}`, + error: 'Credential command returned empty output', }; } commandCache.set(cacheKey, { value, expiresAt: Date.now() + ttlMs }); @@ -125,7 +139,7 @@ function resolveCommandSource(command, ttlMs, opts) { return { ok: false, transient: isTimeout, - error: `Command failed (exit ${err.status || 'unknown'}): ${command}`, + error: `Credential command failed (exit ${err.status || 'unknown'})`, }; } } @@ -143,7 +157,7 @@ function resolveFileSource(filePath) { return { ok: false, transient: false, - error: `Key file is empty: ${filePath}`, + error: 'Credential file is empty', }; } return { ok: true, value: content }; @@ -152,7 +166,7 @@ function resolveFileSource(filePath) { return { ok: false, transient: isTransient, - error: `Failed to read key file "${filePath}": ${err.message}`, + error: 'Credential file could not be read', }; } } @@ -170,7 +184,7 @@ function resolveEnvSource(envVar, env) { return { ok: false, transient: false, - error: `Environment variable "${envVar}" is not set or is empty`, + error: 'Credential environment source is not set or is empty', }; } return { ok: true, value: value.trim() }; @@ -307,17 +321,13 @@ function isScopeReachable(hierarchy, parentScope, targetScope) { } /** - * Mask a key value for safe display: show prefix and last 4 characters. + * Redact a key value for safe display without preserving key fingerprints. * * @param {string} key - The Stripe API key. - * @returns {string} Masked representation (e.g. "rk_live_...ab1c"). + * @returns {string} Redacted representation. */ -function maskKeyValue(key) { - if (typeof key !== 'string' || key.length < 12) return '[INVALID_KEY]'; - const prefixMatch = key.match(/^(sk_live_|sk_test_|rk_live_|rk_test_)/); - if (!prefixMatch) return '[UNKNOWN_FORMAT]'; - const suffix = key.slice(-4); - return `${prefixMatch[1]}...${suffix}`; +function maskKeyValue(_key) { + return '[REDACTED]'; } // --------------------------------------------------------------------------- @@ -528,26 +538,23 @@ async function createRestrictedKey(masterKey, permissions, apiBase) { return { ok: false, transient: false, - error: `Stripe API returned success but missing id or secret in response: ${JSON.stringify(res.body)}`, + error: 'Stripe API returned an incomplete restricted-key response', }; } return { ok: true, key_id: keyId, key_secret: keySecret }; } const isTransient = res.statusCode === 429 || res.statusCode >= 500; - const errorMsg = (res.body && res.body.error && res.body.error.message) - ? res.body.error.message - : `HTTP ${res.statusCode}`; return { ok: false, transient: isTransient, - error: `Stripe API error creating restricted key: ${errorMsg}`, + error: `Stripe API error creating restricted key (HTTP ${res.statusCode})`, }; - } catch (err) { + } catch (_err) { return { ok: false, transient: true, - error: `Stripe API request failed: ${err.message}`, + error: 'Stripe API request failed while creating a restricted key', }; } } @@ -579,12 +586,9 @@ async function deleteRestrictedKey(masterKey, keyId, apiBase) { return { ok: true }; } - const errorMsg = (res.body && res.body.error && res.body.error.message) - ? res.body.error.message - : `HTTP ${res.statusCode}`; - return { ok: false, error: `Stripe API error deleting key ${keyId}: ${errorMsg}` }; - } catch (err) { - return { ok: false, error: `Stripe API request failed during key deletion: ${err.message}` }; + return { ok: false, error: `Stripe API error deleting restricted key (HTTP ${res.statusCode})` }; + } catch (_err) { + return { ok: false, error: 'Stripe API request failed during restricted-key deletion' }; } } @@ -596,7 +600,7 @@ async function deleteRestrictedKey(masterKey, keyId, apiBase) { * @param {string} cwd - Working directory. * @returns {{ ok: boolean, value?: string, error?: string, transient?: boolean }} */ -function resolveMasterKey(masterKeySource, env, cwd) { +function resolveMasterKey(masterKeySource, env, cwd, commandEnv = env) { if (!masterKeySource || typeof masterKeySource !== 'object') { return { ok: false, transient: false, error: 'master_key_source is missing or not an object' }; } @@ -607,7 +611,7 @@ function resolveMasterKey(masterKeySource, env, cwd) { return resolveFileSource(masterKeySource.file); } if (typeof masterKeySource.command === 'string' && masterKeySource.command.length > 0) { - return resolveCommandSource(masterKeySource.command, 60000, { cwd, env }); + return resolveCommandSource(masterKeySource.command, 60000, { cwd, env, commandEnv }); } return { ok: false, @@ -647,7 +651,46 @@ const stripeApiKeyProvider = { */ validateProfile(profile, _ctx) { const errors = []; - const config = (profile.auth && profile.auth.provider_config) || {}; + if (!profile || typeof profile !== 'object' || Array.isArray(profile)) { + return { valid: false, errors: ['identity profile must be an object'] }; + } + const auth = (profile.auth && typeof profile.auth === 'object' && !Array.isArray(profile.auth)) + ? profile.auth + : {}; + const presentation = (profile.presentation && typeof profile.presentation === 'object' && !Array.isArray(profile.presentation)) + ? profile.presentation + : {}; + const config = (auth.provider_config && typeof auth.provider_config === 'object' && !Array.isArray(auth.provider_config)) + ? auth.provider_config + : {}; + + if (profile.auth != null && profile.auth !== auth) errors.push('auth must be an object'); + if (profile.presentation != null && profile.presentation !== presentation) errors.push('presentation must be an object'); + if (auth.mode != null && auth.mode !== 'service') errors.push('auth.mode must be "service"'); + if (auth.cache != null && auth.cache !== 'none') errors.push('auth.cache is unsupported; use "none"'); + if (auth.refresh != null && auth.refresh !== 'never') errors.push('auth.refresh is unsupported; use "never"'); + if (presentation.handoff != null && !['none', 'downscope'].includes(presentation.handoff)) { + errors.push('presentation.handoff must be "none" or "downscope"'); + } + if (presentation.bindings != null && !Array.isArray(presentation.bindings)) { + errors.push('presentation.bindings must be an array'); + } else { + for (const [index, binding] of (presentation.bindings || []).entries()) { + if (!binding || typeof binding !== 'object' || Array.isArray(binding)) { + errors.push(`presentation.bindings[${index}] must be an object`); + continue; + } + if (typeof binding.source !== 'string' || binding.source.length === 0) { + errors.push(`presentation.bindings[${index}].source must be a non-empty string`); + } else if (binding.source === 'provider_assertions' || binding.source.startsWith('provider_assertions.') || + binding.source === 'delegation_chain' || binding.source.startsWith('delegation_chain.')) { + errors.push(`presentation.bindings[${index}].source cannot reference audit-only data`); + } + if (!binding.target || binding.target.kind !== 'env' || !ENV_NAME.test(binding.target.name || '')) { + errors.push(`presentation.bindings[${index}].target must name an environment variable`); + } + } + } // key_strategy const validStrategies = ['precreated', 'dynamic']; @@ -681,14 +724,11 @@ const stripeApiKeyProvider = { errors.push(`permission_sets["${scopeName}"] must be an object`); continue; } - const hasSource = ( - (typeof entry.key_env === 'string' && entry.key_env.length > 0) || - (typeof entry.key_file === 'string' && entry.key_file.length > 0) || - (typeof entry.key_command === 'string' && entry.key_command.length > 0) - ); - if (!hasSource) { + const sourceCount = [entry.key_env, entry.key_file, entry.key_command] + .filter(source => typeof source === 'string' && source.length > 0).length; + if (sourceCount !== 1) { errors.push( - `permission_sets["${scopeName}"] must declare at least one key source: key_env, key_file, or key_command` + `permission_sets["${scopeName}"] must declare exactly one key source: key_env, key_file, or key_command` ); } } @@ -728,13 +768,10 @@ const stripeApiKeyProvider = { errors.push('provider_config.master_key_source is required for dynamic strategy'); } else { const src = config.master_key_source; - const hasSource = ( - (typeof src.env === 'string' && src.env.length > 0) || - (typeof src.file === 'string' && src.file.length > 0) || - (typeof src.command === 'string' && src.command.length > 0) - ); - if (!hasSource) { - errors.push('master_key_source must declare at least one source: env, file, or command'); + const sourceCount = [src.env, src.file, src.command] + .filter(source => typeof source === 'string' && source.length > 0).length; + if (sourceCount !== 1) { + errors.push('master_key_source must declare exactly one source: env, file, or command'); } } @@ -745,20 +782,22 @@ const stripeApiKeyProvider = { } else { try { const parsed = new URL(config.api_base); + if (parsed.username || parsed.password) { + errors.push('provider_config.api_base must not contain URL credentials'); + } if (parsed.protocol === 'https:') { // OK: HTTPS is always allowed } else if (parsed.protocol === 'http:') { - const isLocalhost = parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1'; - if (!isLocalhost && config.allow_insecure_http !== true) { + if (!isLoopbackHostname(parsed.hostname)) { errors.push( - 'provider_config.api_base using http: is only allowed for localhost or when provider_config.allow_insecure_http is true' + 'provider_config.api_base using HTTP is allowed only for loopback endpoints' ); } } else { errors.push('provider_config.api_base must use https: protocol'); } } catch (_urlErr) { - errors.push(`provider_config.api_base is not a valid URL: ${config.api_base}`); + errors.push('provider_config.api_base is not a valid URL'); } } } @@ -802,13 +841,14 @@ const stripeApiKeyProvider = { resolveSession(request, ctx) { const env = (ctx && ctx.env) || process.env; const cwd = (ctx && ctx.cwd) || process.cwd(); + const commandEnv = (ctx && ctx.commandEnv) || env; const profile = request.profile || {}; const config = (profile.auth && profile.auth.provider_config) || {}; const scope = request.scope || null; const trustLevel = (profile.trust && profile.trust.level) || 'supervised'; if (config.key_strategy === 'dynamic') { - return this._resolveDynamicSession(request, config, env, cwd, trustLevel); + return this._resolveDynamicSession(request, config, env, cwd, trustLevel, commandEnv); } // Precreated strategy @@ -841,7 +881,7 @@ const stripeApiKeyProvider = { } else if (typeof permSet.key_file === 'string' && permSet.key_file.length > 0) { keyResult = resolveFileSource(permSet.key_file); } else if (typeof permSet.key_command === 'string' && permSet.key_command.length > 0) { - keyResult = resolveCommandSource(permSet.key_command, cacheTtlMs, { cwd, env }); + keyResult = resolveCommandSource(permSet.key_command, cacheTtlMs, { cwd, env, commandEnv }); } else { return { ok: false, @@ -929,7 +969,7 @@ const stripeApiKeyProvider = { * @param {string} trustLevel - Effective trust level. * @returns {Promise<{ ok: boolean, session?: object, transient?: boolean, error?: string }>} */ - async _resolveDynamicSession(request, config, env, cwd, trustLevel) { + async _resolveDynamicSession(request, config, env, cwd, trustLevel, commandEnv = env) { const scope = request.scope || 'full'; const accountMode = config.account_mode || 'test'; const apiBase = config.api_base || DEFAULT_API_BASE; @@ -938,7 +978,7 @@ const stripeApiKeyProvider = { : DEFAULT_EXPIRY_BUFFER_S; // Resolve the master key - const masterKeyResult = resolveMasterKey(config.master_key_source, env, cwd); + const masterKeyResult = resolveMasterKey(config.master_key_source, env, cwd, commandEnv); if (!masterKeyResult.ok) { return { ok: false, @@ -1058,13 +1098,39 @@ const stripeApiKeyProvider = { // Additional bindings from presentation const bindings = (presentation && presentation.bindings) || []; - for (const binding of bindings) { + for (const [index, binding] of bindings.entries()) { + if (!binding || typeof binding !== 'object' || Array.isArray(binding)) { + throw stripeIdentityError('presentation_binding_invalid', `Credential presentation binding ${index} is invalid`); + } const target = binding.target || {}; - if (target.kind !== 'env' || !target.name) continue; + if (binding.source === 'provider_assertions' || binding.source?.startsWith('provider_assertions.') || + binding.source === 'delegation_chain' || binding.source?.startsWith('delegation_chain.')) { + throw stripeIdentityError( + 'presentation_source_forbidden', + 'Credential presentation cannot use audit-only session data' + ); + } + if (target.kind !== 'env') { + throw stripeIdentityError('presentation_target_unsupported', 'Stripe credentials support env presentation only'); + } + if (!ENV_NAME.test(target.name || '')) { + throw stripeIdentityError('presentation_target_invalid', 'Credential environment target name is invalid'); + } const value = resolveSessionPath(session, binding.source); if (value !== undefined && value !== null) { - envVars[target.name] = String(value); + if (binding.format === 'json' || (binding.format == null && typeof value === 'object')) { + envVars[target.name] = JSON.stringify(value); + } else if (binding.format === 'base64') { + envVars[target.name] = Buffer.from(String(value)).toString('base64'); + } else { + envVars[target.name] = String(value); + } + } else if (binding.required === true) { + throw stripeIdentityError( + 'presentation_binding_missing', + `Required credential presentation binding ${index} is unavailable` + ); } } @@ -1077,7 +1143,12 @@ const stripeApiKeyProvider = { // Embed session reference so cleanup() can access stripe_key_id and // provider_config without callers needing to thread the session through ctx. if (config.key_strategy === 'dynamic') { - result.session = session; + Object.defineProperty(result, 'session', { + value: session, + enumerable: false, + configurable: false, + writable: false, + }); } return result; @@ -1096,46 +1167,62 @@ const stripeApiKeyProvider = { * @returns {Promise<{ cleaned: boolean, warnings?: string[] }>|{ cleaned: boolean, warnings?: string[] }} */ cleanup(materialization, ctx) { + const trackable = materialization && typeof materialization === 'object'; + if (trackable) { + const existing = standaloneCleanupResults.get(materialization); + if (existing) return existing; + } + const remember = result => { + if (trackable) { + if ((result?.warnings || []).length > 0) standaloneCleanupResults.delete(materialization); + else standaloneCleanupResults.set(materialization, result); + } + return result; + }; + const session = (ctx && ctx.session) || (materialization && materialization.session) || null; if (!session) { - return { cleaned: true }; + return remember({ cleaned: true }); } const assertions = session.provider_assertions || {}; if (assertions.key_strategy !== 'dynamic') { // Precreated keys need no cleanup -- they are long-lived and not minted per-job - return { cleaned: true }; + return remember({ cleaned: true }); } const keyId = assertions.stripe_key_id; if (!keyId) { - return { cleaned: true, warnings: ['Dynamic session has no stripe_key_id; nothing to revoke'] }; + return remember({ cleaned: true, warnings: ['Dynamic session has no stripe_key_id; nothing to revoke'] }); } // Resolve master key for deletion const env = (ctx && ctx.env) || process.env; const cwd = (ctx && ctx.cwd) || process.cwd(); + const commandEnv = (ctx && ctx.commandEnv) || env; const config = (ctx && ctx.provider_config) || {}; const masterKeySource = config.master_key_source || {}; const apiBase = assertions.api_base || config.api_base || DEFAULT_API_BASE; - const masterKeyResult = resolveMasterKey(masterKeySource, env, cwd); + const masterKeyResult = resolveMasterKey(masterKeySource, env, cwd, commandEnv); if (!masterKeyResult.ok) { - return { + return remember({ cleaned: true, warnings: [`Could not resolve master key for cleanup: ${masterKeyResult.error}`], - }; + }); } // Async deletion, best-effort - return deleteRestrictedKey(masterKeyResult.value, keyId, apiBase).then((delResult) => { + const operation = deleteRestrictedKey(masterKeyResult.value, keyId, apiBase).then((delResult) => { if (!delResult.ok) { - return { cleaned: true, warnings: [delResult.error] }; + return remember({ cleaned: true, warnings: [delResult.error] }); } - return { cleaned: true }; - }).catch((err) => { - return { cleaned: true, warnings: [`Key revocation failed: ${err.message}`] }; + return remember({ cleaned: true }); + }).catch((_err) => { + return remember({ cleaned: true, warnings: ['Restricted-key revocation failed'] }); }); + if (trackable) standaloneCleanupResults.set(materialization, operation); + return operation; }, /** @@ -1155,6 +1242,7 @@ const stripeApiKeyProvider = { prepareHandoff(session, handoff, ctx) { const env = (ctx && ctx.env) || process.env; const cwd = (ctx && ctx.cwd) || process.cwd(); + const commandEnv = (ctx && ctx.commandEnv) || env; const targetScope = handoff && handoff.target_scope; const parentProfile = handoff && handoff.parent_profile; @@ -1186,7 +1274,7 @@ const stripeApiKeyProvider = { } if (config.key_strategy === 'dynamic') { - return this._prepareDynamicHandoff(session, targetScope, parentScope, config, env, cwd); + return this._prepareDynamicHandoff(session, targetScope, parentScope, config, env, cwd, commandEnv); } // Look up the target scope's permission set (precreated strategy) @@ -1209,7 +1297,7 @@ const stripeApiKeyProvider = { } else if (typeof targetPermSet.key_file === 'string' && targetPermSet.key_file.length > 0) { keyResult = resolveFileSource(targetPermSet.key_file); } else if (typeof targetPermSet.key_command === 'string' && targetPermSet.key_command.length > 0) { - keyResult = resolveCommandSource(targetPermSet.key_command, cacheTtlMs, { cwd, env }); + keyResult = resolveCommandSource(targetPermSet.key_command, cacheTtlMs, { cwd, env, commandEnv }); } else { return { prepared: false, @@ -1294,7 +1382,7 @@ const stripeApiKeyProvider = { * @param {string} cwd - Working directory. * @returns {Promise<{ prepared: boolean, session?: object, error?: string }>} */ - async _prepareDynamicHandoff(session, targetScope, parentScope, config, env, cwd) { + async _prepareDynamicHandoff(session, targetScope, parentScope, config, env, cwd, commandEnv = env) { const accountMode = config.account_mode || 'test'; const apiBase = config.api_base || DEFAULT_API_BASE; const expiryBufferS = (typeof config.default_expiry_buffer_s === 'number' && config.default_expiry_buffer_s > 0) @@ -1302,7 +1390,7 @@ const stripeApiKeyProvider = { : DEFAULT_EXPIRY_BUFFER_S; // Resolve the master key for minting the child key - const masterKeyResult = resolveMasterKey(config.master_key_source, env, cwd); + const masterKeyResult = resolveMasterKey(config.master_key_source, env, cwd, commandEnv); if (!masterKeyResult.ok) { return { prepared: false, error: `Failed to resolve master key for handoff: ${masterKeyResult.error}` }; } @@ -1454,8 +1542,8 @@ const stripeApiKeyProvider = { }, /** - * Describe a session for audit purposes. Redacts all key values; - * shows only prefix and last 4 characters. + * Describe a session for audit purposes. Redacts all key values without + * preserving prefixes or suffixes that could become credential fingerprints. * * @param {object} session - The credential session. * @param {object} _ctx - Resolution context. @@ -1468,6 +1556,18 @@ const stripeApiKeyProvider = { const original = described.credentials.api_key.value; described.credentials.api_key.value = maskKeyValue(original); } + if (typeof described.provider_assertions?.api_base === 'string') { + try { + const apiBase = new URL(described.provider_assertions.api_base); + apiBase.username = ''; + apiBase.password = ''; + apiBase.search = ''; + apiBase.hash = ''; + described.provider_assertions.api_base = apiBase.toString(); + } catch { + described.provider_assertions.api_base = '[REDACTED]'; + } + } return described; }, diff --git a/src/index.js b/src/index.js index 2cd5a64..56c9dc6 100644 --- a/src/index.js +++ b/src/index.js @@ -3,12 +3,24 @@ export { handleJsonRpcRequest, serveJsonRpc } from './jsonrpc.js'; export { validateManifest } from './validate.js'; export { compileManifestToStandalone } from './compiler/standalone.js'; export { compileManifestToScheduler } from './compiler/openclaw-scheduler.js'; -export { applyManifestToScheduler, createSchedulerCliRunner, resolveSchedulerInvocation } from './apply.js'; +export { + applyManifestToScheduler, + createSchedulerCliRunner, + resolveSchedulerInvocation, + requiredSchedulerFieldVersion, + negotiateSchedulerFieldVersion, +} from './apply.js'; export { querySchedulerCapabilities, resolveEffectiveFeatures, validateManifestCapabilities } from './capabilities.js'; -export { MANIFEST_SCHEMA, MANIFEST_VERSION } from './schema.js'; +export { + MANIFEST_SCHEMA, + MANIFEST_JSON_SCHEMA, + JSON_SCHEMAS, + MANIFEST_VERSION, +} from './schema.js'; export { TARGETS, getTarget, listTargets, registerTarget } from './targets.js'; export { ensureAgentcliHome, getAgentcliPaths, resolveAgentcliHome, resolveManifestCandidate } from './home.js'; export { normalizeShellExecution, renderShellExecution } from './shell.js'; +export { resolveCommandValue, resolveValueFrom, shellCommandInvocation } from './command.js'; export { resolveSandboxSupport, needsSandboxEnforcement, @@ -18,10 +30,18 @@ export { export { inspectSchedulerState, listInspectableEntities } from './inspect.js'; export { describeTarget } from './describe.js'; export { sanitizeForAgent } from './sanitize.js'; +export { sortKeysDeep, canonicalStringify, canonicalDigest, hashString, hashNullableString } from './canonical.js'; +export { AgentcliError, ERROR_CODES, ERROR_TYPES, makeError, normalizeError, errorTypeForCode } from './errors.js'; export { expandManifestShorthands } from './shorthand.js'; export { applyFieldMask, parseFieldMask } from './fields.js'; export { loadJsonInput, writeJsonOutput, resolveSafeOutputPath } from './io.js'; -export { executeTask } from './exec.js'; +export { + executeTask, + inspectTaskIdentity, + validateTaskDelegation, + evaluateTaskAuthorization, + verifyTaskAuthorizationProof, +} from './exec.js'; export { runWorkflow } from './run.js'; export { registerRuntimeAdapter, @@ -42,7 +62,18 @@ export { approvalPolicyAutoRejects, verifyApprovalSignature, } from './approvals.js'; -export { resolveIdentity, resolveIdentityV2, resolveContract, resolveAuthorizationProof, resolveAuthorization, resolveEvidence } from './compiler/shared.js'; +export { + resolveIdentity, + resolveIdentityV2, + resolveContract, + resolveAuthorizationProof, + resolveAuthorization, + resolveEvidence, + buildEffectiveExecutionBinding, + computeEffectiveTaskHash, + commandBindingForShell, + canonicalExecutionBindingString, +} from './compiler/shared.js'; export { buildAttestationPayload, commandHash } from './attestation.js'; export { createManifestScaffold, writeManifest } from './init.js'; export { listRegistry, addToRegistry, showRegistryEntry, removeFromRegistry } from './registry.js'; @@ -94,9 +125,16 @@ export { } from './evidence/index.js'; export { buildEvidencePayload, + buildCompleteEvidencePayload, + validateCompleteEvidencePayload, + validateEvidenceRecordBinding, + EVIDENCE_PAYLOAD_SCHEMA, + EVIDENCE_PAYLOAD_VERSION, serializePayload, collectComplianceContext, } from './evidence/payload.js'; +export { verifyEvidenceEnvelope } from './evidence/index.js'; +export { EVIDENCE_ENVELOPE_SCHEMA, EVIDENCE_ENVELOPE_VERSION } from './evidence/ssh.js'; // -- Authorization proof verifiers (v0.2) -- export { @@ -104,6 +142,9 @@ export { getVerifier, listVerifiers, resolveVerifier, + validateAuthorizationProofProfile, + assertValidAuthorizationProofProfile, + verifyAuthorizationProof, } from './authorization-proof/index.js'; // -- Authorization providers (v0.2) -- diff --git a/src/init.js b/src/init.js index c2627ba..84e1733 100644 --- a/src/init.js +++ b/src/init.js @@ -1,7 +1,7 @@ -import { existsSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { existsSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { validateManifest } from './validate.js'; +import { resolveSafeOutputPath, writeJsonOutput } from './io.js'; const DEFAULT_WORKFLOW_ID = 'default'; const DEFAULT_TASK_ID = 'run'; @@ -26,7 +26,7 @@ export function createManifestScaffold({ const args = tool ? [] : ['hello from agentcli']; const manifest = { - version: '0.1', + version: '0.2', workflows: [ { id: workflowId, @@ -38,6 +38,12 @@ export function createManifestScaffold({ target: { session_target: 'shell' }, shell: { program, args }, schedule: { cron: '0 * * * *' }, + output: { format: 'text' }, + contract: { + sandbox: 'permissive', + network: 'unrestricted', + audit: 'always', + }, }, ], }, @@ -62,7 +68,8 @@ export function createManifestScaffold({ } export function writeManifest(manifest, { output, cwd = process.cwd() } = {}) { - const filePath = output || join(cwd, 'agentcli.json'); + const requestedPath = output || 'agentcli.json'; + const filePath = resolveSafeOutputPath(requestedPath, cwd); if (existsSync(filePath)) { throw Object.assign( @@ -71,6 +78,5 @@ export function writeManifest(manifest, { output, cwd = process.cwd() } = {}) { ); } - writeFileSync(filePath, JSON.stringify(manifest, null, 2) + '\n', 'utf8'); - return filePath; + return writeJsonOutput(requestedPath, manifest, { cwd }); } diff --git a/src/io.js b/src/io.js index 8c1e259..fd169d9 100644 --- a/src/io.js +++ b/src/io.js @@ -1,5 +1,15 @@ -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, relative, resolve } from 'node:path'; +import { + closeSync, + constants as fsConstants, + existsSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + realpathSync, + writeFileSync, +} from 'node:fs'; +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; import { isatty } from 'node:tty'; import { resolveManifestCandidate } from './home.js'; @@ -14,11 +24,17 @@ export async function loadJsonInput( { cwd = process.cwd(), env = process.env, stdin = process.stdin } = {} ) { if (!input) { - throw new Error('Missing input. Pass a file path or JSON string.'); + throw Object.assign( + new Error('Missing input. Pass a file path or JSON string.'), + { code: 'invalid_argument' } + ); } const resolvedPath = resolveManifestCandidate(input, { cwd, env }); if (input === '-' && (stdin?.isTTY ?? isatty(0))) { - throw new Error('stdin is a TTY. Pipe JSON data or pass a file path.'); + throw Object.assign( + new Error('stdin is a TTY. Pipe JSON data or pass a file path.'), + { code: 'invalid_argument' } + ); } const raw = input === '-' ? await readStdinText(stdin) @@ -27,7 +43,10 @@ export async function loadJsonInput( : looksLikeJsonLiteral(input) ? input : (() => { - throw new Error(`Input not found: ${input}. Pass a file path, a manifest name from AGENTCLI_HOME/manifests, or a JSON string.`); + throw Object.assign( + new Error(`Input not found: ${input}. Pass a file path, a manifest name from AGENTCLI_HOME/manifests, or a JSON string.`), + { code: 'invalid_argument' } + ); })(); try { return JSON.parse(raw); @@ -42,7 +61,10 @@ export async function loadJsonInput( async function readStdinText(stream) { if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') { - throw new Error('stdin is not readable. Pipe JSON data or pass a file path.'); + throw Object.assign( + new Error('stdin is not readable. Pipe JSON data or pass a file path.'), + { code: 'invalid_argument' } + ); } let raw = ''; @@ -52,29 +74,94 @@ async function readStdinText(stream) { return raw; } +function invalidOutput(message) { + return Object.assign(new Error(message), { code: 'invalid_argument' }); +} + +function isWithin(basePath, candidatePath) { + const pathFromBase = relative(basePath, candidatePath); + return pathFromBase === '' || ( + pathFromBase !== '..' && + !pathFromBase.startsWith(`..${sep}`) && + !isAbsolute(pathFromBase) + ); +} + +function nearestExistingAncestor(candidatePath) { + let current = candidatePath; + while (!existsSync(current)) { + const parent = dirname(current); + if (parent === current) return null; + current = parent; + } + return current; +} + export function resolveSafeOutputPath(outputPath, cwd = process.cwd()) { if (!outputPath) { - throw new Error('Missing output path.'); + throw invalidOutput('Missing output path.'); } const baseDir = resolve(cwd); + if (!existsSync(baseDir)) { + throw invalidOutput(`Current working directory does not exist: ${baseDir}`); + } + const baseRealPath = realpathSync(baseDir); const resolvedPath = resolve(baseDir, outputPath); const relativePath = relative(baseDir, resolvedPath); if (relativePath === '' || relativePath === '.') { - throw new Error('Output path must point to a file inside the current working directory.'); + throw invalidOutput('Output path must point to a file inside the current working directory.'); + } + + if (!isWithin(baseDir, resolvedPath)) { + throw invalidOutput('Refusing to write outside the current working directory.'); + } + + const existingAncestor = nearestExistingAncestor(dirname(resolvedPath)); + if (!existingAncestor) { + throw invalidOutput('Unable to resolve an existing parent directory for the output path.'); + } + const realAncestor = realpathSync(existingAncestor); + if (!isWithin(baseRealPath, realAncestor)) { + throw invalidOutput('Refusing to write through a symlink outside the current working directory.'); } - if (relativePath.startsWith('..')) { - throw new Error('Refusing to write outside the current working directory.'); + if (existsSync(resolvedPath)) { + if (lstatSync(resolvedPath).isSymbolicLink()) { + throw invalidOutput('Refusing to overwrite a symbolic link.'); + } + if (!isWithin(baseRealPath, realpathSync(resolvedPath))) { + throw invalidOutput('Refusing to overwrite a file outside the current working directory.'); + } } return resolvedPath; } export function writeJsonOutput(outputPath, payload, { cwd = process.cwd() } = {}) { - const resolvedPath = resolveSafeOutputPath(outputPath, cwd); + let resolvedPath = resolveSafeOutputPath(outputPath, cwd); mkdirSync(dirname(resolvedPath), { recursive: true }); - writeFileSync(resolvedPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); + resolvedPath = resolveSafeOutputPath(outputPath, cwd); + + let fd; + try { + fd = openSync( + resolvedPath, + fsConstants.O_WRONLY | + fsConstants.O_CREAT | + fsConstants.O_TRUNC | + (fsConstants.O_NOFOLLOW || 0), + 0o600 + ); + writeFileSync(fd, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); + } catch (err) { + if (err?.code === 'ELOOP') { + throw invalidOutput('Refusing to overwrite a symbolic link.'); + } + throw err; + } finally { + if (fd !== undefined) closeSync(fd); + } return resolvedPath; } diff --git a/src/jsonrpc.js b/src/jsonrpc.js index a8e4937..5b8267f 100644 --- a/src/jsonrpc.js +++ b/src/jsonrpc.js @@ -1,13 +1,18 @@ import { existsSync } from 'node:fs'; import { createRequire } from 'node:module'; import { createInterface } from 'node:readline'; -import { MANIFEST_SCHEMA, MANIFEST_VERSION } from './schema.js'; +import { JSON_SCHEMAS, MANIFEST_SCHEMA, MANIFEST_VERSION } from './schema.js'; import { describeTarget } from './describe.js'; import { validateManifest } from './validate.js'; -import { getTarget } from './targets.js'; +import { getTarget, listTargets } from './targets.js'; import { parseFieldMask } from './fields.js'; import { inspectSchedulerState, listInspectableEntities } from './inspect.js'; import { applyManifestToScheduler } from './apply.js'; +import { normalizeError } from './errors.js'; +import { getAgentcliPaths } from './home.js'; +import { readAuditLog } from './audit.js'; +import { listApprovals } from './approvals.js'; +import { listRegistry, showRegistryEntry } from './registry.js'; const require = createRequire(import.meta.url); const { version: PACKAGE_VERSION } = require('../package.json'); @@ -29,15 +34,16 @@ function responseError(id, code, message, data) { } class InvalidParamsError extends Error { - constructor(message, data) { + constructor(message, data, code = 'invalid_argument') { super(message); this.name = 'InvalidParamsError'; this.data = data; + this.code = code; } } -function invalidParams(message, data) { - return new InvalidParamsError(message, data); +function invalidParams(message, data, code) { + return new InvalidParamsError(message, data, code); } function paramsObject(rawParams) { @@ -50,20 +56,29 @@ function paramsObject(rawParams) { return rawParams; } -function schemaByName(name = 'manifest') { +function schemaByName(name = 'manifest', { legacy = false } = {}) { const aliases = { 'scheduler-job': 'schedulerJob', 'standalone-plan': 'standalonePlan', 'rpc-request': 'rpcRequest', 'rpc-response': 'rpcResponse' }; - const schema = MANIFEST_SCHEMA[aliases[name] || name]; + const schemas = legacy ? MANIFEST_SCHEMA : JSON_SCHEMAS; + const schema = schemas[aliases[name] || name]; if (!schema) { throw invalidParams(`Unknown schema target: ${name}`); } return schema; } +function rpcErrorData(code, errorType, extra) { + return { + code, + error_type: errorType, + ...(extra === undefined ? {} : { details: extra }), + }; +} + function describedTarget(name = 'commands') { try { return describeTarget(name); @@ -142,15 +157,30 @@ function inspectParams(params, defaults) { export async function handleJsonRpcRequest(message, defaults = {}) { if (Array.isArray(message)) { - return responseError(null, -32600, 'Batch requests are not supported'); + return responseError( + null, + -32600, + 'Batch requests are not supported', + rpcErrorData('invalid_argument', 'invalid_argument') + ); } if (!message || typeof message !== 'object') { - return responseError(null, -32600, 'Invalid Request'); + return responseError( + null, + -32600, + 'Invalid Request', + rpcErrorData('invalid_argument', 'invalid_argument') + ); } const { id = null, method, params: rawParams } = message; if (message.jsonrpc !== '2.0' || typeof method !== 'string') { - return responseError(id, -32600, 'Invalid Request'); + return responseError( + id, + -32600, + 'Invalid Request', + rpcErrorData('invalid_argument', 'invalid_argument') + ); } try { @@ -164,13 +194,30 @@ export async function handleJsonRpcRequest(message, defaults = {}) { package_version: PACKAGE_VERSION, manifest_version: MANIFEST_VERSION }); - case 'agentcli.schema': - return responseResult(id, { ok: true, schema: schemaByName(params.target) }); + case 'agentcli.schema': { + if (params.legacy != null && typeof params.legacy !== 'boolean') { + throw invalidParams('legacy must be a boolean'); + } + return responseResult(id, { + ok: true, + schema_format: params.legacy ? 'agentcli-legacy' : 'json-schema-draft-2020-12', + schema: schemaByName(params.target, { legacy: Boolean(params.legacy) }), + }); + } case 'agentcli.describe': return responseResult(id, { ok: true, description: describedTarget(params.target) }); + case 'agentcli.targets': + return responseResult(id, { ok: true, targets: listTargets() }); + case 'agentcli.paths': + return responseResult(id, { + ok: true, + paths: getAgentcliPaths({ env: defaults.env || process.env }), + }); case 'agentcli.validate': + if (!Object.hasOwn(params, 'manifest')) throw invalidParams('manifest is required'); return responseResult(id, validateManifest(params.manifest)); case 'agentcli.compile': { + if (!Object.hasOwn(params, 'manifest')) throw invalidParams('manifest is required'); const target = compileTarget(params.target || defaults.target || 'standalone'); return responseResult( id, @@ -182,6 +229,10 @@ export async function handleJsonRpcRequest(message, defaults = {}) { ); } case 'agentcli.apply': { + if (!Object.hasOwn(params, 'manifest')) throw invalidParams('manifest is required'); + if (params.allowProofCommand != null && typeof params.allowProofCommand !== 'boolean') { + throw invalidParams('allowProofCommand must be a boolean'); + } const adoptBy = params.adoptBy || 'id'; if (adoptBy !== 'id' && adoptBy !== 'name') { throw invalidParams(`Invalid adoptBy value: ${adoptBy}. Accepted values: id, name`); @@ -195,7 +246,8 @@ export async function handleJsonRpcRequest(message, defaults = {}) { dbPath: params.dbPath || defaults.dbPath, schedulerPrefix: params.schedulerPrefix || defaults.schedulerPrefix || '', schedulerBin: params.schedulerBin || defaults.schedulerBin || '', - runner: defaults.schedulerRunner || null + runner: defaults.schedulerRunner || null, + allowValueFromCommand: Boolean(params.allowProofCommand), }) ); } @@ -204,11 +256,45 @@ export async function handleJsonRpcRequest(message, defaults = {}) { id, await inspectSchedulerState(inspectParams(params, defaults)) ); + case 'agentcli.audit': { + const limit = inspectLimit(params.limit); + const paths = getAgentcliPaths({ env: defaults.env || process.env }); + const warnings = []; + const records = readAuditLog({ + auditPath: paths.audit, + limit, + onMalformed: ({ lineNumber }) => warnings.push({ + line_number: lineNumber, + message: 'malformed audit record skipped', + }), + }); + return responseResult(id, { ok: true, count: records.length, records, warnings }); + } + case 'agentcli.approvals.list': { + const records = listApprovals({ + env: defaults.env || process.env, + status: params.status, + workflowId: params.workflowId, + taskId: params.taskId, + }); + return responseResult(id, { ok: true, count: records.length, records }); + } + case 'agentcli.registry.list': { + const entries = listRegistry({ env: defaults.env || process.env }); + return responseResult(id, { ok: true, entries }); + } + case 'agentcli.registry.show': { + if (typeof params.name !== 'string' || params.name.trim() === '') { + throw invalidParams('name is required'); + } + const manifest = showRegistryEntry(params.name, { env: defaults.env || process.env }); + return responseResult(id, { ok: true, name: params.name, manifest }); + } case 'agentcli.convert': { const p = paramsObject(rawParams); if (!p.manifest) throw invalidParams('manifest is required'); const { convertManifestV1toV2 } = await import('./convert.js'); - return responseResult(id, convertManifestV1toV2(p.manifest)); + return responseResult(id, { ok: true, manifest: convertManifestV1toV2(p.manifest) }); } case 'agentcli.authorizationProof.methods': { const { listVerifiers } = await import('./authorization-proof/index.js'); @@ -216,7 +302,7 @@ export async function handleJsonRpcRequest(message, defaults = {}) { await import('./authorization-proof/jwt.js'); await import('./authorization-proof/detached-signature.js'); await import('./authorization-proof/certificate.js'); - return responseResult(id, { methods: listVerifiers() }); + return responseResult(id, { ok: true, methods: listVerifiers() }); } case 'agentcli.authorizationProof.schema': { const p = paramsObject(rawParams); @@ -228,7 +314,25 @@ export async function handleJsonRpcRequest(message, defaults = {}) { await import('./authorization-proof/certificate.js'); const verifier = getVerifier(p.method); if (!verifier) throw invalidParams(`Unknown verifier method: ${p.method}`); - return responseResult(id, { method: p.method, verifier: verifier.name }); + return responseResult(id, { ok: true, method: p.method, verifier: verifier.name }); + } + case 'agentcli.authorizationProof.verify': { + const p = paramsObject(rawParams); + if (!p.manifest) throw invalidParams('manifest is required'); + if (!p.taskId) throw invalidParams('taskId is required'); + const { verifyTaskAuthorizationProof } = await import('./exec.js'); + const result = await verifyTaskAuthorizationProof(p.manifest, { + workflowId: p.workflowId, + taskId: p.taskId, + cwd: defaults.cwd || process.cwd(), + env: defaults.env || process.env, + }); + return responseResult(id, { + ok: true, + authorization_proof: result.authorization_proof || null, + effective_task_hash: result.effective_task_hash, + manifest_digest: result.manifest_digest, + }); } case 'agentcli.identity.providers': { const { listProviders, listProviderCapabilities } = await import('./identity/index.js'); @@ -244,7 +348,7 @@ export async function handleJsonRpcRequest(message, defaults = {}) { await import('./identity/entra-agent-id.js'); const providers = listProviders(); const capabilities = listProviderCapabilities(); - return responseResult(id, { providers: providers.map(name => ({ name, capabilities: capabilities.get(name) || null })) }); + return responseResult(id, { ok: true, providers: providers.map(name => ({ name, capabilities: capabilities.get(name) || null })) }); } case 'agentcli.identity.schema': { const p = paramsObject(rawParams); @@ -262,29 +366,41 @@ export async function handleJsonRpcRequest(message, defaults = {}) { await import('./identity/entra-agent-id.js'); const idProvider = getProvider(p.provider); if (!idProvider) throw invalidParams(`Unknown identity provider: ${p.provider}`); - return responseResult(id, { provider: p.provider, capabilities: idProvider.capabilities }); + return responseResult(id, { ok: true, provider: p.provider, capabilities: idProvider.capabilities }); } case 'agentcli.identity.resolve': { const p = paramsObject(rawParams); if (!p.manifest) throw invalidParams('manifest is required'); if (!p.taskId) throw invalidParams('taskId is required'); - const { executeTask } = await import('./exec.js'); - const result = await executeTask(p.manifest, { workflowId: p.workflowId, taskId: p.taskId, dryRun: true, identityDebug: true }); - return responseResult(id, { declared_identity: result.declared_identity || result.identity, resolved_identity: result.resolved_identity || null, principal_used: result.principal_used }); + const { inspectTaskIdentity } = await import('./exec.js'); + const result = await inspectTaskIdentity(p.manifest, { + workflowId: p.workflowId, + taskId: p.taskId, + identityDebug: true, + cwd: defaults.cwd || process.cwd(), + env: defaults.env || process.env, + }); + return responseResult(id, { ok: true, declared_identity: result.declared_identity || result.identity, resolved_identity: result.resolved_identity || null, principal_used: result.principal_used }); } case 'agentcli.identity.validateDelegation': { const p = paramsObject(rawParams); if (!p.manifest) throw invalidParams('manifest is required'); if (!p.taskId) throw invalidParams('taskId is required'); - const { executeTask } = await import('./exec.js'); - const result = await executeTask(p.manifest, { workflowId: p.workflowId, taskId: p.taskId, dryRun: true, identityDebug: true }); - return responseResult(id, { delegation: result.resolved_identity?.delegation_validation || null }); + const { validateTaskDelegation } = await import('./exec.js'); + const result = await validateTaskDelegation(p.manifest, { + workflowId: p.workflowId, + taskId: p.taskId, + identityDebug: true, + cwd: defaults.cwd || process.cwd(), + env: defaults.env || process.env, + }); + return responseResult(id, { ok: true, delegation: result.delegation || null }); } case 'agentcli.authorization.providers': { const { listAuthorizationProviders } = await import('./authorization/index.js'); await import('./authorization/none.js'); await import('./authorization/opa.js'); - return responseResult(id, { providers: listAuthorizationProviders() }); + return responseResult(id, { ok: true, providers: listAuthorizationProviders() }); } case 'agentcli.authorization.schema': { const p = paramsObject(rawParams); @@ -294,21 +410,26 @@ export async function handleJsonRpcRequest(message, defaults = {}) { await import('./authorization/opa.js'); const authzProvider = getAuthorizationProvider(p.provider); if (!authzProvider) throw invalidParams(`Unknown authorization provider: ${p.provider}`); - return responseResult(id, { provider: p.provider, capabilities: authzProvider.capabilities }); + return responseResult(id, { ok: true, provider: p.provider, capabilities: authzProvider.capabilities }); } case 'agentcli.authorization.evaluate': { const p = paramsObject(rawParams); if (!p.manifest) throw invalidParams('manifest is required'); if (!p.taskId) throw invalidParams('taskId is required'); - const { executeTask } = await import('./exec.js'); - const result = await executeTask(p.manifest, { workflowId: p.workflowId, taskId: p.taskId, dryRun: true, requireAuthorization: true }); - return responseResult(id, { authorization: result.authorization || null }); + const { evaluateTaskAuthorization } = await import('./exec.js'); + const result = await evaluateTaskAuthorization(p.manifest, { + workflowId: p.workflowId, + taskId: p.taskId, + cwd: defaults.cwd || process.cwd(), + env: defaults.env || process.env, + }); + return responseResult(id, { ok: true, authorization: result.authorization || null }); } case 'agentcli.evidence.providers': { const { listEvidenceProviders } = await import('./evidence/index.js'); await import('./evidence/none.js'); await import('./evidence/ssh.js'); - return responseResult(id, { providers: listEvidenceProviders() }); + return responseResult(id, { ok: true, providers: listEvidenceProviders() }); } case 'agentcli.evidence.schema': { const p = paramsObject(rawParams); @@ -318,19 +439,55 @@ export async function handleJsonRpcRequest(message, defaults = {}) { await import('./evidence/ssh.js'); const evProvider = getEvidenceProvider(p.provider); if (!evProvider) throw invalidParams(`Unknown evidence provider: ${p.provider}`); - return responseResult(id, { provider: p.provider, methods: evProvider.methods || [] }); + return responseResult(id, { ok: true, provider: p.provider, methods: evProvider.methods || [] }); } default: - return responseError(id, -32601, `Method not found: ${method}`); + return responseError( + id, + -32601, + `Method not found: ${method}`, + rpcErrorData('unknown_command', 'unknown_command') + ); } } catch (err) { if (err instanceof InvalidParamsError) { - return responseError(id, -32602, err.message, err.data); + return responseError( + id, + -32602, + err.message, + rpcErrorData(err.code, 'invalid_argument', err.data) + ); + } + const normalized = normalizeError(err); + if (normalized.validation) { + return responseError(id, -32602, normalized.message, { + code: 'validation_error', + error_type: 'validation_error', + validation: normalized.validation, + }); + } + if (normalized.error_type === 'invalid_argument' || normalized.error_type === 'parse_error') { + return responseError( + id, + -32602, + normalized.message, + rpcErrorData(normalized.code, normalized.error_type) + ); } - if (err.validation) { - return responseError(id, -32602, err.message, err.validation); + if (normalized.error_type === 'internal_error') { + return responseError( + id, + -32603, + 'Internal error', + rpcErrorData(normalized.code, normalized.error_type) + ); } - return responseError(id, -32000, err?.message || 'Internal error'); + return responseError( + id, + -32000, + normalized.message, + rpcErrorData(normalized.code, normalized.error_type) + ); } } @@ -368,7 +525,12 @@ export async function serveJsonRpc({ input = process.stdin, output = process.std try { parsed = JSON.parse(line); } catch (err) { - safeLine(output, responseError(null, -32700, 'Parse error', err.message)); + safeLine(output, responseError( + null, + -32700, + 'Parse error', + rpcErrorData('parse_error', 'parse_error', err.message) + )); continue; } diff --git a/src/merge.js b/src/merge.js index 050a7fa..7f508f2 100644 --- a/src/merge.js +++ b/src/merge.js @@ -1,4 +1,12 @@ import { validateManifest } from './validate.js'; +import { canonicalStringify } from './canonical.js'; + +const PROFILE_COLLECTIONS = [ + 'identity_profiles', + 'authorization_proof_profiles', + 'authorization_profiles', + 'evidence_profiles', +]; export function mergeManifests(manifests) { if (!Array.isArray(manifests) || manifests.length < 2) { @@ -20,6 +28,8 @@ export function mergeManifests(manifests) { const seenWorkflowIds = new Map(); const mergedWorkflows = []; + const mergedProfiles = Object.fromEntries(PROFILE_COLLECTIONS.map(key => [key, []])); + const seenProfiles = Object.fromEntries(PROFILE_COLLECTIONS.map(key => [key, new Map()])); for (const [index, manifest] of manifests.entries()) { for (const workflow of manifest.workflows) { @@ -34,10 +44,33 @@ export function mergeManifests(manifests) { seenWorkflowIds.set(workflow.id, index); mergedWorkflows.push(structuredClone(workflow)); } + + for (const collection of PROFILE_COLLECTIONS) { + for (const profile of manifest[collection] || []) { + const existing = seenProfiles[collection].get(profile.id); + if (existing) { + if (canonicalStringify(existing.profile) !== canonicalStringify(profile)) { + throw Object.assign( + new Error( + `Conflicting ${collection} id "${profile.id}": appears with different definitions in manifest ${existing.index + 1} and manifest ${index + 1}` + ), + { code: 'validation_error' } + ); + } + continue; + } + const cloned = structuredClone(profile); + seenProfiles[collection].set(profile.id, { profile: cloned, index }); + mergedProfiles[collection].push(cloned); + } + } } const merged = { - version: '0.1', + version: '0.2', + ...Object.fromEntries( + Object.entries(mergedProfiles).filter(([, profiles]) => profiles.length > 0) + ), workflows: mergedWorkflows, }; diff --git a/src/registry.js b/src/registry.js index c38178d..dae3b42 100644 --- a/src/registry.js +++ b/src/registry.js @@ -1,11 +1,30 @@ -import { existsSync, readFileSync, writeFileSync, readdirSync, unlinkSync, mkdirSync } from 'node:fs'; +import { + chmodSync, + closeSync, + constants as fsConstants, + existsSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + readdirSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; import { join, basename, extname } from 'node:path'; import { validateManifest } from './validate.js'; import { getAgentcliPaths } from './home.js'; function registryDir({ env = process.env } = {}) { const paths = getAgentcliPaths({ env }); - mkdirSync(paths.registry, { recursive: true }); + if (existsSync(paths.registry) && lstatSync(paths.registry).isSymbolicLink()) { + throw Object.assign( + new Error('Refusing to use a registry directory that is a symbolic link'), + { code: 'invalid_argument' } + ); + } + mkdirSync(paths.registry, { recursive: true, mode: 0o700 }); + if (process.platform !== 'win32') chmodSync(paths.registry, 0o700); return paths.registry; } @@ -28,6 +47,9 @@ export function listRegistry({ env } = {}) { const name = file.replace(/\.json$/, ''); const filePath = join(dir, file); try { + if (lstatSync(filePath).isSymbolicLink()) { + return { name, workflows: [], parse_error: true, symlink_refused: true }; + } const manifest = JSON.parse(readFileSync(filePath, 'utf8')); const workflows = (manifest.workflows || []).map(w => ({ id: w.id, @@ -57,7 +79,14 @@ export function addToRegistry(manifestOrPath, { name, env, cwd = process.cwd() } ); } - manifest = JSON.parse(readFileSync(resolvedPath, 'utf8')); + try { + manifest = JSON.parse(readFileSync(resolvedPath, 'utf8')); + } catch (error) { + throw Object.assign( + new Error(`Invalid JSON in registry source ${resolvedPath}: ${error.message}`), + { code: 'parse_error' } + ); + } derivedName = basename(resolvedPath, extname(resolvedPath)); } else { manifest = manifestOrPath; @@ -75,10 +104,31 @@ export function addToRegistry(manifestOrPath, { name, env, cwd = process.cwd() } const entryName = name || derivedName; const dir = registryDir({ env }); const filePath = entryPath(dir, entryName); + const overwritten = existsSync(filePath); + if (overwritten && lstatSync(filePath).isSymbolicLink()) { + throw Object.assign( + new Error(`Refusing to overwrite symbolic-link registry entry: "${entryName}"`), + { code: 'invalid_argument' } + ); + } - writeFileSync(filePath, JSON.stringify(manifest, null, 2) + '\n', 'utf8'); + let descriptor; + try { + descriptor = openSync( + filePath, + fsConstants.O_WRONLY | + fsConstants.O_CREAT | + fsConstants.O_TRUNC | + (fsConstants.O_NOFOLLOW || 0), + 0o600 + ); + writeFileSync(descriptor, JSON.stringify(manifest, null, 2) + '\n', 'utf8'); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } + if (process.platform !== 'win32') chmodSync(filePath, 0o600); - return { name: entryName, path: filePath, overwritten: false }; + return { name: entryName, path: filePath, overwritten }; } export function showRegistryEntry(name, { env } = {}) { @@ -91,8 +141,21 @@ export function showRegistryEntry(name, { env } = {}) { { code: 'invalid_argument' } ); } + if (lstatSync(filePath).isSymbolicLink()) { + throw Object.assign( + new Error(`Refusing to read symbolic-link registry entry: "${name}"`), + { code: 'invalid_argument' } + ); + } - return JSON.parse(readFileSync(filePath, 'utf8')); + try { + return JSON.parse(readFileSync(filePath, 'utf8')); + } catch (error) { + throw Object.assign( + new Error(`Invalid JSON in registry entry "${name}": ${error.message}`), + { code: 'parse_error' } + ); + } } export function removeFromRegistry(name, { env } = {}) { diff --git a/src/run.js b/src/run.js index addfbca..1c8a01d 100644 --- a/src/run.js +++ b/src/run.js @@ -250,7 +250,8 @@ function summarizeCounts(tasks, dryRun) { for (const task of tasks) { if (dryRun) { - summary.planned += 1; + if (task.status === 'skipped') summary.skipped += 1; + else summary.planned += 1; continue; } @@ -271,19 +272,33 @@ function selectedTaskWarnings(tasksById, selectedTaskIds) { if (disabledTaskIds.length === 0) return []; return [ - `Local workflow runs do not enforce task.enabled; selected disabled tasks were included explicitly: ${disabledTaskIds.join(', ')}`, + `Disabled tasks and their dependent trigger branches will be skipped: ${disabledTaskIds.join(', ')}`, ]; } +function disabledBlocker(task, tasksById) { + let current = task; + const visited = new Set(); + while (current && !visited.has(current.id)) { + visited.add(current.id); + if (current.enabled === false) return current.id; + current = current.trigger?.parent + ? tasksById.get(current.trigger.parent) + : null; + } + return null; +} + function buildDryRunTasks(workflow, graph, selectedTaskIds, rootTaskIds, cwd) { const rootTaskIdSet = new Set(rootTaskIds); return selectedTaskIds.map(taskId => { const task = graph.tasksById.get(taskId); + const blockedBy = disabledBlocker(task, graph.tasksById); return { source: { workflow_id: workflow.id, task_id: task.id }, name: task.name, - status: 'planned', + status: blockedBy ? 'skipped' : 'planned', selected_as_root: rootTaskIdSet.has(task.id), invocation: invocationSummary(task), command: commandPreview(task, cwd), @@ -294,7 +309,11 @@ function buildDryRunTasks(workflow, graph, selectedTaskIds, rootTaskIds, cwd) { condition: task.trigger.condition ?? null, matched: null, } : null, - reason: task.trigger ? 'dry-run does not evaluate trigger outcomes or conditions' : null, + reason: blockedBy + ? (blockedBy === task.id + ? 'task is disabled' + : `ancestor task "${blockedBy}" is disabled`) + : (task.trigger ? 'dry-run does not evaluate trigger outcomes or conditions' : null), }; }); } @@ -366,6 +385,7 @@ export async function runWorkflow(manifest, { readyAt: Date.now(), sequence: index, triggerContext: null, + skipReason: null, })); let sequence = pending.length; @@ -399,6 +419,29 @@ export async function runWorkflow(manifest, { reason: null, }; + const skipReason = next.skipReason || (task.enabled === false ? 'task is disabled' : null); + if (skipReason) { + record.status = 'skipped'; + record.reason = skipReason; + taskRuns.push(record); + + for (const childId of graph.childrenByParent.get(task.id) || []) { + if (!selectedTaskIdSet.has(childId)) continue; + pending.push({ + taskId: childId, + readyAt: Date.now(), + sequence, + triggerContext: { + matched: false, + parentOutcome: 'skipped', + }, + skipReason: `ancestor task "${task.id}" was skipped: ${skipReason}`, + }); + sequence += 1; + } + continue; + } + let payload = null; try { payload = await executeTask(expanded, { @@ -471,6 +514,7 @@ export async function runWorkflow(manifest, { matched: true, parentOutcome: outcome, }, + skipReason: null, }); sequence += 1; } diff --git a/src/runtime/openclaw-scheduler.js b/src/runtime/openclaw-scheduler.js index cb87a65..234682e 100644 --- a/src/runtime/openclaw-scheduler.js +++ b/src/runtime/openclaw-scheduler.js @@ -6,26 +6,21 @@ */ import { compileManifestToScheduler } from '../compiler/openclaw-scheduler.js'; -import { createSchedulerCliRunner, schedulerCreateSpec } from '../apply.js'; +import { + createSchedulerCliRunner, + negotiateSchedulerFieldVersion, + schedulerCreateSpec, +} from '../apply.js'; import { querySchedulerCapabilities, resolveEffectiveFeatures, validateManifestCapabilities, } from '../capabilities.js'; -const compiledManifestCache = new WeakMap(); - export function compileManifestForDispatch(manifest) { - if (!manifest || typeof manifest !== 'object') { - return compileManifestToScheduler(manifest); - } - - const cached = compiledManifestCache.get(manifest); - if (cached) return cached; - - const compiled = compileManifestToScheduler(manifest); - compiledManifestCache.set(manifest, compiled); - return compiled; + // Manifests are ordinary mutable JavaScript objects. Recompile on every + // dispatch so a caller cannot receive a stale job after an in-place edit. + return compileManifestToScheduler(manifest); } export const schedulerAdapter = { @@ -84,7 +79,7 @@ export const schedulerAdapter = { } // Mark as one-off so the scheduler deletes the job after a single run. - // Spread to avoid mutating the compiler output in case it is cached/reused. + // Spread to avoid mutating the compiler output returned to other callers. const jobSpec = { ...job, delete_after_run: 1 }; if (dryRun) { @@ -111,7 +106,6 @@ export const schedulerAdapter = { const runtimeCaps = querySchedulerCapabilities(runner); const effectiveResult = resolveEffectiveFeatures('openclaw-scheduler', runtimeCaps); - const handoffVersion = effectiveResult.handoff_version || '1'; const { errors: capabilityErrors, warnings: capabilityWarnings, @@ -122,6 +116,10 @@ export const schedulerAdapter = { { code: 'unsupported_capability', capability_errors: capabilityErrors } ); } + const handoffVersion = negotiateSchedulerFieldVersion( + [jobSpec], + effectiveResult.handoff_version || '1' + ); const spec = schedulerCreateSpec(jobSpec, { fieldVersion: handoffVersion }); runner.addJob(spec); diff --git a/src/sandbox.js b/src/sandbox.js index 667f6b1..aadb343 100644 --- a/src/sandbox.js +++ b/src/sandbox.js @@ -1,64 +1,98 @@ -import { spawnSync } from 'node:child_process'; -import { realpathSync } from 'node:fs'; +import { lstatSync, realpathSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { isAbsolute, resolve } from 'node:path'; +import { basename, dirname, isAbsolute, relative, resolve } from 'node:path'; function toAbsolutePath(value, cwd) { if (!value || typeof value !== 'string') return null; - return isAbsolute(value) ? value : resolve(cwd, value); + return resolve(isAbsolute(value) ? value : resolve(cwd, value)); } function uniquePaths(paths) { return [...new Set(paths.filter(Boolean))]; } -function canonicalizePath(pathValue) { - if (!pathValue) return []; - const values = [pathValue]; - try { - values.push(realpathSync(pathValue)); - } catch { - // Ignore paths that do not exist yet; the non-canonical path is still useful. +function isPathWithin(candidate, root) { + const relation = relative(root, candidate); + return relation === '' || (!relation.startsWith('..') && !isAbsolute(relation)); +} + +/** + * Resolve a requested sandbox path through its nearest existing ancestor. + * Existing symlinks are resolved, dangling symlinks fail closed, and missing + * descendants are appended only beneath the real ancestor path. + */ +export function canonicalizeSandboxPath(pathValue, { cwd = process.cwd() } = {}) { + const absolute = toAbsolutePath(pathValue, cwd); + if (!absolute) { + throw Object.assign(new Error('Sandbox path must be a non-empty string'), { + code: 'sandbox_path_invalid', + }); + } + + const missing = []; + let cursor = absolute; + while (true) { + try { + lstatSync(cursor); + } catch (error) { + if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') { + throw Object.assign(new Error('Sandbox path cannot be canonicalized safely'), { + code: 'sandbox_path_invalid', + cause: error, + }); + } + + const parent = dirname(cursor); + if (parent === cursor) { + throw Object.assign(new Error('Sandbox path has no resolvable existing ancestor'), { + code: 'sandbox_path_invalid', + }); + } + missing.push(basename(cursor)); + cursor = parent; + continue; + } + + try { + const canonicalAncestor = realpathSync(cursor); + return resolve(canonicalAncestor, ...missing.reverse()); + } catch (error) { + // lstat succeeded, so an ENOENT here means a dangling symlink rather + // than a merely nonexistent descendant. Never treat it as safe. + throw Object.assign(new Error('Sandbox path resolves through an unsafe or dangling symlink'), { + code: 'sandbox_path_invalid', + cause: error, + }); + } } - return uniquePaths(values); } function escapeSandboxString(value) { return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); } -function commandExists(command) { - if (!command) return false; - if (command.includes('/')) return true; - const result = spawnSync('which', [command], { - encoding: 'utf8', - timeout: 5000, - stdio: ['pipe', 'pipe', 'pipe'], - }); - return result.status === 0; -} - +/** + * Resolve configured sandbox support without spawning a probe process. The + * eventual approved command spawn is the authoritative availability check. + */ export function resolveSandboxSupport({ env = process.env, platform = process.platform, } = {}) { const mode = String(env.AGENTCLI_SANDBOX || '').trim().toLowerCase(); - if (['off', '0', 'false', 'disabled', 'none'].includes(mode)) { - return null; - } - - if (platform === 'darwin') { - const command = env.AGENTCLI_SANDBOX_EXEC || 'sandbox-exec'; - if (commandExists(command)) { - return { kind: 'sandbox-exec', command }; - } - } - - return null; + if (['off', '0', 'false', 'disabled', 'none'].includes(mode)) return null; + if (platform !== 'darwin') return null; + return { + kind: 'sandbox-exec', + command: '/usr/bin/sandbox-exec', + }; } export function needsSandboxEnforcement(contract = {}) { - return contract.sandbox === 'strict' || contract.network === 'restricted' || contract.network === 'none'; + return contract.sandbox === 'strict' || + contract.network === 'restricted' || + contract.network === 'none' || + (Array.isArray(contract.allowed_paths) && contract.allowed_paths.length > 0); } export function buildMacOSSandboxProfile({ @@ -68,8 +102,10 @@ export function buildMacOSSandboxProfile({ } = {}) { const executionCwd = toAbsolutePath(shellCwd || cwd, cwd); const sandboxMode = contract.sandbox || 'none'; + const restrictFilesystem = sandboxMode === 'strict' || + (Array.isArray(contract.allowed_paths) && contract.allowed_paths.length > 0); - if (sandboxMode !== 'strict') { + if (!restrictFilesystem) { const lines = ['(version 1)', '(allow default)']; if (contract.network === 'none') { lines.push('(deny network*)'); @@ -79,10 +115,21 @@ export function buildMacOSSandboxProfile({ return lines.join('\n'); } + const canonicalExecutionCwd = canonicalizeSandboxPath(executionCwd, { cwd }); + const canonicalAllowedPaths = (contract.allowed_paths || []) + .map(path => canonicalizeSandboxPath(path, { cwd })); + if (canonicalAllowedPaths.length > 0 && + !canonicalAllowedPaths.some(root => isPathWithin(canonicalExecutionCwd, root))) { + throw Object.assign( + new Error('Execution working directory resolves outside contract.allowed_paths'), + { code: 'sandbox_path_escape' } + ); + } + const writeRoots = uniquePaths([ - ...canonicalizePath(executionCwd), - ...canonicalizePath(tmpdir()), - ...(contract.allowed_paths || []).flatMap(p => canonicalizePath(toAbsolutePath(p, cwd))), + canonicalExecutionCwd, + canonicalizeSandboxPath(tmpdir(), { cwd }), + ...canonicalAllowedPaths, ]); const lines = [ @@ -105,18 +152,15 @@ export function buildMacOSSandboxProfile({ for (const root of writeRoots) { lines.push(`(allow file-write* (subpath "${escapeSandboxString(root)}"))`); } - return lines.join('\n'); } -export function prepareSandboxedShellCommand(shell, contract, { +export function prepareSandboxedShellCommand(shell, contract = {}, { cwd = process.cwd(), env = process.env, platform = process.platform, } = {}) { const warnings = []; - const support = resolveSandboxSupport({ env, platform }); - if (!needsSandboxEnforcement(contract)) { if (contract.sandbox === 'permissive') { warnings.push('contract.sandbox is "permissive"; execution proceeds without additional OS-level isolation'); @@ -131,47 +175,31 @@ export function prepareSandboxedShellCommand(shell, contract, { }; } + const support = resolveSandboxSupport({ env, platform }); if (!support) { - if (contract.sandbox === 'strict') { - warnings.push('contract.sandbox is "strict" but no supported local sandbox runner is available; execution proceeds without OS-level sandbox enforcement'); - } - if (contract.network === 'none') { - warnings.push('contract.network is "none" but no supported local sandbox runner is available; execution proceeds without OS-level network enforcement'); - } else if (contract.network === 'restricted') { - warnings.push('contract.network is "restricted" but no supported local sandbox runner is available; execution proceeds without OS-level inbound network enforcement'); - } - return { - program: shell.program, - args: shell.args, - warnings, - sandboxed: false, - profile: null, - support: null, - }; + const constraints = []; + if (contract.sandbox === 'strict') constraints.push('strict filesystem/process isolation'); + if (contract.network === 'none') constraints.push('network denial'); + if (contract.network === 'restricted') constraints.push('network restriction'); + throw Object.assign( + new Error(`Required sandbox enforcement is unavailable: ${constraints.join(', ')}`), + { code: 'sandbox_enforcement_unavailable', constraints } + ); } - if (support.kind === 'sandbox-exec') { - const profile = buildMacOSSandboxProfile({ - contract, - cwd, - shellCwd: shell.cwd, + if (support.kind !== 'sandbox-exec') { + throw Object.assign(new Error('Configured sandbox implementation is unsupported'), { + code: 'sandbox_enforcement_unavailable', }); - return { - program: support.command, - args: ['-p', profile, shell.program, ...shell.args], - warnings, - sandboxed: true, - profile, - support, - }; } + const profile = buildMacOSSandboxProfile({ contract, cwd, shellCwd: shell.cwd }); return { - program: shell.program, - args: shell.args, + program: support.command, + args: ['-p', profile, shell.program, ...(shell.args || [])], warnings, - sandboxed: false, - profile: null, - support: null, + sandboxed: true, + profile, + support, }; } diff --git a/src/scheduler-fields.js b/src/scheduler-fields.js index b923e96..0aba7f2 100644 --- a/src/scheduler-fields.js +++ b/src/scheduler-fields.js @@ -40,7 +40,14 @@ export const SCHEDULER_FIELDS_V02 = [ 'verify_on_failure', ]; +export const SCHEDULER_FIELDS_V03 = [ + 'approval_risk_level', + 'approval_approver_scope', + 'output_format', +]; + export const SCHEDULER_FIELD_VERSIONS = { '1': SCHEDULER_FIELDS_V1, '2': [...SCHEDULER_FIELDS_V1, ...SCHEDULER_FIELDS_V02], + '3': [...SCHEDULER_FIELDS_V1, ...SCHEDULER_FIELDS_V02, ...SCHEDULER_FIELDS_V03], }; diff --git a/src/schema.js b/src/schema.js index e49db1b..4506dde 100644 --- a/src/schema.js +++ b/src/schema.js @@ -565,6 +565,11 @@ export const authorizationProofProfileField = { audience: nullableString, jwks_uri: nullableString, public_key: nullableString, + allowed_signers: nullableString, + principal: nullableString, + namespace: nullableString, + ca_certificate: nullableString, + ca_certificate_from: valueFromField, proof: { type: 'object', nullable: true, @@ -820,3 +825,486 @@ Object.assign(MANIFEST_SCHEMA.schedulerJob.fields, { verify_on_failure: nullableString, auth_profile: { type: 'string', nullable: true, note: 'Auth profile ID for scheduler dispatch (e.g. \'anthropic:me.com\'). Scheduler-target only — ignored by other backends.' }, }); + +const JSON_SCHEMA_DIALECT = 'https://json-schema.org/draft/2020-12/schema'; +const IDENTIFIER_PATTERN = '^[A-Za-z0-9][A-Za-z0-9._-]*$'; +const TOKEN_PATTERN = '^[A-Za-z0-9@:_./-]+$'; +const ENV_NAME_PATTERN = '^[A-Za-z_][A-Za-z0-9_]*$'; + +function objectSchema(properties, { required = [], additionalProperties = false, ...extra } = {}) { + return { + type: 'object', + properties, + additionalProperties, + ...(required.length > 0 ? { required } : {}), + ...extra, + }; +} + +function nullableSchema(schema) { + return { anyOf: [schema, { type: 'null' }] }; +} + +const nullableStringSchema = nullableSchema({ type: 'string' }); +const nullableTokenSchema = nullableSchema({ type: 'string', pattern: TOKEN_PATTERN }); +const nullableBooleanSchema = nullableSchema({ type: 'boolean' }); + +const jsonSchemaDefs = { + valueFrom: objectSchema({ + env: nullableStringSchema, + file: nullableStringSchema, + literal: nullableStringSchema, + command: nullableStringSchema, + }, { + anyOf: [ + { required: ['env'] }, + { required: ['file'] }, + { required: ['literal'] }, + { required: ['command'] }, + ], + }), + proofValueFrom: objectSchema({ + env: nullableStringSchema, + file: nullableStringSchema, + command: nullableStringSchema, + }, { + anyOf: [ + { required: ['env'] }, + { required: ['file'] }, + { required: ['command'] }, + ], + }), + target: objectSchema({ + session_target: { type: 'string', enum: ['main', 'isolated', 'shell'] }, + agent_id: nullableTokenSchema, + payload_kind: nullableSchema({ type: 'string', enum: ['systemEvent', 'agentTurn', 'shellCommand'] }), + }, { required: ['session_target'] }), + shell: objectSchema({ + program: { type: 'string', pattern: TOKEN_PATTERN }, + args: nullableSchema({ type: 'array', items: { type: 'string' } }), + env: nullableSchema({ + type: 'object', + propertyNames: { pattern: ENV_NAME_PATTERN }, + additionalProperties: { type: 'string' }, + }), + cwd: nullableStringSchema, + stdin: nullableStringSchema, + }, { required: ['program'] }), + modelPolicy: objectSchema({ + provider: nullableTokenSchema, + model: nullableTokenSchema, + thinking: nullableTokenSchema, + }), + intent: objectSchema({ + mode: nullableSchema({ type: 'string', enum: ['execute', 'plan'] }), + read_only: nullableBooleanSchema, + }), + output: objectSchema({ + preview_bytes: nullableSchema({ type: 'integer', minimum: 64 }), + offload: nullableSchema({ type: 'string', enum: ['auto', 'always', 'never'] }), + retrieve: nullableSchema({ type: 'string', enum: ['inline', 'on-demand'] }), + format: nullableSchema({ type: 'string', enum: ['json', 'ndjson', 'text'] }), + }), + budgets: objectSchema({ + max_iterations: nullableSchema({ type: 'integer', minimum: 1 }), + max_fanout: nullableSchema({ type: 'integer', minimum: 1 }), + max_context_items: nullableSchema({ type: 'integer', minimum: 1 }), + max_pending_approvals: nullableSchema({ type: 'integer', minimum: 1 }), + max_queued_dispatches: nullableSchema({ type: 'integer', minimum: 1 }), + }), + schedule: objectSchema({ + cron: { type: 'string', minLength: 1 }, + tz: nullableStringSchema, + }, { required: ['cron'] }), + trigger: objectSchema({ + parent: { type: 'string', minLength: 1 }, + on: { type: 'string', enum: ['success', 'failure', 'complete'] }, + delay_s: nullableSchema({ type: 'integer', minimum: 0 }), + condition: nullableStringSchema, + }, { required: ['parent', 'on'] }), + delivery: objectSchema({ + mode: nullableSchema({ type: 'string', enum: ['announce', 'announce-always', 'none'] }), + channel: nullableTokenSchema, + to: nullableTokenSchema, + }), + reliability: objectSchema({ + guarantee: nullableSchema({ type: 'string', enum: ['at-most-once', 'at-least-once'] }), + max_retries: nullableSchema({ type: 'integer', minimum: 0 }), + overlap_policy: nullableSchema({ type: 'string', enum: ['skip', 'allow', 'queue'] }), + }), + runtime: objectSchema({ + timeout_ms: nullableSchema({ type: 'integer', minimum: 1 }), + }), + approval: objectSchema({ + required: nullableBooleanSchema, + policy: nullableSchema({ type: 'string', enum: ['manual', 'auto-approve', 'auto-reject'] }), + risk_level: nullableSchema({ type: 'string', enum: ['low', 'medium', 'high'] }), + approver_scope: nullableTokenSchema, + timeout_s: nullableSchema({ type: 'integer', minimum: 1 }), + auto: nullableSchema({ type: 'string', enum: ['approve', 'reject'] }), + }), + context: objectSchema({ + retrieval: nullableSchema({ type: 'string', enum: ['none', 'recent', 'hybrid'] }), + limit: nullableSchema({ type: 'integer', minimum: 1 }), + }), + session: objectSchema({ preferred_key: nullableTokenSchema }), + delegationPolicy: objectSchema({ + max_depth: nullableSchema({ type: 'integer', minimum: 1 }), + allowed_delegators: nullableSchema({ type: 'array', items: { type: 'string' } }), + require_grant_per_hop: nullableBooleanSchema, + }), + subject: objectSchema({ + kind: nullableSchema({ type: 'string', enum: ['agent', 'service', 'workload', 'user', 'composite', 'delegated-agent', 'unknown'] }), + principal: nullableStringSchema, + display_name: nullableStringSchema, + run_as: nullableTokenSchema, + issuer: nullableStringSchema, + delegation_mode: nullableSchema({ type: 'string', enum: ['none', 'on-behalf-of', 'impersonation'] }), + attributes: nullableSchema({ type: 'object', additionalProperties: true }), + }), + auth: objectSchema({ + mode: nullableSchema({ type: 'string', enum: ['none', 'service', 'delegated', 'on-behalf-of', 'impersonation', 'exchange'] }), + scopes: nullableSchema({ type: 'array', items: { type: 'string' } }), + audience: nullableStringSchema, + resource: nullableStringSchema, + cache: nullableSchema({ type: 'string', enum: ['none', 'memory', 'state'] }), + refresh: nullableSchema({ type: 'string', enum: ['never', 'manual', 'auto'] }), + required: nullableBooleanSchema, + delegation_policy: nullableSchema({ $ref: '#/$defs/delegationPolicy' }), + provider_config: nullableSchema({ type: 'object', additionalProperties: true }), + inputs: nullableSchema({ type: 'object', additionalProperties: true }), + }), + trust: objectSchema({ + level: nullableSchema({ type: 'string', enum: ['untrusted', 'restricted', 'supervised', 'autonomous'] }), + constraints: nullableSchema(objectSchema({ + escalation: nullableSchema({ type: 'string', enum: ['fail', 'human-approval', 'log-and-proceed'] }), + max_autonomy: nullableSchema({ type: 'string', enum: ['untrusted', 'restricted', 'supervised', 'autonomous'] }), + escalation_timeout: nullableStringSchema, + require_justification: nullableBooleanSchema, + })), + }), + presentationTarget: objectSchema({ + kind: nullableSchema({ type: 'string', enum: ['env', 'file', 'stdin', 'none'] }), + name: nullableStringSchema, + }), + presentationBinding: objectSchema({ + source: { type: 'string', minLength: 1 }, + target: nullableSchema({ $ref: '#/$defs/presentationTarget' }), + required: nullableBooleanSchema, + redact: nullableBooleanSchema, + format: nullableSchema({ type: 'string', enum: ['raw', 'json', 'base64'] }), + }, { required: ['source'] }), + presentation: objectSchema({ + bindings: nullableSchema({ type: 'array', items: { $ref: '#/$defs/presentationBinding' } }), + handoff: nullableSchema({ type: 'string', enum: ['none', 'downscope', 'transaction-token'] }), + cleanup: nullableSchema({ type: 'string', enum: ['always', 'on-success', 'on-failure', 'never'] }), + default_redaction: nullableBooleanSchema, + }), + identityV1: objectSchema({ + principal: nullableTokenSchema, + run_as: nullableTokenSchema, + attestation: nullableStringSchema, + }), + identityV2: objectSchema({ + ref: nullableStringSchema, + scope: nullableStringSchema, + subject: nullableSchema({ $ref: '#/$defs/subject' }), + auth: nullableSchema({ $ref: '#/$defs/auth' }), + trust: nullableSchema({ $ref: '#/$defs/trust' }), + presentation: nullableSchema({ $ref: '#/$defs/presentation' }), + }), + identity: { + anyOf: [ + { $ref: '#/$defs/identityV1' }, + { $ref: '#/$defs/identityV2' }, + ], + }, + contract: objectSchema({ + sandbox: nullableSchema({ type: 'string', enum: ['none', 'permissive', 'strict'] }), + allowed_paths: nullableSchema({ type: 'array', items: { type: 'string' } }), + network: nullableSchema({ type: 'string', enum: ['unrestricted', 'restricted', 'none'] }), + max_cost_usd: nullableSchema({ type: 'number', minimum: 0 }), + audit: nullableSchema({ type: 'string', enum: ['none', 'on-failure', 'always'] }), + required_trust_level: nullableSchema({ type: 'string', enum: ['untrusted', 'restricted', 'supervised', 'autonomous'] }), + trust_enforcement: nullableSchema({ type: 'string', enum: ['none', 'advisory', 'strict'] }), + }), + authorizationProofRef: objectSchema({ + ref: { type: 'string', minLength: 1 }, + claims: nullableSchema({ type: 'object', additionalProperties: true }), + verify: nullableSchema(objectSchema({ required: nullableBooleanSchema })), + }, { required: ['ref'] }), + authorizationRequest: objectSchema({ + include: nullableSchema({ type: 'array', items: { type: 'string' } }), + }), + authorizationDecision: objectSchema({ + allow_values: nullableSchema({ type: 'array', items: { type: 'string' } }), + deny_values: nullableSchema({ type: 'array', items: { type: 'string' } }), + escalate_values: nullableSchema({ type: 'array', items: { type: 'string' } }), + }), + authorizationRef: objectSchema({ + ref: { type: 'string', minLength: 1 }, + provider_config: nullableSchema({ type: 'object', additionalProperties: true }), + on_error: nullableSchema({ type: 'string', enum: ['deny', 'warn'] }), + request: nullableSchema({ $ref: '#/$defs/authorizationRequest' }), + decision: nullableSchema({ $ref: '#/$defs/authorizationDecision' }), + }, { required: ['ref'] }), + evidencePayload: objectSchema({ + bind: nullableSchema({ type: 'array', items: { type: 'string' } }), + context: nullableSchema({ type: 'object', additionalProperties: true }), + format: nullableSchema({ type: 'string', enum: ['canonical-json', 'json'] }), + }), + evidenceRef: objectSchema({ + ref: nullableStringSchema, + payload: nullableSchema({ $ref: '#/$defs/evidencePayload' }), + verify: nullableSchema(objectSchema({ required: nullableBooleanSchema })), + }), + verify: objectSchema({ + shell: { type: 'string', minLength: 1 }, + timeout_seconds: nullableSchema({ type: 'integer', minimum: 1 }), + on_failure: nullableSchema({ type: 'string', enum: ['error', 'warn'] }), + }, { required: ['shell'] }), + identityProfile: objectSchema({ + id: { type: 'string', pattern: IDENTIFIER_PATTERN }, + provider: { type: 'string', minLength: 1 }, + subject: nullableSchema({ $ref: '#/$defs/subject' }), + auth: nullableSchema({ $ref: '#/$defs/auth' }), + trust: nullableSchema({ $ref: '#/$defs/trust' }), + presentation: nullableSchema({ $ref: '#/$defs/presentation' }), + provider_config: nullableSchema({ type: 'object', additionalProperties: true }), + }, { required: ['id', 'provider'] }), + authorizationProofProfile: objectSchema({ + id: { type: 'string', pattern: IDENTIFIER_PATTERN }, + method: { type: 'string', enum: ['none', 'jwt', 'detached-signature', 'certificate'] }, + issuer: nullableStringSchema, + audience: nullableStringSchema, + jwks_uri: nullableStringSchema, + public_key: nullableStringSchema, + allowed_signers: nullableStringSchema, + principal: nullableStringSchema, + namespace: nullableStringSchema, + ca_certificate: nullableStringSchema, + ca_certificate_from: nullableSchema({ $ref: '#/$defs/valueFrom' }), + proof: nullableSchema(objectSchema({ + value_from: nullableSchema({ $ref: '#/$defs/proofValueFrom' }), + })), + claims: nullableSchema({ type: 'object', additionalProperties: true }), + verify: nullableSchema(objectSchema({ required: nullableBooleanSchema })), + }, { required: ['id', 'method'] }), + authorizationProfile: objectSchema({ + id: { type: 'string', pattern: IDENTIFIER_PATTERN }, + provider: { type: 'string', minLength: 1 }, + provider_config: nullableSchema({ type: 'object', additionalProperties: true }), + on_error: nullableSchema({ type: 'string', enum: ['deny', 'warn'] }), + request: nullableSchema({ $ref: '#/$defs/authorizationRequest' }), + decision: nullableSchema({ $ref: '#/$defs/authorizationDecision' }), + }, { required: ['id', 'provider'] }), + evidenceProfile: objectSchema({ + id: { type: 'string', pattern: IDENTIFIER_PATTERN }, + provider: { type: 'string', minLength: 1 }, + methods: nullableSchema({ type: 'array', items: { type: 'string' } }), + provider_config: nullableSchema({ type: 'object', additionalProperties: true }), + payload: nullableSchema({ $ref: '#/$defs/evidencePayload' }), + verify: nullableSchema(objectSchema({ required: nullableBooleanSchema })), + }, { required: ['id', 'provider'] }), +}; + +const commonExecutionProperties = { + id: { type: 'string', pattern: IDENTIFIER_PATTERN }, + name: { type: 'string', minLength: 1 }, + enabled: nullableBooleanSchema, + prompt: nullableStringSchema, + shell: nullableSchema({ $ref: '#/$defs/shell' }), + target: { $ref: '#/$defs/target' }, + model_policy: nullableSchema({ $ref: '#/$defs/modelPolicy' }), + intent: nullableSchema({ $ref: '#/$defs/intent' }), + output: nullableSchema({ $ref: '#/$defs/output' }), + budgets: nullableSchema({ $ref: '#/$defs/budgets' }), + delivery: nullableSchema({ $ref: '#/$defs/delivery' }), + reliability: nullableSchema({ $ref: '#/$defs/reliability' }), + runtime: nullableSchema({ $ref: '#/$defs/runtime' }), + approval: nullableSchema({ $ref: '#/$defs/approval' }), + context: nullableSchema({ $ref: '#/$defs/context' }), + session: nullableSchema({ $ref: '#/$defs/session' }), + identity: nullableSchema({ $ref: '#/$defs/identity' }), + contract: nullableSchema({ $ref: '#/$defs/contract' }), + authorization_proof: nullableSchema({ $ref: '#/$defs/authorizationProofRef' }), + authorization: nullableSchema({ $ref: '#/$defs/authorizationRef' }), + evidence: nullableSchema({ $ref: '#/$defs/evidenceRef' }), + child_credential_policy: nullableSchema({ type: 'string', enum: ['none', 'inherit', 'downscope', 'independent'] }), + auth_profile: nullableStringSchema, + delete_after_run: nullableBooleanSchema, +}; + +const { + child_credential_policy: _childCredentialPolicy, + auth_profile: _authProfile, + ...onFailureCommonProperties +} = commonExecutionProperties; + +jsonSchemaDefs.onFailure = objectSchema({ + ...onFailureCommonProperties, + delay_s: nullableSchema({ type: 'integer', minimum: 0 }), + condition: nullableStringSchema, +}, { + allOf: [{ + if: { required: ['shell'] }, + then: { + properties: { + shell: { $ref: '#/$defs/shell' }, + prompt: { type: 'null' }, + }, + }, + else: { + required: ['prompt'], + properties: { + prompt: { type: 'string', minLength: 1 }, + shell: { type: 'null' }, + }, + }, + }], +}); + +jsonSchemaDefs.task = objectSchema({ + ...commonExecutionProperties, + schedule: nullableSchema({ $ref: '#/$defs/schedule' }), + trigger: nullableSchema({ $ref: '#/$defs/trigger' }), + verify: nullableSchema({ $ref: '#/$defs/verify' }), + on_failure: nullableSchema({ $ref: '#/$defs/onFailure' }), +}, { + required: ['id', 'name', 'target'], + oneOf: [ + { + required: ['schedule'], + properties: { + schedule: { $ref: '#/$defs/schedule' }, + trigger: { type: 'null' }, + }, + }, + { + required: ['trigger'], + properties: { + trigger: { $ref: '#/$defs/trigger' }, + schedule: { type: 'null' }, + }, + }, + ], + allOf: [{ + if: { + properties: { + target: { + properties: { session_target: { const: 'shell' } }, + required: ['session_target'], + }, + }, + required: ['target'], + }, + then: { + required: ['shell'], + properties: { + shell: { $ref: '#/$defs/shell' }, + prompt: { type: 'null' }, + }, + }, + else: { + required: ['prompt'], + properties: { + prompt: { type: 'string', minLength: 1 }, + shell: { type: 'null' }, + }, + }, + }], +}); + +jsonSchemaDefs.workflow = objectSchema({ + id: { type: 'string', pattern: IDENTIFIER_PATTERN }, + name: { type: 'string', minLength: 1 }, + model_policy: nullableSchema({ $ref: '#/$defs/modelPolicy' }), + identity: nullableSchema({ $ref: '#/$defs/identity' }), + contract: nullableSchema({ $ref: '#/$defs/contract' }), + authorization_proof: nullableSchema({ $ref: '#/$defs/authorizationProofRef' }), + authorization: nullableSchema({ $ref: '#/$defs/authorizationRef' }), + evidence: nullableSchema({ $ref: '#/$defs/evidenceRef' }), + child_credential_policy: nullableSchema({ type: 'string', enum: ['none', 'inherit', 'downscope', 'independent'] }), + verify: nullableSchema({ $ref: '#/$defs/verify' }), + tasks: { type: 'array', minItems: 1, items: { $ref: '#/$defs/task' } }, +}, { required: ['id', 'name', 'tasks'] }); + +export const MANIFEST_JSON_SCHEMA = { + $schema: JSON_SCHEMA_DIALECT, + $id: 'https://github.com/amittell/agentcli/schema/manifest-0.2.json', + title: 'agentcli workflow manifest', + description: 'Draft 2020-12 schema for agentcli v0.1 and v0.2 manifests.', + ...objectSchema({ + version: { type: 'string', enum: ['0.1', MANIFEST_VERSION] }, + identity_profiles: nullableSchema({ type: 'array', items: { $ref: '#/$defs/identityProfile' } }), + authorization_proof_profiles: nullableSchema({ type: 'array', items: { $ref: '#/$defs/authorizationProofProfile' } }), + authorization_profiles: nullableSchema({ type: 'array', items: { $ref: '#/$defs/authorizationProfile' } }), + evidence_profiles: nullableSchema({ type: 'array', items: { $ref: '#/$defs/evidenceProfile' } }), + workflows: { type: 'array', minItems: 1, items: { $ref: '#/$defs/workflow' } }, + }, { required: ['version', 'workflows'] }), + $defs: jsonSchemaDefs, +}; + +function legacyDescriptorToJsonSchema(descriptor) { + if (!descriptor || typeof descriptor !== 'object') return {}; + if (descriptor.removed) return false; + + const result = {}; + if (descriptor.type !== undefined) { + const types = Array.isArray(descriptor.type) ? [...descriptor.type] : [descriptor.type]; + if (descriptor.nullable && !types.includes('null')) types.push('null'); + result.type = types.length === 1 ? types[0] : types; + } + if (descriptor.const !== undefined) result.const = descriptor.const; + if (descriptor.enum) result.enum = [...descriptor.enum]; + if (descriptor.required) result.required = [...descriptor.required]; + if (descriptor.min !== undefined) result.minimum = descriptor.min; + if (descriptor.minItems !== undefined) result.minItems = descriptor.minItems; + if (descriptor.note) result.description = descriptor.note; + if (descriptor.format === 'token') result.pattern = TOKEN_PATTERN; + if (descriptor.fields) { + result.properties = Object.fromEntries( + Object.entries(descriptor.fields).map(([name, value]) => [name, legacyDescriptorToJsonSchema(value)]) + ); + result.additionalProperties = false; + } + if (descriptor.items) result.items = legacyDescriptorToJsonSchema(descriptor.items); + if (descriptor.values) result.additionalProperties = legacyDescriptorToJsonSchema(descriptor.values); + return result; +} + +function fragmentSchema(definitionName, title) { + return { + $schema: JSON_SCHEMA_DIALECT, + title, + $ref: `#/$defs/${definitionName}`, + $defs: jsonSchemaDefs, + }; +} + +export const JSON_SCHEMAS = Object.freeze({ + manifest: MANIFEST_JSON_SCHEMA, + workflow: fragmentSchema('workflow', 'agentcli workflow'), + task: fragmentSchema('task', 'agentcli task'), + schedulerJob: { + $schema: JSON_SCHEMA_DIALECT, + title: 'openclaw-scheduler compiled job', + ...legacyDescriptorToJsonSchema(MANIFEST_SCHEMA.schedulerJob), + }, + standalonePlan: { + $schema: JSON_SCHEMA_DIALECT, + title: 'agentcli standalone compiled plan', + ...legacyDescriptorToJsonSchema(MANIFEST_SCHEMA.standalonePlan), + }, + rpcRequest: { + $schema: JSON_SCHEMA_DIALECT, + title: 'agentcli JSON-RPC request', + ...legacyDescriptorToJsonSchema(MANIFEST_SCHEMA.rpcRequest), + }, + rpcResponse: { + $schema: JSON_SCHEMA_DIALECT, + title: 'agentcli JSON-RPC response', + ...legacyDescriptorToJsonSchema(MANIFEST_SCHEMA.rpcResponse), + oneOf: [{ required: ['result'] }, { required: ['error'] }], + }, +}); diff --git a/src/signing/ssh.js b/src/signing/ssh.js index 60fd9c4..b7e550a 100644 --- a/src/signing/ssh.js +++ b/src/signing/ssh.js @@ -1,5 +1,16 @@ import { spawnSync } from 'node:child_process'; -import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs'; +import { + chmodSync, + closeSync, + constants as fsConstants, + existsSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; import { homedir } from 'node:os'; import { join, dirname } from 'node:path'; import { tmpdir } from 'node:os'; @@ -96,9 +107,9 @@ export function signPayload(payload, { keyPath }) { export function resolveAllowedSigners({ env = process.env, statePath } = {}) { const explicit = env.AGENTCLI_ALLOWED_SIGNERS; - if (explicit && existsSync(explicit)) return explicit; + if (explicit && existsSync(explicit) && lstatSync(explicit).isFile()) return explicit; - if (statePath && existsSync(statePath)) return statePath; + if (statePath && existsSync(statePath) && lstatSync(statePath).isFile()) return statePath; return null; } @@ -117,8 +128,24 @@ export function generateAllowedSigners({ principal, homeDir = homedir(), outputP if (lines.length === 0) return null; - mkdirSync(dirname(outputPath), { recursive: true }); - writeFileSync(outputPath, lines.join('\n') + '\n', 'utf8'); + const outputDirectory = dirname(outputPath); + mkdirSync(outputDirectory, { recursive: true, mode: 0o700 }); + if (process.platform !== 'win32') chmodSync(outputDirectory, 0o700); + let descriptor; + try { + descriptor = openSync( + outputPath, + fsConstants.O_WRONLY | + fsConstants.O_CREAT | + fsConstants.O_TRUNC | + (fsConstants.O_NOFOLLOW || 0), + 0o600 + ); + writeFileSync(descriptor, lines.join('\n') + '\n', 'utf8'); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } + if (process.platform !== 'win32') chmodSync(outputPath, 0o600); return outputPath; } diff --git a/src/targets.js b/src/targets.js index a732d55..ef61ce5 100644 --- a/src/targets.js +++ b/src/targets.js @@ -1,29 +1,12 @@ import { compileManifestToScheduler } from './compiler/openclaw-scheduler.js'; -import { compileManifestToStandalone } from './compiler/standalone.js'; +import { compileManifestToStandalone, STANDALONE_FEATURES } from './compiler/standalone.js'; export const TARGETS = { standalone: { name: 'standalone', description: 'Portable execution plan for authoring, validation, and protocol use without a bound runtime.', capabilities: ['schema', 'validate', 'compile', 'describe', 'json-rpc'], - features: { - approvals: 'intent-only', - model_policy: 'portable', - execution_intent: 'portable', - output_hints: 'portable', - timeout_support: 'portable', - context_retrieval: 'portable', - runtime_execution: false, - identity_declaration: true, - runtime_identity_resolution: false, - evidence_generation: false, - audit_export: false, - trust_evaluation: false, - delegation_validation: false, - credential_handoff: false, - authorization_proof_verification: false, - authorization_hook: false, - }, + features: { ...STANDALONE_FEATURES }, compile: compileManifestToStandalone, }, 'openclaw-scheduler': { @@ -50,6 +33,9 @@ export const TARGETS = { credential_handoff: false, authorization_proof_verification: false, authorization_hook: false, + root_approval_gate: false, + approval_scope_enforcement: false, + structured_output_format: false, }, compile: compileManifestToScheduler, }, diff --git a/src/validate.js b/src/validate.js index bd03165..25d38fd 100644 --- a/src/validate.js +++ b/src/validate.js @@ -1,5 +1,27 @@ import { MANIFEST_VERSION } from './schema.js'; import { onFailureTaskId } from './shorthand.js'; +import { getProvider as getIdentityProvider } from './identity/index.js'; +import { getVerifier } from './authorization-proof/index.js'; +import { getAuthorizationProvider } from './authorization/index.js'; +import { getEvidenceProvider } from './evidence/index.js'; +import './identity/none.js'; +import './identity/env-bearer.js'; +import './identity/file-bearer.js'; +import './identity/oidc-client-credentials.js'; +import './identity/oidc-token-exchange.js'; +import './identity/azure-managed-identity.js'; +import './identity/aws-sts-assume-role.js'; +import './identity/gcp-workload-identity.js'; +import './identity/spiffe-jwt-svid.js'; +import './identity/entra-agent-id.js'; +import './authorization-proof/none.js'; +import './authorization-proof/jwt.js'; +import './authorization-proof/detached-signature.js'; +import './authorization-proof/certificate.js'; +import './authorization/none.js'; +import './authorization/opa.js'; +import './evidence/none.js'; +import './evidence/ssh.js'; const SUPPORTED_VERSIONS = ['0.1', MANIFEST_VERSION]; const TRUST_LEVELS = ['untrusted', 'restricted', 'supervised', 'autonomous']; @@ -37,6 +59,51 @@ const KNOWN_ON_FAILURE_KEYS = new Set([ 'authorization_proof', 'authorization', 'evidence' ]); +const V2_KEYS = Object.freeze({ + target: new Set(['session_target', 'agent_id', 'payload_kind']), + shell: new Set(['program', 'args', 'env', 'cwd', 'stdin']), + modelPolicy: new Set(['provider', 'model', 'thinking']), + intent: new Set(['mode', 'read_only']), + output: new Set(['preview_bytes', 'offload', 'retrieve', 'format']), + budgets: new Set(['max_iterations', 'max_fanout', 'max_context_items', 'max_pending_approvals', 'max_queued_dispatches']), + schedule: new Set(['cron', 'tz']), + trigger: new Set(['parent', 'on', 'delay_s', 'condition']), + delivery: new Set(['mode', 'channel', 'to']), + reliability: new Set(['guarantee', 'max_retries', 'overlap_policy']), + runtime: new Set(['timeout_ms']), + approval: new Set(['required', 'policy', 'risk_level', 'approver_scope', 'timeout_s', 'auto']), + context: new Set(['retrieval', 'limit']), + session: new Set(['preferred_key']), + subject: new Set(['kind', 'principal', 'display_name', 'run_as', 'issuer', 'delegation_mode', 'attributes']), + auth: new Set(['mode', 'scopes', 'audience', 'resource', 'cache', 'refresh', 'required', 'delegation_policy', 'provider_config', 'inputs']), + delegationPolicy: new Set(['max_depth', 'allowed_delegators', 'require_grant_per_hop']), + trust: new Set(['level', 'constraints']), + trustConstraints: new Set(['escalation', 'max_autonomy', 'escalation_timeout', 'require_justification']), + presentation: new Set(['bindings', 'handoff', 'cleanup', 'default_redaction']), + presentationBinding: new Set(['source', 'target', 'required', 'redact', 'format']), + presentationTarget: new Set(['kind', 'name']), + identity: new Set(['ref', 'scope', 'subject', 'auth', 'trust', 'presentation']), + contract: new Set(['sandbox', 'allowed_paths', 'network', 'max_cost_usd', 'audit', 'required_trust_level', 'trust_enforcement']), + authorizationProofRef: new Set(['ref', 'claims', 'verify']), + authorizationRef: new Set(['ref', 'provider_config', 'on_error', 'request', 'decision']), + authorizationRequest: new Set(['include']), + authorizationDecision: new Set(['allow_values', 'deny_values', 'escalate_values']), + evidenceRef: new Set(['ref', 'payload', 'verify']), + evidencePayload: new Set(['bind', 'context', 'format']), + requiredVerify: new Set(['required']), + verify: new Set(['shell', 'timeout_seconds', 'on_failure']), + valueFrom: new Set(['env', 'file', 'literal', 'command']), + proof: new Set(['value_from']), + identityProfile: new Set(['id', 'provider', 'subject', 'auth', 'trust', 'presentation', 'provider_config']), + authorizationProofProfile: new Set([ + 'id', 'method', 'issuer', 'audience', 'jwks_uri', 'public_key', + 'allowed_signers', 'principal', 'namespace', 'ca_certificate', 'ca_certificate_from', + 'proof', 'claims', 'verify' + ]), + authorizationProfile: new Set(['id', 'provider', 'provider_config', 'on_error', 'request', 'decision']), + evidenceProfile: new Set(['id', 'provider', 'methods', 'provider_config', 'payload', 'verify']), +}); + function isObject(value) { return value && typeof value === 'object' && !Array.isArray(value); } @@ -54,6 +121,15 @@ function checkUnknownKeys(warnings, path, value, knownKeys) { } } +function rejectUnknownKeys(errors, path, value, knownKeys) { + if (!isObject(value)) return; + for (const key of Object.keys(value)) { + if (!knownKeys.has(key)) { + addError(errors, `${path}.${key}`, `unknown key "${key}" is not allowed in a v0.2 manifest`); + } + } +} + function hasUnsupportedControlChars(value) { for (const char of value) { const code = char.charCodeAt(0); @@ -307,6 +383,12 @@ function validateAuth(errors, path, value) { checkEnum(errors, `${path}.cache`, value.cache, ['none', 'memory', 'state']); checkEnum(errors, `${path}.refresh`, value.refresh, ['never', 'manual', 'auto']); checkBoolean(errors, `${path}.required`, value.required); + if (value.provider_config != null && !isObject(value.provider_config)) { + addError(errors, `${path}.provider_config`, 'must be an object'); + } + if (value.inputs != null && !isObject(value.inputs)) { + addError(errors, `${path}.inputs`, 'must be an object'); + } if (checkOptionalObject(errors, `${path}.delegation_policy`, value.delegation_policy)) { validateDelegationPolicy(errors, `${path}.delegation_policy`, value.delegation_policy); } @@ -637,7 +719,7 @@ function validateVerify(errors, path, value) { checkEnum(errors, `${path}.on_failure`, value.on_failure, ['error', 'warn']); } -function validateOnFailure(errors, warnings, path, task) { +function validateOnFailure(errors, warnings, path, task, { strictUnknown = false } = {}) { if (task.on_failure == null) return; if (!isObject(task.on_failure)) { addError(errors, path, 'must be an object'); @@ -645,7 +727,7 @@ function validateOnFailure(errors, warnings, path, task) { } const handler = task.on_failure; - checkUnknownKeys(warnings, path, handler, KNOWN_ON_FAILURE_KEYS); + if (!strictUnknown) checkUnknownKeys(warnings, path, handler, KNOWN_ON_FAILURE_KEYS); checkIdentifier(errors, `${path}.id`, handler.id, { required: false }); checkString(errors, `${path}.name`, handler.name, { required: false }); checkBoolean(errors, `${path}.enabled`, handler.enabled); @@ -661,6 +743,253 @@ function validateOnFailure(errors, warnings, path, task) { validateOptionalBlocks(errors, warnings, path, handler); } +function validateV2IdentityUnknownKeys(errors, path, identity) { + rejectUnknownKeys(errors, path, identity, V2_KEYS.identity); + if (!isObject(identity)) return; + + rejectUnknownKeys(errors, `${path}.subject`, identity.subject, V2_KEYS.subject); + rejectUnknownKeys(errors, `${path}.auth`, identity.auth, V2_KEYS.auth); + if (isObject(identity.auth)) { + rejectUnknownKeys( + errors, + `${path}.auth.delegation_policy`, + identity.auth.delegation_policy, + V2_KEYS.delegationPolicy + ); + } + rejectUnknownKeys(errors, `${path}.trust`, identity.trust, V2_KEYS.trust); + if (isObject(identity.trust)) { + rejectUnknownKeys( + errors, + `${path}.trust.constraints`, + identity.trust.constraints, + V2_KEYS.trustConstraints + ); + } + rejectUnknownKeys(errors, `${path}.presentation`, identity.presentation, V2_KEYS.presentation); + if (Array.isArray(identity.presentation?.bindings)) { + for (const [index, binding] of identity.presentation.bindings.entries()) { + const bindingPath = `${path}.presentation.bindings[${index}]`; + rejectUnknownKeys(errors, bindingPath, binding, V2_KEYS.presentationBinding); + if (isObject(binding)) { + rejectUnknownKeys(errors, `${bindingPath}.target`, binding.target, V2_KEYS.presentationTarget); + } + } + } +} + +function validateV2AuthorizationProofUnknownKeys(errors, path, declaration) { + rejectUnknownKeys(errors, path, declaration, V2_KEYS.authorizationProofRef); + if (isObject(declaration)) { + rejectUnknownKeys(errors, `${path}.verify`, declaration.verify, V2_KEYS.requiredVerify); + } +} + +function validateV2AuthorizationUnknownKeys(errors, path, declaration) { + rejectUnknownKeys(errors, path, declaration, V2_KEYS.authorizationRef); + if (!isObject(declaration)) return; + rejectUnknownKeys(errors, `${path}.request`, declaration.request, V2_KEYS.authorizationRequest); + rejectUnknownKeys(errors, `${path}.decision`, declaration.decision, V2_KEYS.authorizationDecision); +} + +function validateV2EvidenceUnknownKeys(errors, path, declaration) { + rejectUnknownKeys(errors, path, declaration, V2_KEYS.evidenceRef); + if (!isObject(declaration)) return; + rejectUnknownKeys(errors, `${path}.payload`, declaration.payload, V2_KEYS.evidencePayload); + rejectUnknownKeys(errors, `${path}.verify`, declaration.verify, V2_KEYS.requiredVerify); +} + +function validateV2CommonUnknownKeys(errors, path, value) { + if (!isObject(value)) return; + rejectUnknownKeys(errors, `${path}.target`, value.target, V2_KEYS.target); + rejectUnknownKeys(errors, `${path}.shell`, value.shell, V2_KEYS.shell); + rejectUnknownKeys(errors, `${path}.model_policy`, value.model_policy, V2_KEYS.modelPolicy); + rejectUnknownKeys(errors, `${path}.intent`, value.intent, V2_KEYS.intent); + rejectUnknownKeys(errors, `${path}.output`, value.output, V2_KEYS.output); + rejectUnknownKeys(errors, `${path}.budgets`, value.budgets, V2_KEYS.budgets); + rejectUnknownKeys(errors, `${path}.delivery`, value.delivery, V2_KEYS.delivery); + rejectUnknownKeys(errors, `${path}.reliability`, value.reliability, V2_KEYS.reliability); + rejectUnknownKeys(errors, `${path}.runtime`, value.runtime, V2_KEYS.runtime); + rejectUnknownKeys(errors, `${path}.approval`, value.approval, V2_KEYS.approval); + rejectUnknownKeys(errors, `${path}.context`, value.context, V2_KEYS.context); + rejectUnknownKeys(errors, `${path}.session`, value.session, V2_KEYS.session); + validateV2IdentityUnknownKeys(errors, `${path}.identity`, value.identity); + rejectUnknownKeys(errors, `${path}.contract`, value.contract, V2_KEYS.contract); + validateV2AuthorizationProofUnknownKeys(errors, `${path}.authorization_proof`, value.authorization_proof); + validateV2AuthorizationUnknownKeys(errors, `${path}.authorization`, value.authorization); + validateV2EvidenceUnknownKeys(errors, `${path}.evidence`, value.evidence); + rejectUnknownKeys(errors, `${path}.verify`, value.verify, V2_KEYS.verify); +} + +function validateV2UnknownKeys(errors, manifest) { + rejectUnknownKeys(errors, '$', manifest, KNOWN_MANIFEST_KEYS); + + if (Array.isArray(manifest.identity_profiles)) { + for (const [index, profile] of manifest.identity_profiles.entries()) { + const path = `$.identity_profiles[${index}]`; + rejectUnknownKeys(errors, path, profile, V2_KEYS.identityProfile); + if (!isObject(profile)) continue; + validateV2IdentityUnknownKeys(errors, path, { + subject: profile.subject, + auth: profile.auth, + trust: profile.trust, + presentation: profile.presentation, + }); + } + } + + if (Array.isArray(manifest.authorization_proof_profiles)) { + for (const [index, profile] of manifest.authorization_proof_profiles.entries()) { + const path = `$.authorization_proof_profiles[${index}]`; + rejectUnknownKeys(errors, path, profile, V2_KEYS.authorizationProofProfile); + if (!isObject(profile)) continue; + rejectUnknownKeys(errors, `${path}.proof`, profile.proof, V2_KEYS.proof); + if (isObject(profile.proof)) { + rejectUnknownKeys(errors, `${path}.proof.value_from`, profile.proof.value_from, V2_KEYS.valueFrom); + } + rejectUnknownKeys(errors, `${path}.ca_certificate_from`, profile.ca_certificate_from, V2_KEYS.valueFrom); + rejectUnknownKeys(errors, `${path}.verify`, profile.verify, V2_KEYS.requiredVerify); + } + } + + if (Array.isArray(manifest.authorization_profiles)) { + for (const [index, profile] of manifest.authorization_profiles.entries()) { + const path = `$.authorization_profiles[${index}]`; + rejectUnknownKeys(errors, path, profile, V2_KEYS.authorizationProfile); + if (!isObject(profile)) continue; + rejectUnknownKeys(errors, `${path}.request`, profile.request, V2_KEYS.authorizationRequest); + rejectUnknownKeys(errors, `${path}.decision`, profile.decision, V2_KEYS.authorizationDecision); + } + } + + if (Array.isArray(manifest.evidence_profiles)) { + for (const [index, profile] of manifest.evidence_profiles.entries()) { + const path = `$.evidence_profiles[${index}]`; + rejectUnknownKeys(errors, path, profile, V2_KEYS.evidenceProfile); + if (!isObject(profile)) continue; + rejectUnknownKeys(errors, `${path}.payload`, profile.payload, V2_KEYS.evidencePayload); + rejectUnknownKeys(errors, `${path}.verify`, profile.verify, V2_KEYS.requiredVerify); + } + } + + if (!Array.isArray(manifest.workflows)) return; + for (const [workflowIndex, workflow] of manifest.workflows.entries()) { + const workflowPath = `$.workflows[${workflowIndex}]`; + rejectUnknownKeys(errors, workflowPath, workflow, KNOWN_WORKFLOW_KEYS); + if (!isObject(workflow)) continue; + validateV2CommonUnknownKeys(errors, workflowPath, workflow); + + if (!Array.isArray(workflow.tasks)) continue; + for (const [taskIndex, task] of workflow.tasks.entries()) { + const taskPath = `${workflowPath}.tasks[${taskIndex}]`; + rejectUnknownKeys(errors, taskPath, task, KNOWN_TASK_KEYS); + if (!isObject(task)) continue; + validateV2CommonUnknownKeys(errors, taskPath, task); + rejectUnknownKeys(errors, `${taskPath}.schedule`, task.schedule, V2_KEYS.schedule); + rejectUnknownKeys(errors, `${taskPath}.trigger`, task.trigger, V2_KEYS.trigger); + if (isObject(task.on_failure)) { + const failurePath = `${taskPath}.on_failure`; + rejectUnknownKeys(errors, failurePath, task.on_failure, KNOWN_ON_FAILURE_KEYS); + validateV2CommonUnknownKeys(errors, failurePath, task.on_failure); + } + } + } +} + +const STRUCTURAL_PROVIDER_CONTEXT = Object.freeze({ + structural: true, + allowInsecure: false, + resolveCredentials: false, + performIo: false, +}); + +function applyStructuralProfileValidation(errors, path, profile, component) { + if (typeof component?.validateProfile !== 'function') return; + + let result; + try { + result = component.validateProfile(profile, STRUCTURAL_PROVIDER_CONTEXT); + } catch (err) { + addError(errors, path, `provider structural validation failed: ${err.message}`); + return; + } + + if (result && typeof result.then === 'function') { + addError(errors, path, 'provider validateProfile must be synchronous and must not perform I/O'); + return; + } + if (!result || result.valid !== false) return; + + const providerErrors = Array.isArray(result.errors) && result.errors.length > 0 + ? result.errors + : ['provider rejected the profile']; + for (const providerError of providerErrors) { + if (typeof providerError === 'string') { + addError(errors, path, providerError); + continue; + } + const field = typeof providerError?.field === 'string' && providerError.field + ? `.${providerError.field}` + : ''; + addError(errors, `${path}${field}`, providerError?.message || 'provider rejected the profile'); + } +} + +function validateV2ProfileProviders(errors, manifest) { + const identityProfiles = Array.isArray(manifest.identity_profiles) ? manifest.identity_profiles : []; + const proofProfiles = Array.isArray(manifest.authorization_proof_profiles) + ? manifest.authorization_proof_profiles + : []; + const authorizationProfiles = Array.isArray(manifest.authorization_profiles) + ? manifest.authorization_profiles + : []; + const evidenceProfiles = Array.isArray(manifest.evidence_profiles) ? manifest.evidence_profiles : []; + + for (const [index, profile] of identityProfiles.entries()) { + if (!isObject(profile) || typeof profile.provider !== 'string' || !profile.provider) continue; + const path = `$.identity_profiles[${index}]`; + const provider = getIdentityProvider(profile.provider); + if (!provider) { + addError(errors, `${path}.provider`, `unknown identity provider "${profile.provider}"`); + continue; + } + applyStructuralProfileValidation(errors, path, profile, provider); + } + + for (const [index, profile] of proofProfiles.entries()) { + if (!isObject(profile) || typeof profile.method !== 'string' || !profile.method) continue; + const path = `$.authorization_proof_profiles[${index}]`; + const verifier = getVerifier(profile.method); + if (!verifier) { + addError(errors, `${path}.method`, `unknown authorization proof verifier "${profile.method}"`); + continue; + } + applyStructuralProfileValidation(errors, path, profile, verifier); + } + + for (const [index, profile] of authorizationProfiles.entries()) { + if (!isObject(profile) || typeof profile.provider !== 'string' || !profile.provider) continue; + const path = `$.authorization_profiles[${index}]`; + const provider = getAuthorizationProvider(profile.provider); + if (!provider) { + addError(errors, `${path}.provider`, `unknown authorization provider "${profile.provider}"`); + continue; + } + applyStructuralProfileValidation(errors, path, profile, provider); + } + + for (const [index, profile] of evidenceProfiles.entries()) { + if (!isObject(profile) || typeof profile.provider !== 'string' || !profile.provider) continue; + const path = `$.evidence_profiles[${index}]`; + const provider = getEvidenceProvider(profile.provider); + if (!provider) { + addError(errors, `${path}.provider`, `unknown evidence provider "${profile.provider}"`); + continue; + } + applyStructuralProfileValidation(errors, path, profile, provider); + } +} + function compareTrustLevels(a, b) { const indexA = TRUST_LEVELS.indexOf(a); const indexB = TRUST_LEVELS.indexOf(b); @@ -714,7 +1043,9 @@ export function validateManifest(manifest) { addError(errors, '$.version', `must be one of: ${SUPPORTED_VERSIONS.join(', ')}`); } - checkUnknownKeys(warnings, '$', manifest, KNOWN_MANIFEST_KEYS); + if (manifest.version !== MANIFEST_VERSION) { + checkUnknownKeys(warnings, '$', manifest, KNOWN_MANIFEST_KEYS); + } // Validate v0.2 profile arrays if (manifest.identity_profiles != null) { @@ -727,6 +1058,9 @@ export function validateManifest(manifest) { if (!isObject(profile)) { addError(errors, pp, 'must be an object'); continue; } checkIdentifier(errors, `${pp}.id`, profile.id); checkString(errors, `${pp}.provider`, profile.provider); + if (profile.provider_config != null && !isObject(profile.provider_config)) { + addError(errors, `${pp}.provider_config`, 'must be an object'); + } if (profile.id) { if (profileIds.has(profile.id)) addError(errors, `${pp}.id`, 'must be unique'); profileIds.add(profile.id); @@ -765,9 +1099,21 @@ export function validateManifest(manifest) { checkString(errors, `${pp}.audience`, profile.audience, { required: false }); checkString(errors, `${pp}.jwks_uri`, profile.jwks_uri, { required: false }); checkString(errors, `${pp}.public_key`, profile.public_key, { required: false }); + checkString(errors, `${pp}.allowed_signers`, profile.allowed_signers, { required: false }); + checkString(errors, `${pp}.principal`, profile.principal, { required: false }); + checkString(errors, `${pp}.namespace`, profile.namespace, { required: false }); + checkString(errors, `${pp}.ca_certificate`, profile.ca_certificate, { required: false }); + if (checkOptionalObject(errors, `${pp}.ca_certificate_from`, profile.ca_certificate_from)) { + validateValueFrom(errors, `${pp}.ca_certificate_from`, profile.ca_certificate_from); + } if (checkOptionalObject(errors, `${pp}.proof`, profile.proof)) { if (checkOptionalObject(errors, `${pp}.proof.value_from`, profile.proof.value_from)) { - validateValueFrom(errors, `${pp}.proof.value_from`, profile.proof.value_from); + validateValueFrom( + errors, + `${pp}.proof.value_from`, + profile.proof.value_from, + { allowLiteral: false } + ); } } if (profile.claims != null && !isObject(profile.claims)) { @@ -851,7 +1197,9 @@ export function validateManifest(manifest) { addError(errors, workflowPath, 'must be an object'); continue; } - checkUnknownKeys(warnings, workflowPath, workflow, KNOWN_WORKFLOW_KEYS); + if (manifest.version !== MANIFEST_VERSION) { + checkUnknownKeys(warnings, workflowPath, workflow, KNOWN_WORKFLOW_KEYS); + } checkIdentifier(errors, `${workflowPath}.id`, workflow.id); checkString(errors, `${workflowPath}.name`, workflow.name); if (checkOptionalObject(errors, `${workflowPath}.model_policy`, workflow.model_policy)) { @@ -893,7 +1241,9 @@ export function validateManifest(manifest) { continue; } - checkUnknownKeys(warnings, taskPath, task, KNOWN_TASK_KEYS); + if (manifest.version !== MANIFEST_VERSION) { + checkUnknownKeys(warnings, taskPath, task, KNOWN_TASK_KEYS); + } checkIdentifier(errors, `${taskPath}.id`, task.id); checkString(errors, `${taskPath}.name`, task.name); checkBoolean(errors, `${taskPath}.enabled`, task.enabled); @@ -951,7 +1301,13 @@ export function validateManifest(manifest) { message: 'shell targets do not get a first-class planning boundary in every backend; intent may be advisory only' }); } - validateOnFailure(errors, warnings, `${taskPath}.on_failure`, task); + validateOnFailure( + errors, + warnings, + `${taskPath}.on_failure`, + task, + { strictUnknown: manifest.version === MANIFEST_VERSION } + ); } const validTaskIds = new Set(workflow.tasks.filter(isObject).map(task => task.id).filter(Boolean)); @@ -994,12 +1350,25 @@ export function validateManifest(manifest) { } } + if (manifest.version === MANIFEST_VERSION) { + validateV2UnknownKeys(errors, manifest); + validateV2ProfileProviders(errors, manifest); + } + // Cross-reference validation: verify ref targets exist in profile arrays - const identityProfileIds = new Set((manifest.identity_profiles || []).filter(p => p.id).map(p => p.id)); - const identityProfilesById = new Map((manifest.identity_profiles || []).filter(p => p.id).map(p => [p.id, p])); - const proofProfileIds = new Set((manifest.authorization_proof_profiles || []).filter(p => p.id).map(p => p.id)); - const authzProfileIds = new Set((manifest.authorization_profiles || []).filter(p => p.id).map(p => p.id)); - const evidProfileIds = new Set((manifest.evidence_profiles || []).filter(p => p.id).map(p => p.id)); + const identityProfiles = Array.isArray(manifest.identity_profiles) ? manifest.identity_profiles : []; + const proofProfiles = Array.isArray(manifest.authorization_proof_profiles) + ? manifest.authorization_proof_profiles + : []; + const authorizationProfiles = Array.isArray(manifest.authorization_profiles) + ? manifest.authorization_profiles + : []; + const evidenceProfiles = Array.isArray(manifest.evidence_profiles) ? manifest.evidence_profiles : []; + const identityProfileIds = new Set(identityProfiles.filter(p => isObject(p) && p.id).map(p => p.id)); + const identityProfilesById = new Map(identityProfiles.filter(p => isObject(p) && p.id).map(p => [p.id, p])); + const proofProfileIds = new Set(proofProfiles.filter(p => isObject(p) && p.id).map(p => p.id)); + const authzProfileIds = new Set(authorizationProfiles.filter(p => isObject(p) && p.id).map(p => p.id)); + const evidProfileIds = new Set(evidenceProfiles.filter(p => isObject(p) && p.id).map(p => p.id)); const trustSatisfiabilityErrors = new Set(); function checkRef(refPath, ref, profileSet, profileType) { diff --git a/test/agentcli.test.js b/test/agentcli.test.js index 6449b3e..54003f4 100644 --- a/test/agentcli.test.js +++ b/test/agentcli.test.js @@ -65,6 +65,7 @@ import { getProvider as getIdentityProvider, } from '../src/identity/index.js'; import { buildActorContext, buildStepUpContext } from '../src/actor-context.js'; +import { canonicalDigest } from '../src/canonical.js'; function readExample(name) { return JSON.parse(readFileSync(new URL(`../examples/${name}`, import.meta.url), 'utf8')); @@ -94,6 +95,13 @@ function signedJwt(payload) { return `${header}.${body}.${signature}`; } +function signedManifestJwt(manifest, payload = {}) { + return signedJwt({ + ...payload, + manifest_digest: canonicalDigest(manifest), + }); +} + const exampleManifest = readExample('hello-world.json'); const shellManifest = readExample('shell-workflow.json'); const publicBotHealthManifest = readExample('public-bot-health.json'); @@ -395,7 +403,7 @@ test('shell workflow validates and carries policy-based approval intent', () => assert.equal(followup.trigger_condition, 'regex:(9[0-9]%|100%)'); }); -test('structured shell execution is preserved in standalone plans and rendered for scheduler jobs', () => { +test('standalone plans hash sensitive shell inputs and scheduler compilation rejects durable disclosure', () => { const manifest = { version: '0.1', workflows: [ @@ -428,20 +436,24 @@ test('structured shell execution is preserved in standalone plans and rendered f const validation = validateManifest(manifest); assert.equal(validation.ok, true); - const schedulerCompiled = compileManifestToScheduler(manifest); - assert.equal(schedulerCompiled.jobs[0].payload_message, 'cd \'/tmp/work dir\' && printf %s \'line one\nline two\' | KUBECONFIG=\'/tmp/kube config\' START_NS=\'1700000000000000000\' \'python3\' \'scripts/query_logs.py\' \'--namespace\' \'agent x\''); + assert.throws( + () => compileManifestToScheduler(manifest), + error => ( + error.validation?.errors.some(item => item.path.endsWith('.shell.env')) && + error.validation?.errors.some(item => item.path.endsWith('.shell.stdin')) + ) + ); const standaloneCompiled = compileManifestToStandalone(manifest); - assert.deepEqual(standaloneCompiled.workflows[0].tasks[0].execution.payload, { - program: 'python3', - args: ['scripts/query_logs.py', '--namespace', 'agent x'], - env: { - START_NS: '1700000000000000000', - KUBECONFIG: '/tmp/kube config' - }, - cwd: '/tmp/work dir', - stdin: 'line one\nline two' - }); + const payload = standaloneCompiled.workflows[0].tasks[0].execution.payload; + assert.equal(payload.program, 'python3'); + assert.deepEqual(payload.args, ['scripts/query_logs.py', '--namespace', 'agent x']); + assert.equal(payload.cwd, '/tmp/work dir'); + assert.equal(payload.env, null); + assert.deepEqual(payload.env_keys, ['KUBECONFIG', 'START_NS']); + assert.match(payload.env_hash, /^sha256:[a-f0-9]{64}$/); + assert.equal(payload.stdin, null); + assert.match(payload.stdin_hash, /^sha256:[a-f0-9]{64}$/); }); test('shell.stdin accepts empty strings', () => { @@ -675,8 +687,15 @@ test('cli init creates a valid manifest in cwd', async (t) => { assert.equal(output.ok, true); assert.ok(output.written_to.endsWith('agentcli.json')); - assert.equal(output.manifest.version, '0.1'); + assert.equal(output.manifest.version, '0.2'); assert.equal(output.manifest.workflows.length, 1); + assert.deepEqual(output.manifest.workflows[0].tasks[0].contract, { + sandbox: 'permissive', + network: 'unrestricted', + audit: 'always', + }); + assert.equal(output.manifest.workflows[0].tasks[0].output.format, 'text'); + assert.equal(validateManifest(output.manifest).ok, true); const written = JSON.parse(readFileSync(output.written_to, 'utf8')); assert.deepEqual(written, output.manifest); @@ -784,13 +803,13 @@ test('npm global install exposes the agentcli alias on PATH', (t) => { }); test('cli schema returns json', async () => { - const output = JSON.parse(await runCli(['schema', 'task'])); + const output = JSON.parse(await runCli(['schema', 'task', '--legacy'])); assert.equal(output.ok, true); assert.equal(output.schema.type, 'object'); }); test('cli schema manifest reflects v0.2 identity surfaces', async () => { - const output = JSON.parse(await runCli(['schema', 'manifest'])); + const output = JSON.parse(await runCli(['schema', 'manifest', '--legacy'])); assert.equal(output.ok, true); assert.equal(output.schema.fields.version.const, '0.2'); assert.ok(output.schema.fields.identity_profiles); @@ -800,7 +819,7 @@ test('cli schema manifest reflects v0.2 identity surfaces', async () => { }); test('cli schema manifest exposes authorization proof value_from sources', async () => { - const output = JSON.parse(await runCli(['schema', 'manifest'])); + const output = JSON.parse(await runCli(['schema', 'manifest', '--legacy'])); const proofValueFrom = output.schema.fields.authorization_proof_profiles.items.fields.proof.fields.value_from.fields; assert.equal(output.ok, true); @@ -810,7 +829,7 @@ test('cli schema manifest exposes authorization proof value_from sources', async }); test('cli schema manifest marks workflow and task authorization refs as required', async () => { - const output = JSON.parse(await runCli(['schema', 'manifest'])); + const output = JSON.parse(await runCli(['schema', 'manifest', '--legacy'])); const workflowFields = output.schema.fields.workflows.items.fields; const taskFields = workflowFields.tasks.items.fields; @@ -822,7 +841,7 @@ test('cli schema manifest marks workflow and task authorization refs as required }); test('cli schema exposes child_credential_policy on workflow and task surfaces', async () => { - const output = JSON.parse(await runCli(['schema', 'manifest'])); + const output = JSON.parse(await runCli(['schema', 'manifest', '--legacy'])); const workflowFields = output.schema.fields.workflows.items.fields; const taskFields = workflowFields.tasks.items.fields; @@ -975,7 +994,7 @@ test('openclaw-scheduler target does not advertise unsupported v0.2 runtime feat assert.equal(target.features.delegation_validation, false); }); -test('applyManifestToScheduler strips non-runtime scheduler metadata from backend specs', async () => { +test('applyManifestToScheduler projects only versioned runtime fields to backend specs', async () => { const manifest = { version: '0.2', identity_profiles: [{ @@ -1010,7 +1029,7 @@ test('applyManifestToScheduler strips non-runtime scheduler metadata from backen queryCapabilities() { return { scheduler_version: '0.2.0', - handoff_version: '1', + handoff_version: '3', features: { trust_evaluation: true, } @@ -1032,11 +1051,13 @@ test('applyManifestToScheduler strips non-runtime scheduler metadata from backen assert.equal(calls[0].origin, 'system'); assert.equal(calls[0].run_timeout_ms, 300000); assert.equal(calls[0].delivery_opt_out_reason, 'delivery intentionally disabled by the agentcli manifest'); - assert.equal('identity_ref' in calls[0], false); - assert.equal('identity' in calls[0], false); - assert.equal('contract_sandbox' in calls[0], false); + assert.equal(calls[0].identity_ref, 'profile'); + assert.equal(typeof calls[0].identity, 'string'); + assert.equal(calls[0].contract_sandbox, 'permissive'); assert.equal('authorization_proof' in calls[0], false); assert.equal('evidence' in calls[0], false); + assert.equal('source' in calls[0], false); + assert.equal('explain' in calls[0], false); }); test('applyManifestToScheduler uses replace-style updates for manifest-managed scheduler fields', async () => { @@ -1688,8 +1709,9 @@ test('json-rpc compile errors include validation payload in error.data', async ( assert.equal(response.id, 'compile-invalid'); assert.equal(response.error.code, -32602); assert.equal(response.error.message, 'Manifest validation failed'); - assert.equal(response.error.data.ok, false); - assert.ok(Array.isArray(response.error.data.errors)); + assert.equal(response.error.data.code, 'validation_error'); + assert.equal(response.error.data.validation.ok, false); + assert.ok(Array.isArray(response.error.data.validation.errors)); }); test('json-rpc caller-fixable parameter errors return invalid params', async () => { @@ -2094,18 +2116,18 @@ test('malformed JSON input produces contextual parse error', async () => { }); test('schema task has required fields on schedule and trigger', async () => { - const output = JSON.parse(await runCli(['schema', 'task'])); + const output = JSON.parse(await runCli(['schema', 'task', '--legacy'])); assert.deepEqual(output.schema.fields.schedule.required, ['cron']); assert.deepEqual(output.schema.fields.trigger.required, ['parent', 'on']); }); test('schema task has mutual exclusion note', async () => { - const output = JSON.parse(await runCli(['schema', 'task'])); + const output = JSON.parse(await runCli(['schema', 'task', '--legacy'])); assert.match(output.schema.note, /Exactly one of schedule or trigger/); }); test('schema task includes child_credential_policy', async () => { - const output = JSON.parse(await runCli(['schema', 'task'])); + const output = JSON.parse(await runCli(['schema', 'task', '--legacy'])); assert.deepEqual( output.schema.fields.child_credential_policy.enum, ['none', 'inherit', 'downscope', 'independent'] @@ -2113,7 +2135,7 @@ test('schema task includes child_credential_policy', async () => { }); test('schema workflow and task identity surfaces include scope', async () => { - const output = JSON.parse(await runCli(['schema', 'manifest'])); + const output = JSON.parse(await runCli(['schema', 'manifest', '--legacy'])); const workflowFields = output.schema.fields.workflows.items.fields; const taskFields = workflowFields.tasks.items.fields; assert.strictEqual(workflowFields.identity.fields.scope.type, 'string'); @@ -2274,14 +2296,14 @@ test('json-rpc compile includes target name in result', async () => { }); test('standalone plan schema includes capabilities field', async () => { - const output = JSON.parse(await runCli(['schema', 'standalonePlan'])); + const output = JSON.parse(await runCli(['schema', 'standalonePlan', '--legacy'])); assert.ok(output.schema.fields.capabilities); assert.equal(output.schema.fields.capabilities.type, 'object'); assert.ok(output.schema.fields.capabilities.fields.authoring); }); test('rpcRequest schema allows string or number id', async () => { - const output = JSON.parse(await runCli(['schema', 'rpc-request'])); + const output = JSON.parse(await runCli(['schema', 'rpc-request', '--legacy'])); assert.deepEqual(output.schema.fields.id.type, ['string', 'number']); }); @@ -2340,7 +2362,7 @@ test('cli --fields without a value produces structured error', async () => { }); test('rpcResponse schema allows string or number id', async () => { - const output = JSON.parse(await runCli(['schema', 'rpc-response'])); + const output = JSON.parse(await runCli(['schema', 'rpc-response', '--legacy'])); assert.deepEqual(output.schema.fields.id.type, ['string', 'number']); }); @@ -2421,7 +2443,7 @@ test('barrel export includes io utilities', async () => { }); test('standalonePlan schema version has const constraint', async () => { - const output = JSON.parse(await runCli(['schema', 'standalone-plan'])); + const output = JSON.parse(await runCli(['schema', 'standalone-plan', '--legacy'])); assert.equal(output.schema.fields.version.const, '0.2'); }); @@ -2631,7 +2653,7 @@ test('non-object schedule does not produce redundant mutual exclusion error', () }); test('schema exposes token format on validated fields', async () => { - const output = JSON.parse(await runCli(['schema', 'task'])); + const output = JSON.parse(await runCli(['schema', 'task', '--legacy'])); assert.equal(output.schema.fields.target.fields.agent_id.format, 'token'); assert.equal(output.schema.fields.delivery.fields.channel.format, 'token'); assert.equal(output.schema.fields.session.fields.preferred_key.format, 'token'); @@ -3248,7 +3270,7 @@ test('on_failure with explicit target overrides inference', () => { }); test('cli schema accepts kebab-case aliases', async () => { - const output = JSON.parse(await runCli(['schema', 'scheduler-job'])); + const output = JSON.parse(await runCli(['schema', 'scheduler-job', '--legacy'])); assert.equal(output.ok, true); assert.ok(output.schema.fields.id); }); @@ -3702,7 +3724,7 @@ test('on_failure handler propagates identity, contract, and v0.2 auth/evidence v schedule: { cron: '0 * * * *' }, on_failure: { prompt: 'Handle failure', - identity: { principal: 'ops@co.com' }, + identity: { subject: { principal: 'ops@co.com' } }, contract: { audit: 'on-failure' }, authorization_proof: { ref: 'proof', verify: { required: true } }, authorization: { ref: 'authz', on_error: 'deny' }, @@ -3716,7 +3738,7 @@ test('on_failure handler propagates identity, contract, and v0.2 auth/evidence v const expanded = expandManifestShorthands(manifest); const failureTask = expanded.workflows[0].tasks.find(t => t.id === 't.failure'); assert.ok(failureTask); - assert.equal(failureTask.identity.principal, 'ops@co.com'); + assert.equal(failureTask.identity.subject.principal, 'ops@co.com'); assert.equal(failureTask.contract.audit, 'on-failure'); assert.equal(failureTask.authorization_proof.ref, 'proof'); assert.equal(failureTask.authorization_proof.verify.required, true); @@ -3940,8 +3962,11 @@ test('exec dry-run does not spawn a process', () => { assert.equal(result.ok, true); assert.equal(result.dry_run, true); assert.equal(result.command.program, 'df'); - assert.deepEqual(result.command.args, ['-h']); - assert.ok(!result.result); + assert.equal(result.command.args_count, 1); + assert.deepEqual(result.command.env_keys, []); + assert.match(result.command.args_hashes[0], /^sha256:[a-f0-9]{64}$/); + assert.deepEqual(result.result, { status: 'dry_run' }); + assert.ok(Object.values(result.phases).every(phase => phase === 'skipped')); }); test('exec resolves identity from workflow to task', () => { @@ -3961,8 +3986,9 @@ test('exec resolves identity from workflow to task', () => { }] }; const result = executeTask(manifest, { taskId: 't' }); - assert.equal(result.identity.principal, 'admin@co.com'); - assert.equal(result.identity.run_as, 'builder'); + assert.equal(result.principal_used, 'admin@co.com'); + assert.equal(result.identity.subject.principal, null); + assert.equal(result.identity.subject.run_as, null); }); test('exec enforces contract.allowed_paths against shell.cwd', () => { @@ -4008,16 +4034,18 @@ test('exec allows cwd under an allowed path', () => { } }); -test('prepareSandboxedShellCommand falls back with warnings on unsupported platforms', () => { - const result = prepareSandboxedShellCommand( - { program: 'echo', args: ['hi'] }, - { sandbox: 'strict', network: 'none' }, - { cwd: process.cwd(), env: {}, platform: 'linux' } +test('prepareSandboxedShellCommand fails closed on unsupported platforms', () => { + assert.throws( + () => prepareSandboxedShellCommand( + { program: 'echo', args: ['hi'] }, + { sandbox: 'strict', network: 'none' }, + { cwd: process.cwd(), env: {}, platform: 'linux' } + ), + error => ( + error.code === 'sandbox_enforcement_unavailable' && + error.constraints.includes('network denial') + ) ); - - assert.equal(result.sandboxed, false); - assert.ok(result.warnings.some(w => w.includes('no supported local sandbox runner'))); - assert.ok(result.warnings.some(w => w.includes('network'))); }); test('prepareSandboxedShellCommand keeps permissive sandbox advisory', () => { @@ -4060,7 +4088,7 @@ test('prepareSandboxedShellCommand wraps strict contracts on supported darwin ru assert.equal(result.args[2], 'echo'); }); -test('exec returns fallback warnings for strict sandbox and network none when sandboxing is disabled', () => { +test('exec fails closed for strict sandbox and network none when sandboxing is disabled', () => { const manifest = { version: '0.1', workflows: [{ @@ -4074,12 +4102,13 @@ test('exec returns fallback warnings for strict sandbox and network none when sa }] }] }; - const result = executeTask(manifest, { - taskId: 't', - env: { ...process.env, AGENTCLI_SANDBOX: 'off' }, - }); - assert.ok(result.warnings.some(w => w.includes('no supported local sandbox runner'))); - assert.ok(result.warnings.some(w => w.includes('network'))); + assert.throws( + () => executeTask(manifest, { + taskId: 't', + env: { ...process.env, AGENTCLI_SANDBOX: 'off' }, + }), + error => error.code === 'sandbox_enforcement_unavailable' + ); }); test('exec strict sandbox allows writes inside cwd on supported darwin runners', () => { @@ -4394,7 +4423,8 @@ test('cli exec --dry-run does not execute', async () => { const output = JSON.parse(await runCli(['exec', JSON.stringify(manifest), 't', '--dry-run'])); assert.equal(output.ok, true); assert.equal(output.dry_run, true); - assert.ok(!output.result); + assert.deepEqual(output.result, { status: 'dry_run' }); + assert.ok(Object.values(output.phases).every(phase => phase === 'skipped')); }); test('cli exec --dry-run previews delegated non-shell tasks without scheduler config', async () => { @@ -6144,7 +6174,7 @@ test('none verifier verifyProof returns unverified', async () => { assert.strictEqual(result.method, 'none'); }); -test('jwt verifier validates profile with issuer', async () => { +test('jwt verifier rejects profiles without a configured trust source', async () => { const { getVerifier } = await import('../src/authorization-proof/index.js'); await import('../src/authorization-proof/jwt.js'); const verifier = getVerifier('jwt'); @@ -6152,10 +6182,11 @@ test('jwt verifier validates profile with issuer', async () => { issuer: 'https://issuer.example.com', proof: { value_from: { env: 'JWT_TOKEN' } } }, {}); - assert.strictEqual(result.valid, true); + assert.strictEqual(result.valid, false); + assert.ok(result.errors.some(error => error.field === 'verify')); }); -test('authorization proof verifiers accept literal proof sources', async () => { +test('authorization proof verifiers reject circular literal proof sources', async () => { const { getVerifier } = await import('../src/authorization-proof/index.js'); await import('../src/authorization-proof/jwt.js'); await import('../src/authorization-proof/detached-signature.js'); @@ -6164,9 +6195,11 @@ test('authorization proof verifiers accept literal proof sources', async () => { for (const method of ['jwt', 'detached-signature', 'certificate']) { const verifier = getVerifier(method); const result = verifier.validateProfile({ + public_key: testKeyPair.publicKey, proof: { value_from: { literal: 'inline-proof-material' } } }, {}); - assert.strictEqual(result.valid, true, `${method} should accept literal proof sources`); + assert.strictEqual(result.valid, false, `${method} should reject literal proof sources`); + assert.ok(result.errors.some(error => error.field === 'proof.value_from.literal')); } }); @@ -6203,7 +6236,7 @@ test('jwt verifier rejects token with wrong issuer when profile.issuer is set', assert.strictEqual(result.claims_validated, false); }); -test('jwt verifier accepts token with correct issuer', async () => { +test('jwt verifier validates issuer claims but rejects unsigned tokens', async () => { const { getVerifier } = await import('../src/authorization-proof/index.js'); await import('../src/authorization-proof/jwt.js'); const verifier = getVerifier('jwt'); @@ -6211,9 +6244,11 @@ test('jwt verifier accepts token with correct issuer', async () => { const result = verifier.verifyProof(token, { issuer: 'https://expected.example.com', }, {}); - assert.strictEqual(result.verified, true); + assert.strictEqual(result.verified, false); assert.strictEqual(result.method, 'jwt'); assert.strictEqual(result.claims_validated, true); + assert.strictEqual(result.signature_verified, false); + assert.strictEqual(result.manifest_bound, false); }); test('jwt verifier rejects token with wrong audience when profile.audience is set', async () => { @@ -6230,7 +6265,7 @@ test('jwt verifier rejects token with wrong audience when profile.audience is se assert.strictEqual(result.claims_validated, false); }); -test('jwt verifier accepts token with array audience containing expected value', async () => { +test('jwt verifier validates array audience claims but rejects unsigned tokens', async () => { const { getVerifier } = await import('../src/authorization-proof/index.js'); await import('../src/authorization-proof/jwt.js'); const verifier = getVerifier('jwt'); @@ -6241,9 +6276,10 @@ test('jwt verifier accepts token with array audience containing expected value', const result = verifier.verifyProof(token, { audience: 'https://expected.api.com', }, {}); - assert.strictEqual(result.verified, true); + assert.strictEqual(result.verified, false); assert.strictEqual(result.method, 'jwt'); assert.strictEqual(result.claims_validated, true); + assert.strictEqual(result.signature_verified, false); }); // -- verify.required / signatureRequired behavior -- @@ -6260,16 +6296,17 @@ test('jwt verifier with signatureRequired=true and no trusted key returns verifi assert.strictEqual(result.claims_validated, true); }); -test('jwt verifier with signatureRequired=false and no trusted key returns verified=true (claims-only)', async () => { +test('jwt verifier never treats claims-only parsing as verification', async () => { const { getVerifier } = await import('../src/authorization-proof/index.js'); await import('../src/authorization-proof/jwt.js'); const verifier = getVerifier('jwt'); const token = unsignedJwt({ sub: 'test' }); const result = verifier.verifyProof(token, {}, { requireSignature: false }); - assert.strictEqual(result.verified, true); + assert.strictEqual(result.verified, false); assert.strictEqual(result.signature_verified, false); assert.strictEqual(result.signature_required, false); assert.strictEqual(result.claims_validated, true); + assert.strictEqual(result.manifest_bound, false); }); // -- Audit-safe claim extraction -- @@ -6298,7 +6335,8 @@ test('jwt verifier decoded_claims includes AUDIT_SAFE_CLAIMS from payload', asyn }; const token = unsignedJwt(payload); const result = verifier.verifyProof(token, {}, {}); - assert.strictEqual(result.verified, true); + assert.strictEqual(result.verified, false); + assert.strictEqual(result.claims_validated, true); const dc = result.decoded_claims; assert.strictEqual(dc.sub, 'test-subject'); assert.strictEqual(dc.iss, 'https://issuer.example.com'); @@ -6330,7 +6368,8 @@ test('jwt verifier decoded_claims does not include claims outside the audit-safe }; const token = unsignedJwt(payload); const result = verifier.verifyProof(token, {}, {}); - assert.strictEqual(result.verified, true); + assert.strictEqual(result.verified, false); + assert.strictEqual(result.claims_validated, true); const dc = result.decoded_claims; assert.strictEqual(dc.sub, 'test-subject'); assert.strictEqual(dc.secret_internal_field, undefined); @@ -6350,7 +6389,7 @@ test('jwt verifier validateProfile rejects verify.required=true without public_k }, {}); assert.strictEqual(result.valid, false); const fieldNames = result.errors.map(e => e.field); - assert.ok(fieldNames.includes('verify.required')); + assert.ok(fieldNames.includes('verify')); }); test('jwt verifier validateProfile accepts verify.required=true with public_key', async () => { @@ -6397,11 +6436,13 @@ test('jwt verifier verifies signed token when trustedKey is provided in context' const { getVerifier } = await import('../src/authorization-proof/index.js'); await import('../src/authorization-proof/jwt.js'); const verifier = getVerifier('jwt'); + const manifest = { version: '0.2', workflows: [] }; const token = signedJwt({ sub: 'signed-subject', iss: 'https://issuer.example.com', aud: 'https://api.example.com', org_id: 'org-signed', + manifest_digest: canonicalDigest(manifest), }); const result = verifier.verifyProof(token, { issuer: 'https://issuer.example.com', @@ -6410,11 +6451,13 @@ test('jwt verifier verifies signed token when trustedKey is provided in context' trustedKey: testKeyPair.publicKey, trustedKeySource: 'public_key', requireSignature: true, + manifestDigest: canonicalDigest(manifest), }); assert.strictEqual(result.verified, true); assert.strictEqual(result.signature_verified, true); assert.strictEqual(result.signature_required, true); assert.strictEqual(result.claims_validated, true); + assert.strictEqual(result.manifest_bound, true); assert.strictEqual(result.key_source, 'public_key'); assert.strictEqual(result.decoded_claims.org_id, 'org-signed'); }); @@ -6459,7 +6502,7 @@ test('resolveJwtVerificationContext returns null trustedKey when no key source i assert.strictEqual(ctx.trustedKey, null); assert.strictEqual(ctx.trustedKeySource, null); assert.strictEqual(ctx.trustedKeyError, null); - assert.strictEqual(ctx.requireSignature, false); + assert.strictEqual(ctx.requireSignature, true); }); test('resolveJwtVerificationContext inherits requireSignature from profile.verify.required', async () => { @@ -6851,25 +6894,29 @@ test('v0.2 exec with none identity provider succeeds (dry run)', async () => { const result = await executeTask(manifest, { taskId: 'echo-identity', dryRun: true }); assert.strictEqual(result.ok, true); assert.strictEqual(result.dry_run, true); - assert.ok(result.declared_identity); - assert.ok(result.principal_used); + assert.strictEqual(result.identity.ref, 'local-agent'); + assert.strictEqual(result.declared_identity, undefined); + assert.strictEqual(result.resolved_identity, undefined); + assert.strictEqual(result.principal_used, undefined); + assert.ok(Object.values(result.phases).every(phase => phase === 'skipped')); }); -test('v0.2 exec includes declared identity fields', async () => { +test('v0.2 dry-run includes redacted declared identity fields without resolving a session', async () => { const manifest = JSON.parse(readFileSync(new URL('../examples/identity-v2.json', import.meta.url), 'utf8')); const result = await executeTask(manifest, { taskId: 'echo-identity', dryRun: true }); - assert.strictEqual(result.declared_identity.provider, 'none'); - assert.strictEqual(result.declared_identity.subject.principal, 'agent://local/test-agent'); - assert.strictEqual(result.declared_identity.subject.kind, 'agent'); - assert.strictEqual(result.declared_identity.trust_level, 'supervised'); + assert.strictEqual(result.identity.provider, 'none'); + assert.strictEqual(result.identity.subject.principal, 'agent://local/test-agent'); + assert.strictEqual(result.identity.subject.kind, 'agent'); + assert.strictEqual(result.identity.trust.level, 'supervised'); + assert.strictEqual(result.resolved_identity, undefined); }); -test('v0.2 exec includes trust info', async () => { +test('v0.2 dry-run includes declared trust without provider resolution', async () => { const manifest = JSON.parse(readFileSync(new URL('../examples/identity-v2.json', import.meta.url), 'utf8')); const result = await executeTask(manifest, { taskId: 'echo-identity', dryRun: true }); - assert.ok(result.trust); - assert.strictEqual(result.trust.declared_level, 'supervised'); - assert.strictEqual(result.trust.effective_level, 'supervised'); + assert.strictEqual(result.identity.trust.level, 'supervised'); + assert.strictEqual(result.trust, undefined); + assert.strictEqual(result.phases.identity_resolution, 'skipped'); }); test('v0.2 exec includes contract with trust fields', async () => { @@ -6879,12 +6926,13 @@ test('v0.2 exec includes contract with trust fields', async () => { assert.strictEqual(result.contract.trust_enforcement, 'advisory'); }); -test('v0.2 exec with env-bearer identity and missing optional token succeeds', async () => { +test('v0.2 dry-run inspects env-bearer declarations without resolving the optional token', async () => { const manifest = JSON.parse(readFileSync(new URL('../examples/identity-v2.json', import.meta.url), 'utf8')); const result = await executeTask(manifest, { taskId: 'env-token-task', dryRun: true }); assert.strictEqual(result.ok, true); - assert.strictEqual(result.declared_identity.provider, 'env-bearer'); - assert.strictEqual(result.declared_identity.subject.kind, 'service'); + assert.strictEqual(result.identity.provider, 'env-bearer'); + assert.strictEqual(result.identity.subject.kind, 'service'); + assert.strictEqual(result.phases.identity_resolution, 'skipped'); }); test('v0.2 exec passes identity scope through to scoped providers', async () => { @@ -6941,7 +6989,7 @@ test('v0.2 exec passes identity scope through to scoped providers', async () => assert.strictEqual(result.result.stdout, 'rk_test_readonly_scope_value_654321'); }); -test('v0.2 exec awaits async handoff preparation and summarizes credentials', async () => { +test('v0.2 local shell execution rejects unsupported non-none handoff declarations', async () => { const manifest = { version: '0.2', identity_profiles: [{ @@ -6969,21 +7017,15 @@ test('v0.2 exec awaits async handoff preparation and summarizes credentials', as }], }; - const result = await executeTask(manifest, { - taskId: 'handoff-task', - dryRun: true, - presentationDebug: true, - signer: 'none', - }); - - assert.strictEqual(result.ok, true); - assert.deepStrictEqual(result.handoff, { mode: 'downscope', prepared: true }); - assert.deepStrictEqual(result.presentation_debug.handoff, { - mode: 'downscope', - prepared: true, - credential_types: ['access_token'], - reason: null, - }); + assert.throws( + () => executeTask(manifest, { + taskId: 'handoff-task', + dryRun: true, + presentationDebug: true, + signer: 'none', + }), + error => error.code === 'unsupported_capability' + ); }); test('v0.2 exec runs command and returns output', async () => { @@ -6995,24 +7037,27 @@ test('v0.2 exec runs command and returns output', async () => { assert.ok(result.execution_id); }); -test('v0.2 exec principal_used matches profile principal', async () => { +test('v0.2 dry-run does not claim a resolved principal', async () => { const manifest = JSON.parse(readFileSync(new URL('../examples/identity-v2.json', import.meta.url), 'utf8')); const result = await executeTask(manifest, { taskId: 'echo-identity', dryRun: true }); - assert.strictEqual(result.principal_used, 'agent://local/test-agent'); + assert.strictEqual(result.principal_used, undefined); + assert.strictEqual(result.identity.subject.principal, 'agent://local/test-agent'); }); -test('v0.2 exec with resolved identity includes session description', async () => { +test('v0.2 dry-run does not include a resolved identity session', async () => { const manifest = JSON.parse(readFileSync(new URL('../examples/identity-v2.json', import.meta.url), 'utf8')); const result = await executeTask(manifest, { taskId: 'echo-identity', dryRun: true }); - assert.ok(result.resolved_identity); - assert.strictEqual(result.resolved_identity.provider, 'none'); - assert.deepStrictEqual(result.resolved_identity.credentials, {}); + assert.strictEqual(result.resolved_identity, undefined); + assert.strictEqual(result.phases.identity_resolution, 'skipped'); }); test('v0.2 exec resolves authorization proof, authorization, and evidence', async () => { const result = await executeTask(proofEnabledManifest, { taskId: 'proof-task', - env: { ...process.env, TEST_AGENTCLI_JWT: signedJwt({ sub: 'agentcli-proof' }) } + env: { + ...process.env, + TEST_AGENTCLI_JWT: signedManifestJwt(proofEnabledManifest, { sub: 'agentcli-proof' }), + } }); assert.strictEqual(result.ok, true); assert.strictEqual(result.authorization_proof.verified, true); @@ -7063,13 +7108,15 @@ test('convertManifestV1toV2 preserves workflow identity', async () => { assert.strictEqual(profile.subject.principal, 'deploy-bot@infra.example.com'); }); -test('convertManifestV1toV2 creates authorization_proof_profile for oidc attestation', async () => { +test('convertManifestV1toV2 preserves legacy attestation as a non-verifying declaration', async () => { const { convertManifestV1toV2 } = await import('../src/convert.js'); const v1 = JSON.parse(readFileSync(new URL('../examples/identity-contract.json', import.meta.url), 'utf8')); const v2 = convertManifestV1toV2(v1); assert.ok(Array.isArray(v2.authorization_proof_profiles)); - const jwtProfile = v2.authorization_proof_profiles.find(p => p.method === 'jwt'); - assert.ok(jwtProfile, 'Should create a jwt authorization_proof_profile for oidc attestation'); + const declaration = v2.authorization_proof_profiles.find(p => p.method === 'none'); + assert.ok(declaration, 'Should preserve legacy attestation without claiming cryptographic verification'); + assert.strictEqual(declaration.verify.required, false); + assert.strictEqual(validateManifest(v2).ok, true); }); test('convertManifestV1toV2 rejects null input', async () => { @@ -7092,7 +7139,7 @@ test('v0.2 standalone compilation preserves evidence profiles', () => { const compiled = compileManifestToStandalone(manifest); assert.ok(compiled.evidence_profiles); assert.strictEqual(compiled.evidence_profiles.length, 1); - assert.ok(compiled.capabilities.evidence_generation); + assert.strictEqual(compiled.capabilities.evidence_generation, false); }); test('v0.2 standalone compilation preserves authorization_proof_profiles', () => { @@ -7184,18 +7231,19 @@ test('v0.2 scheduler compilation redacts provider inputs from durable specs', () const manifest = { version: '0.2', authorization_proof_profiles: [{ - id: 'literal-proof', + id: 'external-proof', method: 'jwt', + public_key: testKeyPair.publicKey, proof: { value_from: { - literal: unsignedJwt({ sub: 'agentcli-proof' }) + env: 'SECRET_PROOF' } }, verify: { required: true } }], identity_profiles: [{ id: 'secret-agent', - provider: 'none', + provider: TEST_ASYNC_HANDOFF_PROVIDER, provider_config: { profile_secret: 'profile-level-secret' }, @@ -7240,7 +7288,7 @@ test('v0.2 scheduler compilation redacts provider inputs from durable specs', () shell: { program: 'echo', args: ['redact'] }, schedule: { cron: '0 * * * *' }, identity: { ref: 'secret-agent' }, - authorization_proof: { ref: 'literal-proof' }, + authorization_proof: { ref: 'external-proof' }, authorization: { ref: 'secret-authz', provider_config: { task: 'redact-task' } @@ -7250,16 +7298,24 @@ test('v0.2 scheduler compilation redacts provider inputs from durable specs', () }] }; + const validation = validateManifest(manifest); + assert.equal(validation.ok, true, JSON.stringify(validation.errors)); const compiled = compileManifestToScheduler(manifest); const job = compiled.jobs.find(candidate => candidate.source.task_id === 'redact-task'); assert.ok(job); assert.strictEqual(job.identity.auth.provider_config, null); assert.strictEqual(job.identity.auth.inputs, null); - assert.strictEqual(job.authorization_proof.proof.value_from, null); + assert.deepStrictEqual(job.authorization_proof.proof.value_from, { + env: 'SECRET_PROOF', + file: null, + }); assert.strictEqual(job.authorization.provider_config, null); assert.strictEqual(job.evidence.provider_config, null); - assert.strictEqual(compiled.authorization_proof_profiles[0].proof.value_from, null); + assert.deepStrictEqual(compiled.authorization_proof_profiles[0].proof.value_from, { + env: 'SECRET_PROOF', + file: null, + }); assert.strictEqual(compiled.identity_profiles[0].provider_config, null); assert.strictEqual(compiled.identity_profiles[0].auth.provider_config, null); assert.strictEqual(compiled.identity_profiles[0].auth.inputs, null); @@ -7356,6 +7412,13 @@ test('applyManifestToScheduler returns authorization proof verification summarie const calls = []; const runner = { invocation: { label: 'fake-scheduler' }, + queryCapabilities() { + return { + scheduler_version: 'test', + handoff_version: '3', + features: { authorization_proof_verification: false }, + }; + }, listJobs() { return []; }, @@ -7370,7 +7433,10 @@ test('applyManifestToScheduler returns authorization proof verification summarie const result = await applyManifestToScheduler(applyProofManifest, { runner, - env: { ...process.env, TEST_AGENTCLI_JWT: signedJwt({ sub: 'agentcli-proof' }) } + env: { + ...process.env, + TEST_AGENTCLI_JWT: signedManifestJwt(applyProofManifest, { sub: 'agentcli-proof' }), + } }); assert.strictEqual(result.ok, true); @@ -7379,13 +7445,25 @@ test('applyManifestToScheduler returns authorization proof verification summarie assert.strictEqual(result.authorization_proof_verifications[0].source.task_id, 'verify-me'); assert.strictEqual(result.authorization_proof_verifications[0].verification.verified, true); assert.strictEqual('authorization_proof_verification' in calls[0], false); - assert.strictEqual('authorization_proof' in calls[0], false); + const persistedProof = JSON.parse(calls[0].authorization_proof); + assert.strictEqual(persistedProof.ref, 'jwt-proof'); + assert.deepStrictEqual(persistedProof.proof.value_from, { + env: 'TEST_AGENTCLI_JWT', + file: null, + }); }); test('applyManifestToScheduler resolves command-sourced authorization proofs', async () => { const calls = []; const runner = { invocation: { label: 'fake-scheduler' }, + queryCapabilities() { + return { + scheduler_version: 'test', + handoff_version: '3', + features: { authorization_proof_verification: false }, + }; + }, listJobs() { return []; }, @@ -7398,12 +7476,12 @@ test('applyManifestToScheduler resolves command-sourced authorization proofs', a } }; - const result = await applyManifestToScheduler({ + const manifest = { version: '0.2', authorization_proof_profiles: [{ id: 'jwt-proof', method: 'jwt', - proof: { value_from: { command: `printf '%s' '${signedJwt({ sub: 'agentcli-proof' })}'` } }, + proof: { value_from: { command: 'printf %s "$PROOF_TOKEN"' } }, claims: { subject: 'agentcli-proof' }, public_key: testKeyPair.publicKey, verify: { required: true } @@ -7420,26 +7498,39 @@ test('applyManifestToScheduler resolves command-sourced authorization proofs', a schedule: { cron: '0 * * * *' } }] }] - }, { + }; + const result = await applyManifestToScheduler(manifest, { runner, - env: process.env + env: { + ...process.env, + PROOF_TOKEN: signedManifestJwt(manifest, { sub: 'agentcli-proof' }), + }, + allowValueFromCommand: true, }); assert.strictEqual(result.ok, true); assert.strictEqual(result.authorization_proof_verifications.length, 1); assert.strictEqual(result.authorization_proof_verifications[0].verification.verified, true); - assert.strictEqual('authorization_proof' in calls[0], false); + const persistedProof = JSON.parse(calls[0].authorization_proof); + assert.strictEqual(persistedProof.ref, 'jwt-proof'); + assert.strictEqual(persistedProof.proof.value_from, null); }); test('applyManifestToScheduler resolves command-sourced authorization proofs relative to cwd', async () => { const workdir = mkdtempSync(join(tmpdir(), 'agentcli-apply-proof-cwd-')); const scriptPath = join(workdir, 'emit.js'); - const token = signedJwt({ sub: 'agentcli-proof' }); - writeFileSync(scriptPath, `process.stdout.write(${JSON.stringify(token)})\n`); + writeFileSync(scriptPath, 'process.stdout.write(process.env.PROOF_TOKEN)\n'); const calls = []; const runner = { invocation: { label: 'fake-scheduler' }, + queryCapabilities() { + return { + scheduler_version: 'test', + handoff_version: '3', + features: { authorization_proof_verification: false }, + }; + }, listJobs() { return []; }, @@ -7453,7 +7544,7 @@ test('applyManifestToScheduler resolves command-sourced authorization proofs rel }; try { - const result = await applyManifestToScheduler({ + const manifest = { version: '0.2', authorization_proof_profiles: [{ id: 'jwt-proof', @@ -7475,22 +7566,29 @@ test('applyManifestToScheduler resolves command-sourced authorization proofs rel schedule: { cron: '0 * * * *' } }] }] - }, { + }; + const result = await applyManifestToScheduler(manifest, { runner, cwd: workdir, - env: process.env + env: { + ...process.env, + PROOF_TOKEN: signedManifestJwt(manifest, { sub: 'agentcli-proof' }), + }, + allowValueFromCommand: true, }); assert.strictEqual(result.ok, true); assert.strictEqual(result.authorization_proof_verifications.length, 1); assert.strictEqual(result.authorization_proof_verifications[0].verification.verified, true); - assert.strictEqual('authorization_proof' in calls[0], false); + const persistedProof = JSON.parse(calls[0].authorization_proof); + assert.strictEqual(persistedProof.ref, 'jwt-proof'); + assert.strictEqual(persistedProof.proof.value_from, null); } finally { rmSync(workdir, { recursive: true, force: true }); } }); -test('applyManifestToScheduler verifies literal authorization proofs without persisting them', async () => { +test('applyManifestToScheduler rejects circular literal authorization proofs', async () => { const calls = []; const runner = { invocation: { label: 'fake-scheduler' }, @@ -7506,7 +7604,7 @@ test('applyManifestToScheduler verifies literal authorization proofs without per } }; - const result = await applyManifestToScheduler({ + const manifest = { version: '0.2', authorization_proof_profiles: [{ id: 'jwt-proof', @@ -7528,16 +7626,15 @@ test('applyManifestToScheduler verifies literal authorization proofs without per schedule: { cron: '0 * * * *' } }] }] - }, { - runner, - env: process.env - }); + }; - assert.strictEqual(result.ok, true); - assert.strictEqual(result.authorization_proof_verifications.length, 1); - assert.strictEqual(result.authorization_proof_verifications[0].verification.verified, true); - assert.strictEqual('authorization_proof' in calls[0], false); - assert.strictEqual('authorization_proof_verification' in calls[0], false); + await assert.rejects( + applyManifestToScheduler(manifest, { runner, env: process.env }), + error => error.validation?.errors.some(item => ( + item.path.endsWith('.proof.value_from.literal') && /not supported/.test(item.message) + )) + ); + assert.deepStrictEqual(calls, []); }); test('applyManifestToScheduler rejects generated on_failure authorization when target lacks hook', async () => { @@ -7569,6 +7666,13 @@ test('applyManifestToScheduler rejects generated on_failure authorization when t () => applyManifestToScheduler(manifest, { runner: { invocation: { label: 'fake-scheduler' }, + queryCapabilities() { + return { + scheduler_version: 'test', + handoff_version: '3', + features: { authorization_hook: false }, + }; + }, listJobs() { return []; }, @@ -7588,6 +7692,13 @@ test('applyManifestToScheduler proof fallback uses resolved task proof declarati const calls = []; const runner = { invocation: { label: 'fake-scheduler' }, + queryCapabilities() { + return { + scheduler_version: 'test', + handoff_version: '3', + features: { authorization_proof_verification: false }, + }; + }, listJobs() { return []; }, @@ -7602,13 +7713,16 @@ test('applyManifestToScheduler proof fallback uses resolved task proof declarati const result = await applyManifestToScheduler(applyProofOverrideManifest, { runner, - env: { ...process.env, TEST_AGENTCLI_JWT: signedJwt({ sub: 'agentcli-proof' }) } + env: { + ...process.env, + TEST_AGENTCLI_JWT: signedManifestJwt(applyProofOverrideManifest, { sub: 'agentcli-proof' }), + } }); assert.strictEqual(result.ok, true); assert.strictEqual(result.authorization_proof_verifications.length, 1); assert.strictEqual(result.authorization_proof_verifications[0].source.task_id, 'verify-override'); - assert.strictEqual('authorization_proof' in calls[0], false); + assert.strictEqual(JSON.parse(calls[0].authorization_proof).ref, 'jwt-proof'); assert.strictEqual('authorization_proof_verification' in calls[0], false); }); @@ -7616,6 +7730,13 @@ test('applyManifestToScheduler proof fallback covers generated on_failure tasks' const calls = []; const runner = { invocation: { label: 'fake-scheduler' }, + queryCapabilities() { + return { + scheduler_version: 'test', + handoff_version: '3', + features: { authorization_proof_verification: false }, + }; + }, listJobs() { return []; }, @@ -7630,7 +7751,10 @@ test('applyManifestToScheduler proof fallback covers generated on_failure tasks' const result = await applyManifestToScheduler(applyOnFailureProofManifest, { runner, - env: { ...process.env, TEST_AGENTCLI_JWT: signedJwt({ sub: 'agentcli-proof' }) } + env: { + ...process.env, + TEST_AGENTCLI_JWT: signedManifestJwt(applyOnFailureProofManifest, { sub: 'agentcli-proof' }), + } }); assert.strictEqual(result.ok, true); @@ -7638,7 +7762,7 @@ test('applyManifestToScheduler proof fallback covers generated on_failure tasks' assert.strictEqual(result.authorization_proof_verifications[0].source.task_id, 'primary.failure'); const failureSpec = calls.find(spec => spec.name === 'Handle Failure'); assert.ok(failureSpec); - assert.strictEqual('authorization_proof' in failureSpec, false); + assert.strictEqual(JSON.parse(failureSpec.authorization_proof).ref, 'jwt-proof'); assert.strictEqual('authorization_proof_verification' in failureSpec, false); }); @@ -7737,7 +7861,7 @@ test('resolveEffectiveFeatures upgrades static false to runtime true', () => { assert.strictEqual(result.features.credential_handoff, true); }); -test('resolveEffectiveFeatures cannot downgrade static true to runtime false', () => { +test('resolveEffectiveFeatures treats live false values as authoritative downgrades', () => { const caps = { ok: true, features: { @@ -7747,9 +7871,9 @@ test('resolveEffectiveFeatures cannot downgrade static true to runtime false', ( } }; const result = resolveEffectiveFeatures('openclaw-scheduler', caps); - assert.strictEqual(result.features.runtime_execution, true); - assert.strictEqual(result.features.identity_declaration, true); - assert.strictEqual(result.features.audit_export, true); + assert.strictEqual(result.features.runtime_execution, false); + assert.strictEqual(result.features.identity_declaration, false); + assert.strictEqual(result.features.audit_export, false); }); test('resolveEffectiveFeatures replaces string values from runtime', () => { @@ -8038,7 +8162,7 @@ test('applyManifestToScheduler rejects unsupported trust and evidence capabiliti queryCapabilities() { return { scheduler_version: '0.2.0', - handoff_version: '1', + handoff_version: '3', features: {}, }; }, @@ -8384,7 +8508,7 @@ test('applyManifestToScheduler with handoff_version 2 sends v0.2 fields to addJo assert.strictEqual(result.handoff.projected_fields, SCHEDULER_FIELD_VERSIONS['2'].length); }); -test('applyManifestToScheduler without capabilities (old scheduler) strips v0.2 fields', async () => { +test('applyManifestToScheduler without capabilities rejects v0.2 fields', async () => { const manifest = { version: '0.2', identity_profiles: [{ @@ -8423,20 +8547,15 @@ test('applyManifestToScheduler without capabilities (old scheduler) strips v0.2 updateJob() { throw new Error('should not update'); } }; - const result = await applyManifestToScheduler(manifest, { runner }); - assert.strictEqual(result.ok, true); - assert.strictEqual(calls.length, 1); - // v0.2 fields should NOT be present - assert.strictEqual('identity_ref' in calls[0], false); - assert.strictEqual('identity' in calls[0], false); - assert.strictEqual('contract_sandbox' in calls[0], false); - assert.strictEqual('authorization_proof' in calls[0], false); - assert.strictEqual('evidence' in calls[0], false); - // handoff metadata - assert.ok(result.handoff); - assert.strictEqual(result.handoff.field_version, '1'); - assert.strictEqual(result.handoff.v02_fields_included, false); - assert.strictEqual(result.handoff.projected_fields, SCHEDULER_FIELDS_V1.length); + await assert.rejects( + applyManifestToScheduler(manifest, { runner }), + error => ( + error.code === 'unsupported_capability' && + error.required_handoff_version === '2' && + error.advertised_handoff_version === '1' + ) + ); + assert.strictEqual(calls.length, 0); }); test('applyManifestToScheduler with handoff_version 2 passes v0.2 fields to updateJob', async () => { @@ -8512,9 +8631,10 @@ test('applyManifestToScheduler with handoff_version 2 passes v0.2 fields to upda test('compiler output includes handoff metadata', () => { const compiled = compileManifestToScheduler(exampleManifest); assert.ok(compiled.handoff); - assert.strictEqual(compiled.handoff.field_version, '2'); + assert.strictEqual(compiled.handoff.field_version, '3'); assert.strictEqual(compiled.handoff.v1_field_count, SCHEDULER_FIELDS_V1.length); assert.strictEqual(compiled.handoff.v2_field_count, SCHEDULER_FIELDS_V1.length + SCHEDULER_FIELDS_V02.length); + assert.strictEqual(compiled.handoff.v3_field_count, SCHEDULER_FIELD_VERSIONS['3'].length); }); // --------------------------------------------------------------------------- @@ -8715,10 +8835,6 @@ test('validation rejects contract trust floor above resolved identity max_autono }); test('v0.2 exec rejects JWT authorization proof with wrong claims', async () => { - const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url'); - const payload = Buffer.from(JSON.stringify({ sub: 'wrong-principal', aud: 'agentcli', exp: Math.floor(Date.now() / 1000) + 3600 })).toString('base64url'); - const badJwt = `${header}.${payload}.`; - const manifest = { version: '0.2', identity_profiles: [{ id: 'agent', provider: 'none', subject: { kind: 'agent' } }], @@ -8727,6 +8843,7 @@ test('v0.2 exec rejects JWT authorization proof with wrong claims', async () => method: 'jwt', proof: { value_from: { env: 'TEST_JWT' } }, claims: { subject: 'expected-principal' }, + public_key: testKeyPair.publicKey, verify: { required: true } }], workflows: [{ @@ -8743,6 +8860,11 @@ test('v0.2 exec rejects JWT authorization proof with wrong claims', async () => }] }] }; + const badJwt = signedManifestJwt(manifest, { + sub: 'wrong-principal', + aud: 'agentcli', + exp: Math.floor(Date.now() / 1000) + 3600, + }); await assert.rejects( () => executeTask(manifest, { taskId: 'proof-task', env: { ...process.env, TEST_JWT: badJwt }, signer: 'none' }), @@ -8917,7 +9039,8 @@ test('validation accepts authorization proof profiles with value_from proof sour authorization_proof_profiles: [{ id: 'jwt-proof', method: 'jwt', - proof: { value_from: { literal: 'header.payload.signature' } }, + public_key: testKeyPair.publicKey, + proof: { value_from: { env: 'JWT_PROOF' } }, verify: { required: true } }], workflows: [{ @@ -9144,11 +9267,10 @@ test('file-bearer resolves command-sourced token file paths relative to ctx.cwd' } }); -test('v0.2 exec resolves command-sourced authorization proofs relative to cwd', async () => { +test('v0.2 exec dry-run skips command-sourced authorization proofs', async () => { const workdir = mkdtempSync(join(tmpdir(), 'agentcli-exec-proof-cwd-')); const scriptPath = join(workdir, 'emit.js'); - const token = signedJwt({ sub: 'agentcli-proof' }); - writeFileSync(scriptPath, `process.stdout.write(${JSON.stringify(token)})\n`); + writeFileSync(scriptPath, 'throw new Error("proof command must not execute during dry-run")\n'); try { const manifest = { @@ -9184,7 +9306,8 @@ test('v0.2 exec resolves command-sourced authorization proofs relative to cwd', }); assert.strictEqual(result.ok, true); - assert.strictEqual(result.authorization_proof.verified, true); + assert.strictEqual(result.authorization_proof, undefined); + assert.strictEqual(result.phases.authorization_proof, 'skipped'); } finally { rmSync(workdir, { recursive: true, force: true }); } @@ -9276,7 +9399,7 @@ test('scheduler adapter canExecute checks context and session_target', () => { assert.match(shellResult.reason, /Unsupported session target/); }); -test('compileManifestForDispatch caches compiled manifests by object identity', () => { +test('compileManifestForDispatch recompiles mutable manifest objects', () => { const manifest = { version: '0.1', workflows: [{ @@ -9294,11 +9417,13 @@ test('compileManifestForDispatch caches compiled manifests by object identity', const first = compileManifestForDispatch(manifest); const second = compileManifestForDispatch(manifest); - assert.strictEqual(second, first, 'same manifest object should reuse the cached compiled output'); + assert.notStrictEqual(second, first, 'each dispatch should receive a fresh compiled output'); + assert.deepStrictEqual(second, first); - const clonedManifest = structuredClone(manifest); - const third = compileManifestForDispatch(clonedManifest); - assert.notStrictEqual(third, first, 'different manifest objects should compile independently'); + manifest.workflows[0].tasks[0].name = 'Changed Cache T'; + const third = compileManifestForDispatch(manifest); + assert.notDeepStrictEqual(third, first, 'mutations must be reflected instead of returning stale output'); + assert.strictEqual(third.jobs[0].name, 'Changed Cache T'); }); // --------------------------------------------------------------------------- @@ -9442,7 +9567,7 @@ test('exec delegated task surfaces scheduler capability warnings', () => { '#!/bin/sh', 'case "$*" in', ' *capabilities*)', - ' echo \'{"features":{"runtime_identity_resolution":false,"credential_handoff":false},"scheduler_version":"0.0.0-test"}\'', + ' echo \'{"features":{"runtime_identity_resolution":false,"credential_handoff":false},"handoff_version":"3","scheduler_version":"0.0.0-test"}\'', ' ;;', ' *"jobs add"*|*jobs\\ add*)', ' echo \'{"ok":true}\'', @@ -9735,6 +9860,16 @@ test('child_credential_policy: downscope child with scope compiles OK', () => { id: 'stripe-live', provider: 'stripe-api-key', subject: { kind: 'service', principal: 'stripe:live' }, + auth: { + provider_config: { + key_strategy: 'precreated', + account_mode: 'test', + permission_sets: { + full: { key_env: 'STRIPE_KEY_FULL' }, + readonly: { key_env: 'STRIPE_KEY_READONLY' }, + }, + }, + }, trust: { level: 'supervised' }, }], workflows: [{ @@ -9774,6 +9909,16 @@ test('child_credential_policy: downscope child without scope produces error', () id: 'stripe-live', provider: 'stripe-api-key', subject: { kind: 'service', principal: 'stripe:live' }, + auth: { + provider_config: { + key_strategy: 'precreated', + account_mode: 'test', + permission_sets: { + full: { key_env: 'STRIPE_KEY_FULL' }, + readonly: { key_env: 'STRIPE_KEY_READONLY' }, + }, + }, + }, trust: { level: 'supervised' }, }], workflows: [{ @@ -9815,6 +9960,16 @@ test('child_credential_policy: child with own policy independent overrides inher id: 'stripe-live', provider: 'stripe-api-key', subject: { kind: 'service', principal: 'stripe:live' }, + auth: { + provider_config: { + key_strategy: 'precreated', + account_mode: 'test', + permission_sets: { + full: { key_env: 'STRIPE_KEY_FULL' }, + readonly: { key_env: 'STRIPE_KEY_READONLY' }, + }, + }, + }, trust: { level: 'supervised' }, }], workflows: [{ @@ -10077,7 +10232,8 @@ test('stripe-api-key resolveSession: returns error when env var is not set', () const ctx = { env: {}, cwd: '/tmp' }; const result = stripeApiKeyProvider.resolveSession(request, ctx); assert.strictEqual(result.ok, false); - assert.ok(/STRIPE_KEY_FULL/.test(result.error)); + assert.match(result.error, /Credential environment source is not set or is empty/); + assert.doesNotMatch(result.error, /STRIPE_KEY_FULL/); }); test('stripe-api-key resolveSession: rejects invalid key format', () => { @@ -10237,7 +10393,7 @@ test('stripe-api-key materialize: additional presentation bindings are included' target: { kind: 'env', name: 'MY_STRIPE_KEY' }, }, { - source: 'provider_assertions.scope', + source: 'credentials.api_key.scope', target: { kind: 'env', name: 'STRIPE_SCOPE' }, }, ], @@ -10247,6 +10403,16 @@ test('stripe-api-key materialize: additional presentation bindings are included' assert.strictEqual(result.env_vars['STRIPE_API_KEY'], 'sk_test_abc123def456'); assert.strictEqual(result.env_vars['MY_STRIPE_KEY'], 'sk_test_abc123def456'); assert.strictEqual(result.env_vars['STRIPE_SCOPE'], 'full'); + + assert.throws( + () => stripeApiKeyProvider.materialize(session, { + bindings: [{ + source: 'provider_assertions.scope', + target: { kind: 'env', name: 'AUDIT_ONLY_SCOPE' }, + }], + }, {}), + error => error.code === 'presentation_source_forbidden' + ); }); // -- prepareHandoff tests -- @@ -10439,7 +10605,7 @@ test('stripe-api-key validateDelegation: empty chain passes', () => { // -- describeSession tests -- -test('stripe-api-key describeSession: masks key values (shows prefix + last 4 chars)', () => { +test('stripe-api-key describeSession: fully redacts key values', () => { const session = { provider: 'stripe-api-key', credentials: { @@ -10456,8 +10622,8 @@ test('stripe-api-key describeSession: masks key values (shows prefix + last 4 ch }, }; const described = stripeApiKeyProvider.describeSession(session, {}); - assert.strictEqual(described.credentials.api_key.value, 'rk_live_...mnop'); - assert.strictEqual(described.credentials.api_key.scope, 'payments'); + assert.strictEqual(described.credentials.api_key.value, '[REDACTED]'); + assert.strictEqual(described.credentials.api_key.scope, '[REDACTED]'); assert.strictEqual(described.provider_assertions.account_mode, 'live'); }); @@ -10500,14 +10666,13 @@ test('stripe-api-key prepareHandoff: requires target_scope and parent_profile pe assert.strictEqual(result.session.credentials.api_key.value, 'rk_test_readonly_key_xyz789ab'); }); -test('v0.2 exec stripe-api-key handoff with downscope produces prepared session', async () => { +test('v0.2 local stripe-api-key handoff is rejected without resolving credentials', async () => { const manifest = { version: '0.2', identity_profiles: [{ id: 'stripe-handoff', provider: 'stripe-api-key', subject: { kind: 'service', principal: 'stripe:test' }, - scope: 'full', auth: { provider_config: { key_strategy: 'precreated', @@ -10543,20 +10708,19 @@ test('v0.2 exec stripe-api-key handoff with downscope produces prepared session' }], }; - const result = await executeTask(manifest, { - taskId: 'stripe-ho-task', - dryRun: true, - presentationDebug: true, - env: { - ...process.env, - STRIPE_HO_FULL: 'sk_test_handoff_full_key_123456', - STRIPE_HO_READONLY: 'rk_test_handoff_readonly_654321', - }, - }); - - assert.strictEqual(result.ok, true); - assert.strictEqual(result.handoff?.prepared, true); - assert.strictEqual(result.handoff?.mode, 'downscope'); + assert.throws( + () => executeTask(manifest, { + taskId: 'stripe-ho-task', + dryRun: true, + presentationDebug: true, + env: { + ...process.env, + STRIPE_HO_FULL: 'sk_test_handoff_full_key_123456', + STRIPE_HO_READONLY: 'rk_test_handoff_readonly_654321', + }, + }), + error => error.code === 'unsupported_capability' + ); }); // --------------------------------------------------------------------------- @@ -10650,7 +10814,8 @@ test('stripe-api-key resolveMasterKey: resolves from env', () => { test('stripe-api-key resolveMasterKey: returns error for missing env var', () => { const result = resolveMasterKey({ env: 'MISSING_VAR' }, {}, '/tmp'); assert.strictEqual(result.ok, false); - assert.ok(/MISSING_VAR/.test(result.error)); + assert.match(result.error, /Credential environment source is not set or is empty/); + assert.doesNotMatch(result.error, /MISSING_VAR/); }); test('stripe-api-key resolveMasterKey: returns error when no source specified', () => { @@ -10725,7 +10890,8 @@ test('stripe-api-key createRestrictedKey: API error returns structured error', a const result = await createRestrictedKey('sk_test_bad', { charges: 'read' }, mock.baseUrl); assert.strictEqual(result.ok, false); assert.strictEqual(result.transient, false); - assert.ok(/Invalid API Key/.test(result.error)); + assert.match(result.error, /Stripe API error creating restricted key \(HTTP 401\)/); + assert.doesNotMatch(result.error, /Invalid API Key/); } finally { await mock.close(); } @@ -10774,7 +10940,8 @@ test('stripe-api-key createRestrictedKey: missing id/secret in response returns try { const result = await createRestrictedKey('sk_test_key_xyz', { charges: 'read' }, mock.baseUrl); assert.strictEqual(result.ok, false); - assert.ok(/missing id or secret/.test(result.error)); + assert.match(result.error, /incomplete restricted-key response/); + assert.doesNotMatch(result.error, /\{.*object.*api_key/); } finally { await mock.close(); } @@ -10815,7 +10982,8 @@ test('stripe-api-key deleteRestrictedKey: API error returns structured error', a try { const result = await deleteRestrictedKey('sk_test_master', 'rk_test_missing', mock.baseUrl); assert.strictEqual(result.ok, false); - assert.ok(/No such API key/.test(result.error)); + assert.match(result.error, /Stripe API error deleting restricted key \(HTTP 404\)/); + assert.doesNotMatch(result.error, /No such API key/); } finally { await mock.close(); } @@ -10948,7 +11116,8 @@ test('stripe-api-key resolveSession dynamic: API failure propagates error', asyn }; const result = await stripeApiKeyProvider.resolveSession(request, ctx); assert.strictEqual(result.ok, false); - assert.ok(/Insufficient permissions/.test(result.error)); + assert.match(result.error, /Stripe API error creating restricted key \(HTTP 403\)/); + assert.doesNotMatch(result.error, /Insufficient permissions/); } finally { await mock.close(); } @@ -11070,7 +11239,7 @@ test('v0.2 exec stripe-api-key dynamic cleanup revokes minted key after executio } }); -test('v0.2 exec stripe-api-key dynamic handoff cleanup revokes prepared child key during dry-run', async () => { +test('v0.2 dry-run never mints or revokes dynamic stripe handoff credentials', async () => { let createCount = 0; const mock = await createMockStripeServer((req, res, _body) => { if (req.method === 'POST' && req.url === '/v1/api_keys') { @@ -11137,19 +11306,18 @@ test('v0.2 exec stripe-api-key dynamic handoff cleanup revokes prepared child ke }], }; - const result = await executeTask(manifest, { - taskId: 't', - dryRun: true, - env: { - ...process.env, - STRIPE_MASTER_KEY: 'sk_test_master_key_handoff_cleanup_12345', - }, - }); - - assert.strictEqual(result.ok, true); - assert.strictEqual(result.handoff?.prepared, true); - assert.strictEqual(mock.requests.filter(request => request.method === 'POST').length, 2); - assert.strictEqual(mock.requests.filter(request => request.method === 'DELETE').length, 2); + assert.throws( + () => executeTask(manifest, { + taskId: 't', + dryRun: true, + env: { + ...process.env, + STRIPE_MASTER_KEY: 'sk_test_master_key_handoff_cleanup_12345', + }, + }), + error => error.code === 'unsupported_capability' + ); + assert.strictEqual(mock.requests.length, 0); } finally { await mock.close(); } @@ -11217,7 +11385,8 @@ test('stripe-api-key cleanup: dynamic session with API failure returns warning', assert.strictEqual(result.cleaned, true); assert.ok(Array.isArray(result.warnings)); assert.ok(result.warnings.length > 0); - assert.ok(result.warnings[0].includes('Internal error')); + assert.match(result.warnings[0], /Stripe API error deleting restricted key \(HTTP 500\)/); + assert.doesNotMatch(result.warnings[0], /Internal error/); } finally { await mock.close(); } @@ -11394,7 +11563,8 @@ test('stripe-api-key prepareHandoff dynamic: API failure during minting returns const ctx = { env: { STRIPE_MASTER: 'sk_test_handoff_api_fail_master' }, cwd: '/tmp' }; const result = await stripeApiKeyProvider.prepareHandoff(parentSession, handoff, ctx); assert.strictEqual(result.prepared, false); - assert.ok(/Service unavailable/.test(result.error)); + assert.match(result.error, /Stripe API error creating restricted key \(HTTP 503\)/); + assert.doesNotMatch(result.error, /Service unavailable/); } finally { await mock.close(); } @@ -11554,7 +11724,7 @@ test('stripe-api-key materialize: dynamic session sets cleanup_required true', ( // -- describeSession: dynamic session masks key and preserves stripe_key_id -- -test('stripe-api-key describeSession: dynamic session masks key but keeps metadata', () => { +test('stripe-api-key describeSession: dynamic session redacts key but keeps metadata', () => { const session = { provider: 'stripe-api-key', credentials: { @@ -11573,8 +11743,8 @@ test('stripe-api-key describeSession: dynamic session masks key but keeps metada }, }; const described = stripeApiKeyProvider.describeSession(session, {}); - assert.strictEqual(described.credentials.api_key.value, 'rk_test_..._xyz'); - assert.strictEqual(described.provider_assertions.stripe_key_id, 'rk_test_desc_key_id'); + assert.strictEqual(described.credentials.api_key.value, '[REDACTED]'); + assert.strictEqual(described.provider_assertions.stripe_key_id, '[REDACTED]'); assert.strictEqual(described.provider_assertions.key_strategy, 'dynamic'); }); diff --git a/test/approvals.test.js b/test/approvals.test.js index 25011d1..3e2ab6c 100644 --- a/test/approvals.test.js +++ b/test/approvals.test.js @@ -1,9 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, readFileSync, writeFileSync, appendFileSync, existsSync, rmSync, mkdirSync, utimesSync } from 'node:fs'; +import { mkdtempSync, readFileSync, writeFileSync, appendFileSync, existsSync, rmSync, mkdirSync, statSync, symlinkSync, utimesSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { Worker } from 'node:worker_threads'; +import { spawnSync } from 'node:child_process'; import { grantApproval, @@ -60,6 +61,30 @@ function isolatedEnv() { }; } +function createEphemeralSshKey(directory) { + const keyPath = join(directory, 'approval-signing-key'); + const generated = spawnSync('ssh-keygen', [ + '-q', '-t', 'ed25519', '-N', '', '-f', keyPath, + ], { encoding: 'utf8' }); + assert.equal( + generated.status, + 0, + `ssh-keygen failed: ${generated.stderr || generated.error?.message || 'unknown error'}` + ); + return keyPath; +} + +function trustEphemeralSshKey({ env, keyPath, principal }) { + const paths = getAgentcliPaths({ env }); + mkdirSync(paths.state, { recursive: true }); + const publicKey = readFileSync(`${keyPath}.pub`, 'utf8').trim(); + writeFileSync(paths.allowed_signers, `${principal} ${publicKey}\n`, { + encoding: 'utf8', + mode: 0o600, + }); + return paths; +} + test('policy predicates', () => { assert.equal(approvalPolicyRequiresApproval({ policy: 'manual' }), true); assert.equal(approvalPolicyRequiresApproval({ required: true }), true); @@ -92,8 +117,11 @@ test('grant writes a pending approval; list + find work', () => { const { env, cleanup } = isolatedEnv(); try { const m = makeManifest({ approval: { policy: 'manual', risk_level: 'high' } }); - const task = m.workflows[0].tasks[0]; - const taskHash = computeTaskApprovalHash({ workflowId: 'test-wf', task }); + const taskHash = computeTaskApprovalHash({ + manifest: m, + workflowId: 'test-wf', + taskId: 'echo-task', + }); const rec = grantApproval({ manifest: m, @@ -118,6 +146,36 @@ test('grant writes a pending approval; list + find work', () => { }); assert.ok(found); assert.equal(found.approval_id, rec.approval_id); + if (process.platform !== 'win32') { + const paths = getAgentcliPaths({ env }); + assert.equal(statSync(paths.state).mode & 0o777, 0o700); + assert.equal(statSync(paths.approvals).mode & 0o777, 0o600); + } + } finally { + cleanup(); + } +}); + +test('approval writes refuse symbolic-link log destinations', { skip: process.platform === 'win32' }, () => { + const { home, env, cleanup } = isolatedEnv(); + try { + const paths = getAgentcliPaths({ env }); + mkdirSync(paths.state, { recursive: true }); + const target = join(home, 'outside-approvals.ndjson'); + writeFileSync(target, 'unchanged\n', 'utf8'); + symlinkSync(target, paths.approvals); + const manifest = makeManifest({ approval: { policy: 'manual', risk_level: 'high' } }); + assert.throws( + () => grantApproval({ + manifest, + taskId: 'echo-task', + approver: 'alice', + signer: 'none', + env, + }), + error => error.code === 'ELOOP' || error.code === 'EACCES' + ); + assert.equal(readFileSync(target, 'utf8'), 'unchanged\n'); } finally { cleanup(); } @@ -449,6 +507,82 @@ test('verifyApprovalSignature returns unsigned for signer=none grants', () => { } }); +test('unexpected unsigned approval records are rejected', () => { + const check = verifyApprovalSignature({ + approval_id: 'unsigned', + workflow_id: 'test-wf', + task_id: 'echo-task', + task_hash: 'sha256:deadbeef', + approver: 'alice', + granted_at: new Date().toISOString(), + expires_at: new Date(Date.now() + 1000).toISOString(), + signature: null, + }); + assert.equal(check.verified, false); + assert.match(check.reason, /unexpectedly unsigned/); +}); + +test('approval signing failure does not silently write an unsigned grant', () => { + const { env, cleanup } = isolatedEnv(); + try { + delete env.AGENTCLI_SIGNER; + const m = makeManifest({ approval: { policy: 'manual', risk_level: 'high' } }); + assert.throws( + () => grantApproval({ + manifest: m, + taskId: 'echo-task', + approver: 'alice', + signer: 'ssh', + signingKey: '/definitely/missing/agentcli-key', + env, + }), + error => error.code === 'approval_signature_invalid' + ); + assert.deepEqual(listApprovals({ env }), []); + } finally { + cleanup(); + } +}); + +test('approver scope and manifest timeout are enforced when granting', () => { + const { env, cleanup } = isolatedEnv(); + try { + const m = makeManifest({ + approval: { + policy: 'manual', + risk_level: 'high', + approver_scope: 'domain:example.com', + timeout_s: 30, + }, + }); + assert.throws( + () => grantApproval({ manifest: m, taskId: 'echo-task', approver: 'alice@other.test', env }), + error => error.code === 'approval_scope_mismatch' + ); + assert.throws( + () => grantApproval({ + manifest: m, + taskId: 'echo-task', + approver: 'alice@example.com', + ttlS: 31, + env, + }), + error => error.code === 'invalid_argument' + ); + const rec = grantApproval({ + manifest: m, + taskId: 'echo-task', + approver: 'alice@example.com', + env, + now: 1_000, + }); + assert.equal(rec.approver_scope, 'domain:example.com'); + assert.equal(Date.parse(rec.expires_at) - Date.parse(rec.granted_at), 30_000); + } finally { + cleanup(); + } +}); + test('corrupted approvals.ndjson does not DoS subsequent exec', async () => { const { env, cleanup } = isolatedEnv(); try { @@ -479,50 +613,39 @@ test('corrupted approvals.ndjson does not DoS subsequent exec', async () => { } }); -test('ssh-signed grant: round-trip with allowed_signers auto-bootstrap', async (t) => { - // End-to-end: approve with signer=ssh on a fresh AGENTCLI_HOME (no - // allowed_signers file yet), then exec and confirm the auto-bootstrap kicks - // in and signature_verified is true. If the test host has no SSH key - // available, skip gracefully. - const { existsSync: fsExists } = await import('node:fs'); - const { homedir } = await import('node:os'); - const sshCandidates = ['id_ed25519', 'id_ecdsa', 'id_rsa'] - .map(k => join(homedir(), '.ssh', k)) - .filter(p => fsExists(p) && fsExists(`${p}.pub`)); - if (sshCandidates.length === 0) { - t.skip('no local SSH key pair found; skipping signed round-trip'); - return; - } +test('ssh-signed grant round-trips with an isolated explicit trust store', async () => { + // End-to-end: approve with signer=ssh, trust the generated public key, then + // execute and verify without reading a developer's personal SSH identity. const home = mkdtempSync(join(tmpdir(), 'agentcli-approval-signed-')); const env = { ...process.env, AGENTCLI_HOME: home }; delete env.AGENTCLI_SIGNER; // use default (ssh) try { + const signingKey = createEphemeralSshKey(home); const m = makeManifest({ approval: { policy: 'manual', risk_level: 'high' } }); const rec = grantApproval({ manifest: m, taskId: 'echo-task', approver: 'alice', signer: 'ssh', + signingKey, env, }); assert.ok(rec.signature, 'grant should carry a signature'); assert.equal(rec.signature.method, 'ssh-signature'); - // Pre-condition: allowed_signers file should NOT exist yet - const paths = getAgentcliPaths({ env }); - assert.equal(existsSync(paths.allowed_signers), false, 'allowed_signers should not pre-exist'); + const paths = trustEphemeralSshKey({ env, keyPath: signingKey, principal: 'alice' }); + assert.equal(existsSync(paths.allowed_signers), true); - // Run the gated task; verifyApprovalSignature should auto-bootstrap - // allowed_signers and verify cleanly. const result = await executeTask(m, { taskId: 'echo-task', env }); assert.equal(result.ok, true); assert.ok(result.approval_used); assert.equal(result.approval_used.approval_id, rec.approval_id); assert.equal(result.approval_used.signature_verified, true); - // Post-condition: allowed_signers was generated - assert.equal(existsSync(paths.allowed_signers), true, 'allowed_signers should have been bootstrapped'); + if (process.platform !== 'win32') { + assert.equal(statSync(paths.allowed_signers).mode & 0o777, 0o600); + } } finally { rmSync(home, { recursive: true, force: true }); } @@ -563,8 +686,11 @@ test('concurrency: N parallel claims on one grant serialize to exactly one winne const { env, cleanup } = isolatedEnv(); try { const m = makeManifest({ approval: { policy: 'manual', risk_level: 'medium' } }); - const task = m.workflows[0].tasks[0]; - const taskHash = computeTaskApprovalHash({ workflowId: 'test-wf', task }); + const taskHash = computeTaskApprovalHash({ + manifest: m, + workflowId: 'test-wf', + taskId: 'echo-task', + }); const rec = grantApproval({ manifest: m, taskId: 'echo-task', @@ -605,8 +731,11 @@ test('concurrency: two pending grants + two concurrent claims → both succeed w const { env, cleanup } = isolatedEnv(); try { const m = makeManifest({ approval: { policy: 'manual', risk_level: 'medium' } }); - const task = m.workflows[0].tasks[0]; - const taskHash = computeTaskApprovalHash({ workflowId: 'test-wf', task }); + const taskHash = computeTaskApprovalHash({ + manifest: m, + workflowId: 'test-wf', + taskId: 'echo-task', + }); const a = grantApproval({ manifest: m, taskId: 'echo-task', approver: 'alice', env }); const b = grantApproval({ manifest: m, taskId: 'echo-task', approver: 'bob', env }); @@ -659,21 +788,12 @@ test('concurrency: stale lock is broken and claim proceeds', () => { } }); -test('tamper: edit to approver/reason/expires_at in ndjson fails verification', async (t) => { - const { existsSync: fsExists } = await import('node:fs'); - const { homedir } = await import('node:os'); - const sshCandidates = ['id_ed25519', 'id_ecdsa', 'id_rsa'] - .map(k => join(homedir(), '.ssh', k)) - .filter(p => fsExists(p) && fsExists(`${p}.pub`)); - if (sshCandidates.length === 0) { - t.skip('no local SSH key pair found; skipping signed tamper test'); - return; - } - +test('tamper: edit to approver/reason/expires_at in ndjson fails verification', async () => { const home = mkdtempSync(join(tmpdir(), 'agentcli-approval-tamper-')); const env = { ...process.env, AGENTCLI_HOME: home }; delete env.AGENTCLI_SIGNER; try { + const signingKey = createEphemeralSshKey(home); const m = makeManifest({ approval: { policy: 'manual', risk_level: 'high' } }); const rec = grantApproval({ manifest: m, @@ -681,11 +801,12 @@ test('tamper: edit to approver/reason/expires_at in ndjson fails verification', approver: 'alice', reason: 'original reason', signer: 'ssh', + signingKey, env, }); assert.ok(rec.signature, 'grant should be signed'); - const paths = getAgentcliPaths({ env }); + const paths = trustEphemeralSshKey({ env, keyPath: signingKey, principal: 'alice' }); const raw = readFileSync(paths.approvals, 'utf8').trim().split('\n'); const grantEvent = JSON.parse(raw[0]); diff --git a/test/cli-rpc-validation.test.js b/test/cli-rpc-validation.test.js new file mode 100644 index 0000000..f4e8cf6 --- /dev/null +++ b/test/cli-rpc-validation.test.js @@ -0,0 +1,494 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { runCli } from '../src/cli.js'; +import { handleJsonRpcRequest } from '../src/jsonrpc.js'; +import { writeJsonOutput } from '../src/io.js'; +import { JSON_SCHEMAS, MANIFEST_JSON_SCHEMA, MANIFEST_SCHEMA } from '../src/schema.js'; +import { validateManifest } from '../src/validate.js'; + +function validManifest(overrides = {}) { + return { + version: '0.2', + workflows: [{ + id: 'workflow', + name: 'Workflow', + tasks: [{ + id: 'task', + name: 'Task', + prompt: 'Run the task.', + target: { session_target: 'isolated' }, + schedule: { cron: '0 * * * *' }, + }], + }], + ...overrides, + }; +} + +function governanceInspectionManifest(markerPath) { + return { + version: '0.2', + identity_profiles: [{ + id: 'operator', + provider: 'env-bearer', + subject: { kind: 'service', principal: 'agent://test/operator' }, + auth: { + required: true, + provider_config: { token_env: 'INSPECTION_TOKEN' }, + }, + trust: { level: 'supervised' }, + }], + authorization_profiles: [{ id: 'permit', provider: 'none' }], + workflows: [{ + id: 'workflow', + name: 'Workflow', + identity: { ref: 'operator' }, + authorization: { ref: 'permit' }, + tasks: [{ + id: 'task', + name: 'Task', + shell: { + program: process.execPath, + args: ['-e', `require('node:fs').writeFileSync(${JSON.stringify(markerPath)}, 'executed')`], + }, + target: { session_target: 'shell' }, + schedule: { cron: '0 * * * *' }, + contract: { audit: 'none' }, + }], + }], + }; +} + +test('v0.2 validation rejects unknown governed keys at every nested level', () => { + const manifest = validManifest(); + manifest.workflows[0].tasks[0].approval = { polciy: 'manual' }; + manifest.workflows[0].tasks[0].contract = { sandbxo: 'strict' }; + manifest.workflows[0].tasks[0].identity = { + subject: { pricipal: 'operator' }, + }; + + const result = validateManifest(manifest); + assert.equal(result.ok, false); + assert.deepEqual( + result.errors.filter(error => /unknown key/.test(error.message)).map(error => error.path).sort(), + [ + '$.workflows[0].tasks[0].approval.polciy', + '$.workflows[0].tasks[0].contract.sandbxo', + '$.workflows[0].tasks[0].identity.subject.pricipal', + ] + ); +}); + +test('v0.2 validation keeps deliberate provider and claims extension maps open', () => { + const manifest = validManifest({ + identity_profiles: [{ + id: 'identity', + provider: 'none', + provider_config: { vendor_extension: { arbitrary: true } }, + subject: { attributes: { team: 'platform', nested: { allowed: true } } }, + auth: { + provider_config: { vendor_option: 'value' }, + inputs: { credential: { value_from: { env: 'CREDENTIAL' } } }, + }, + }], + authorization_proof_profiles: [{ + id: 'proof', + method: 'none', + claims: { custom_claim: { required: true } }, + }], + }); + manifest.workflows[0].identity = { ref: 'identity' }; + manifest.workflows[0].authorization_proof = { ref: 'proof', claims: { runtime_claim: 1 } }; + + const result = validateManifest(manifest); + assert.equal(result.ok, true, JSON.stringify(result.errors)); +}); + +test('profile provider existence and synchronous structural validation are enforced', () => { + const unknown = validManifest({ + identity_profiles: [{ id: 'identity', provider: 'does-not-exist' }], + }); + unknown.workflows[0].identity = { ref: 'identity' }; + const unknownResult = validateManifest(unknown); + assert.equal(unknownResult.ok, false); + assert.ok(unknownResult.errors.some(error => /unknown identity provider/.test(error.message))); + + const insecureOidc = validManifest({ + identity_profiles: [{ + id: 'identity', + provider: 'oidc-client-credentials', + auth: { + provider_config: { + token_endpoint: 'http://issuer.example/token', + client_id: 'agentcli', + }, + inputs: { client_secret: { value_from: { env: 'OIDC_CLIENT_SECRET' } } }, + }, + }], + }); + insecureOidc.workflows[0].identity = { ref: 'identity' }; + const oidcResult = validateManifest(insecureOidc); + assert.equal(oidcResult.ok, false); + assert.ok(oidcResult.errors.some(error => /must use HTTPS/i.test(error.message))); +}); + +test('authorization proof values cannot be embedded as circular literals', () => { + const manifest = validManifest({ + authorization_proof_profiles: [{ + id: 'proof', + method: 'jwt', + public_key: 'not-a-valid-key', + proof: { value_from: { literal: 'signed-value' } }, + }], + }); + manifest.workflows[0].authorization_proof = { ref: 'proof' }; + + const result = validateManifest(manifest); + assert.equal(result.ok, false); + assert.ok(result.errors.some(error => ( + error.path.endsWith('.proof.value_from.literal') && /not supported/.test(error.message) + ))); +}); + +test('schema API exports Draft 2020-12 and retains the legacy descriptor API', async () => { + assert.equal(MANIFEST_JSON_SCHEMA.$schema, 'https://json-schema.org/draft/2020-12/schema'); + assert.equal(MANIFEST_JSON_SCHEMA.type, 'object'); + assert.equal(MANIFEST_JSON_SCHEMA.additionalProperties, false); + assert.ok(MANIFEST_JSON_SCHEMA.$defs.task); + assert.equal(JSON_SCHEMAS.task.$schema, MANIFEST_JSON_SCHEMA.$schema); + assert.equal(MANIFEST_SCHEMA.manifest.fields.version.const, '0.2'); + + const standard = JSON.parse(await runCli(['schema', 'manifest'])); + assert.equal(standard.schema_format, 'json-schema-draft-2020-12'); + assert.equal(standard.schema.$schema, MANIFEST_JSON_SCHEMA.$schema); + assert.equal(standard.schema.additionalProperties, false); + + const legacy = JSON.parse(await runCli(['schema', 'manifest', '--legacy'])); + assert.equal(legacy.schema_format, 'agentcli-legacy'); + assert.equal(legacy.schema.fields.version.const, '0.2'); +}); + +test('strict CLI parsing distinguishes boolean flags from value flags', async () => { + const version = JSON.parse(await runCli(['--json', 'version'])); + assert.equal(version.ok, true); + + const compile = JSON.parse(await runCli([ + 'compile', + '--explain', + JSON.stringify(validManifest()), + ])); + assert.equal(compile.ok, true); + assert.ok(Array.isArray(compile.output.explain)); + + await assert.rejects( + runCli(['version', '--unknown']), + error => error.code === 'invalid_argument' && /Unknown flag/.test(error.message) + ); + await assert.rejects( + runCli(['version', '--json=false']), + error => error.code === 'invalid_argument' && /does not accept a value/.test(error.message) + ); + await assert.rejects( + runCli(['compile', JSON.stringify(validManifest()), '--target']), + error => error.code === 'invalid_argument' && /requires a value/.test(error.message) + ); + await assert.rejects( + runCli(['version', '--json', 'false']), + error => error.code === 'invalid_argument' && /positional argument/.test(error.message) + ); +}); + +test('runCli preserves validation-result compatibility while process mode throws', async () => { + const manifest = { version: '0.2', workflows: [] }; + const libraryResult = JSON.parse(await runCli(['validate', JSON.stringify(manifest)])); + assert.equal(libraryResult.ok, false); + + await assert.rejects( + runCli(['validate', JSON.stringify(manifest)], { throwOnValidationFailure: true }), + error => ( + error.code === 'validation_error' && + error.validation?.ok === false && + Array.isArray(error.validation.errors) + ) + ); +}); + +test('binary validate exits nonzero and writes only a structured validation error to stderr', () => { + const result = spawnSync( + process.execPath, + ['bin/agentcli.js', 'validate', '{"version":"0.2","workflows":[]}', '--json'], + { cwd: process.cwd(), encoding: 'utf8' } + ); + + assert.equal(result.status, 1); + assert.equal(result.stdout, ''); + const error = JSON.parse(result.stderr); + assert.equal(error.ok, false); + assert.equal(error.error_type, 'validation_error'); + assert.equal(error.code, 'validation_error'); + assert.equal(error.validation.ok, false); +}); + +test('binary errors use stable categories and a separate detailed code', () => { + const result = spawnSync( + process.execPath, + ['bin/agentcli.js', 'validate', 'missing-manifest.json'], + { cwd: process.cwd(), encoding: 'utf8' } + ); + + assert.equal(result.status, 1); + const error = JSON.parse(result.stderr); + assert.equal(error.error_type, 'invalid_argument'); + assert.equal(error.code, 'invalid_argument'); + assert.match(error.error, /Input not found/); +}); + +test('JSON-RPC uses documented result envelopes and stable error data codes', async () => { + const schema = await handleJsonRpcRequest({ + jsonrpc: '2.0', + id: 'schema', + method: 'agentcli.schema', + params: { target: 'manifest' }, + }); + assert.equal(schema.result.ok, true); + assert.equal(schema.result.schema_format, 'json-schema-draft-2020-12'); + + const converted = await handleJsonRpcRequest({ + jsonrpc: '2.0', + id: 'convert', + method: 'agentcli.convert', + params: { manifest: { ...validManifest(), version: '0.1' } }, + }); + assert.equal(converted.result.ok, true); + assert.equal(converted.result.manifest.version, '0.2'); + + const invalid = await handleJsonRpcRequest({ + jsonrpc: '2.0', + id: 'compile', + method: 'agentcli.compile', + params: { manifest: { version: '0.2', workflows: [] } }, + }); + assert.equal(invalid.error.code, -32602); + assert.equal(invalid.error.data.code, 'validation_error'); + assert.equal(invalid.error.data.error_type, 'validation_error'); + assert.equal(invalid.error.data.validation.ok, false); + + const unknown = await handleJsonRpcRequest({ + jsonrpc: '2.0', + id: 'unknown', + method: 'agentcli.notThere', + }); + assert.equal(unknown.error.code, -32601); + assert.equal(unknown.error.data.code, 'unknown_command'); +}); + +test('JSON-RPC exposes read-only discovery and inspection methods', async (t) => { + const home = mkdtempSync(join(tmpdir(), 'agentcli-rpc-')); + t.after(() => rmSync(home, { recursive: true, force: true })); + const defaults = { env: { ...process.env, AGENTCLI_HOME: home } }; + + const targets = await handleJsonRpcRequest({ + jsonrpc: '2.0', id: 1, method: 'agentcli.targets', + }, defaults); + assert.equal(targets.result.ok, true); + assert.ok(targets.result.targets.some(target => target.name === 'standalone')); + + const paths = await handleJsonRpcRequest({ + jsonrpc: '2.0', id: 2, method: 'agentcli.paths', + }, defaults); + assert.equal(paths.result.ok, true); + assert.equal(paths.result.paths.root, home); + + const audit = await handleJsonRpcRequest({ + jsonrpc: '2.0', id: 3, method: 'agentcli.audit', + }, defaults); + assert.deepEqual(audit.result, { ok: true, count: 0, records: [], warnings: [] }); + + const approvals = await handleJsonRpcRequest({ + jsonrpc: '2.0', id: 4, method: 'agentcli.approvals.list', + }, defaults); + assert.equal(approvals.result.ok, true); + assert.equal(approvals.result.count, 0); +}); + +test('CLI and JSON-RPC governance inspection resolve state without executing the task', async (t) => { + const workdir = mkdtempSync(join(tmpdir(), 'agentcli-governance-inspection-')); + t.after(() => rmSync(workdir, { recursive: true, force: true })); + const marker = join(workdir, 'task-executed'); + const manifest = governanceInspectionManifest(marker); + const env = { ...process.env, INSPECTION_TOKEN: 'inspection-secret' }; + + const cliIdentity = JSON.parse(await runCli([ + 'identity', 'resolve', JSON.stringify(manifest), 'task', + ], { cwd: workdir, env })); + assert.equal(cliIdentity.principal_used, 'agent://test/operator'); + assert.equal(cliIdentity.resolved_identity.credentials.access_token.value, '[REDACTED]'); + + const cliDelegation = JSON.parse(await runCli([ + 'identity', 'validate-delegation', JSON.stringify(manifest), 'task', + ], { cwd: workdir, env })); + assert.equal(cliDelegation.delegation.valid, true); + assert.equal(cliDelegation.delegation.depth, 1); + + const cliAuthorization = JSON.parse(await runCli([ + 'authorization', 'evaluate', JSON.stringify(manifest), 'task', + ], { cwd: workdir, env })); + assert.equal(cliAuthorization.authorization.decision, 'permit'); + + const cliWhoami = JSON.parse(await runCli([ + 'whoami', JSON.stringify(manifest), 'task', + ], { cwd: workdir, env })); + assert.equal(cliWhoami.principal_used, 'agent://test/operator'); + + const rpcDefaults = { cwd: workdir, env }; + const rpcIdentity = await handleJsonRpcRequest({ + jsonrpc: '2.0', + id: 'identity', + method: 'agentcli.identity.resolve', + params: { manifest, taskId: 'task' }, + }, rpcDefaults); + assert.equal(rpcIdentity.result.principal_used, 'agent://test/operator'); + assert.equal(rpcIdentity.result.resolved_identity.credentials.access_token.value, '[REDACTED]'); + + const rpcDelegation = await handleJsonRpcRequest({ + jsonrpc: '2.0', + id: 'delegation', + method: 'agentcli.identity.validateDelegation', + params: { manifest, taskId: 'task' }, + }, rpcDefaults); + assert.equal(rpcDelegation.result.delegation.valid, true); + + const rpcAuthorization = await handleJsonRpcRequest({ + jsonrpc: '2.0', + id: 'authorization', + method: 'agentcli.authorization.evaluate', + params: { manifest, taskId: 'task' }, + }, rpcDefaults); + assert.equal(rpcAuthorization.result.authorization.decision, 'permit'); + assert.equal(existsSync(marker), false); +}); + +test('authorization-proof inspection resolves its command source without executing the task', async (t) => { + const workdir = mkdtempSync(join(tmpdir(), 'agentcli-proof-inspection-')); + t.after(() => rmSync(workdir, { recursive: true, force: true })); + const proofMarker = join(workdir, 'proof-command-executed'); + const taskMarker = join(workdir, 'task-executed'); + const proofScript = join(workdir, 'proof-command.cjs'); + writeFileSync( + proofScript, + `require('node:fs').writeFileSync(${JSON.stringify(proofMarker)}, 'resolved'); process.stdout.write('proof-value');`, + 'utf8' + ); + + const manifest = { + version: '0.2', + authorization_proof_profiles: [{ + id: 'proof', + method: 'none', + proof: { value_from: { command: `${JSON.stringify(process.execPath)} ${JSON.stringify(proofScript)}` } }, + }], + workflows: [{ + id: 'workflow', + name: 'Workflow', + authorization_proof: { ref: 'proof' }, + tasks: [{ + id: 'task', + name: 'Task', + shell: { + program: process.execPath, + args: ['-e', `require('node:fs').writeFileSync(${JSON.stringify(taskMarker)}, 'executed')`], + }, + target: { session_target: 'shell' }, + schedule: { cron: '0 * * * *' }, + contract: { audit: 'none' }, + }], + }], + }; + + const cli = JSON.parse(await runCli([ + 'authorization-proof', 'verify', JSON.stringify(manifest), 'task', + ], { cwd: workdir })); + assert.equal(cli.authorization_proof.method, 'none'); + assert.equal(cli.authorization_proof.verified, false); + assert.match(cli.effective_task_hash, /^sha256:/); + assert.match(cli.manifest_digest, /^sha256:/); + assert.equal(existsSync(proofMarker), true); + assert.equal(existsSync(taskMarker), false); + + rmSync(proofMarker); + const rpc = await handleJsonRpcRequest({ + jsonrpc: '2.0', + id: 'proof', + method: 'agentcli.authorizationProof.verify', + params: { manifest, taskId: 'task' }, + }, { cwd: workdir, env: process.env }); + assert.equal(rpc.result.authorization_proof.method, 'none'); + assert.equal(rpc.result.authorization_proof.verified, false); + assert.match(rpc.result.effective_task_hash, /^sha256:/); + assert.match(rpc.result.manifest_digest, /^sha256:/); + assert.equal(existsSync(proofMarker), true); + assert.equal(existsSync(taskMarker), false); +}); + +test('audit inspection reports malformed line numbers without echoing raw content', async (t) => { + const home = mkdtempSync(join(tmpdir(), 'agentcli-audit-malformed-')); + t.after(() => rmSync(home, { recursive: true, force: true })); + const state = join(home, 'state'); + mkdirSync(state, { recursive: true }); + writeFileSync( + join(state, 'audit.ndjson'), + '{"execution_id":"valid","timestamp":"now"}\nsecret malformed content\n', + 'utf8' + ); + + const output = JSON.parse(await runCli(['audit'], { + env: { ...process.env, AGENTCLI_HOME: home }, + })); + assert.equal(output.count, 1); + assert.deepEqual(output.warnings, [{ + line_number: 2, + message: 'malformed audit record skipped', + }]); + assert.equal(JSON.stringify(output).includes('secret malformed content'), false); +}); + +test('safe JSON output rejects parent symlinks that escape cwd', (t) => { + if (process.platform === 'win32') { + t.skip('symlink creation requires platform-specific privileges'); + return; + } + const base = mkdtempSync(join(tmpdir(), 'agentcli-output-base-')); + const outside = mkdtempSync(join(tmpdir(), 'agentcli-output-outside-')); + t.after(() => { + rmSync(base, { recursive: true, force: true }); + rmSync(outside, { recursive: true, force: true }); + }); + + symlinkSync(outside, join(base, 'escape')); + assert.throws( + () => writeJsonOutput('escape/result.json', { secret: false }, { cwd: base }), + error => error.code === 'invalid_argument' && /symlink outside/.test(error.message) + ); + assert.equal(existsSync(join(outside, 'result.json')), false); +}); + +test('safe JSON output creates mode-restricted files inside cwd', (t) => { + const base = mkdtempSync(join(tmpdir(), 'agentcli-output-')); + t.after(() => rmSync(base, { recursive: true, force: true })); + + const written = writeJsonOutput('nested/result.json', { ok: true }, { cwd: base }); + assert.equal(written, join(base, 'nested', 'result.json')); + assert.deepEqual(JSON.parse(readFileSync(written, 'utf8')), { ok: true }); +}); diff --git a/test/exec-ordering.test.js b/test/exec-ordering.test.js new file mode 100644 index 0000000..5901c38 --- /dev/null +++ b/test/exec-ordering.test.js @@ -0,0 +1,382 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + existsSync, + mkdtempSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { + evaluateTaskAuthorization, + executeTask, + inspectTaskIdentity, + verifyTaskAuthorizationProof, +} from '../src/exec.js'; +import { grantApproval } from '../src/approvals.js'; +import { getAgentcliPaths } from '../src/home.js'; +import { registerProvider as registerIdentityProvider } from '../src/identity/index.js'; +import { registerAuthorizationProvider } from '../src/authorization/index.js'; + +function isolatedEnvironment(prefix) { + const root = mkdtempSync(join(tmpdir(), prefix)); + return { + root, + env: { ...process.env, AGENTCLI_HOME: join(root, 'home'), AGENTCLI_SIGNER: 'none' }, + cleanup: () => rmSync(root, { recursive: true, force: true }), + }; +} + +function proofCommandManifest(marker, approval = { policy: 'manual', risk_level: 'high' }) { + return { + version: '0.2', + authorization_proof_profiles: [{ + id: 'command-proof', + method: 'none', + proof: { + value_from: { + command: `printf ran > ${JSON.stringify(marker)}`, + }, + }, + verify: { required: false }, + }], + workflows: [{ + id: 'ops', + name: 'Ops', + tasks: [{ + id: 'dangerous', + name: 'Dangerous', + target: { session_target: 'shell' }, + shell: { program: 'printf', args: ['ok'] }, + schedule: { cron: '0 * * * *' }, + approval, + authorization_proof: { ref: 'command-proof' }, + contract: { sandbox: 'permissive', network: 'unrestricted', audit: 'always' }, + }], + }], + }; +} + +test('manual approval is enforced before proof value_from.command', async () => { + const { root, env, cleanup } = isolatedEnvironment('agentcli-order-proof-'); + const marker = join(root, 'proof-ran'); + try { + const manifest = proofCommandManifest(marker); + await assert.rejects( + executeTask(manifest, { taskId: 'dangerous', env, signer: 'none' }), + error => error.code === 'approval_required' + ); + assert.equal(existsSync(marker), false); + } finally { + cleanup(); + } +}); + +test('dry-run does not resolve proof commands, sign, or write audit records', async () => { + const { root, env, cleanup } = isolatedEnvironment('agentcli-order-dry-'); + const marker = join(root, 'proof-ran'); + try { + const manifest = proofCommandManifest(marker); + const result = await executeTask(manifest, { + taskId: 'dangerous', + dryRun: true, + env, + signer: 'none', + }); + assert.equal(result.ok, true); + assert.equal(result.dry_run, true); + assert.equal(result.phases.authorization_proof, 'skipped'); + assert.equal(result.phases.audit, 'skipped'); + assert.equal(existsSync(marker), false); + assert.equal(existsSync(getAgentcliPaths({ env }).audit), false); + } finally { + cleanup(); + } +}); + +test('manual approval is enforced before identity provider resolution', async () => { + const { root, env, cleanup } = isolatedEnvironment('agentcli-order-identity-'); + const marker = join(root, 'resolved'); + const providerName = `ordering-provider-${process.pid}-${Date.now()}`; + registerIdentityProvider({ + name: providerName, + capabilities: { + auth_modes: ['service'], credential_types: [], presentation_kinds: ['none'], + handoff_modes: ['none'], refreshable: false, delegation: false, + trust_levels: ['supervised'], approval_mechanisms: [], + }, + validateProfile: () => ({ valid: true }), + resolveSession: () => { + writeFileSync(marker, 'resolved'); + return { + provider: providerName, + subject: { principal: 'test', issuer: null, run_as: null }, + trust: { declared_level: 'supervised', effective_level: 'supervised' }, + delegation_validation: { valid: true }, + credentials: {}, + provider_assertions: {}, + }; + }, + describeSession: session => ({ provider: session.provider, subject: session.subject }), + materialize: () => ({ materialized: false, env_vars: {}, cleanup_required: false }), + cleanup: () => ({ cleaned: true, warnings: [] }), + }); + + const manifest = { + version: '0.2', + identity_profiles: [{ id: 'identity', provider: providerName }], + workflows: [{ + id: 'ops', name: 'Ops', tasks: [{ + id: 'task', name: 'Task', target: { session_target: 'shell' }, + shell: { program: 'printf', args: ['ok'] }, + schedule: { cron: '0 * * * *' }, + approval: { policy: 'manual', risk_level: 'high' }, + identity: { ref: 'identity' }, + contract: { sandbox: 'permissive', network: 'unrestricted', audit: 'none' }, + }], + }], + }; + + try { + await assert.rejects( + executeTask(manifest, { taskId: 'task', env, signer: 'none' }), + error => error.code === 'approval_required' + ); + assert.equal(existsSync(marker), false); + } finally { + cleanup(); + } +}); + +test('materialized credentials are cleaned when post-execution verify fails', async () => { + const { root, env, cleanup } = isolatedEnvironment('agentcli-order-cleanup-'); + const credentialFile = join(root, 'credential'); + const providerName = `cleanup-provider-${process.pid}-${Date.now()}`; + registerIdentityProvider({ + name: providerName, + capabilities: { + auth_modes: ['service'], credential_types: ['token'], presentation_kinds: ['file'], + handoff_modes: ['none'], refreshable: false, delegation: false, + trust_levels: ['supervised'], approval_mechanisms: [], + }, + validateProfile: () => ({ valid: true }), + resolveSession: () => ({ + provider: providerName, + subject: { principal: 'test', issuer: null, run_as: null }, + trust: { declared_level: 'supervised', effective_level: 'supervised' }, + delegation_validation: { valid: true }, + credentials: { token: { value: 'secret' } }, + provider_assertions: {}, + }), + describeSession: session => ({ provider: session.provider, subject: session.subject }), + materialize: session => { + writeFileSync(credentialFile, session.credentials.token.value, { mode: 0o600 }); + return { materialized: true, env_vars: {}, temp_files: [credentialFile], cleanup_required: true }; + }, + cleanup: materialization => { + for (const file of materialization.temp_files || []) rmSync(file, { force: true }); + return { cleaned: true, warnings: [] }; + }, + }); + + const manifest = { + version: '0.2', + identity_profiles: [{ id: 'identity', provider: providerName }], + workflows: [{ + id: 'ops', name: 'Ops', tasks: [{ + id: 'task', name: 'Task', target: { session_target: 'shell' }, + shell: { program: 'printf', args: ['ok'] }, + schedule: { cron: '0 * * * *' }, + approval: { policy: 'manual', risk_level: 'high' }, + identity: { ref: 'identity' }, + verify: { shell: 'exit 1', on_failure: 'error' }, + contract: { sandbox: 'permissive', network: 'unrestricted', audit: 'none' }, + }], + }], + }; + + try { + grantApproval({ manifest, taskId: 'task', approver: 'alice', signer: 'none', env }); + await assert.rejects( + executeTask(manifest, { taskId: 'task', env, signer: 'none' }), + error => error.code === 'verify_failed' + ); + assert.equal(existsSync(credentialFile), false); + } finally { + cleanup(); + } +}); + +test('source credentials are not inherited by child processes unless explicitly declared', async () => { + const { env, cleanup } = isolatedEnvironment('agentcli-child-env-'); + env.MASTER_API_TOKEN = 'source-secret'; + env.NEUTRAL_SOURCE_VALUE = 'neutral-secret'; + try { + const manifest = { + version: '0.2', + workflows: [{ + id: 'ops', name: 'Ops', tasks: [{ + id: 'task', name: 'Task', target: { session_target: 'shell' }, + shell: { + program: process.execPath, + args: ['-e', `process.stdout.write(JSON.stringify({ + named: process.env.MASTER_API_TOKEN || 'missing', + neutral: process.env.NEUTRAL_SOURCE_VALUE || 'missing', + declared: process.env.DECLARED_INPUT || 'missing', + }))`], + env: { DECLARED_INPUT: 'allowed' }, + }, + schedule: { cron: '0 * * * *' }, + contract: { sandbox: 'permissive', network: 'unrestricted', audit: 'none' }, + }], + }], + }; + const result = await executeTask(manifest, { taskId: 'task', env, signer: 'none' }); + assert.deepEqual(JSON.parse(result.result.stdout), { + named: 'missing', + neutral: 'missing', + declared: 'allowed', + }); + } finally { + cleanup(); + } +}); + +test('explicit identity inspection resolves and cleans identity without executing the task', async () => { + const { root, env, cleanup } = isolatedEnvironment('agentcli-inspect-identity-'); + const resolvedMarker = join(root, 'resolved'); + const materializedMarker = join(root, 'materialized'); + const taskMarker = join(root, 'task-ran'); + const providerName = `inspection-provider-${process.pid}-${Date.now()}`; + registerIdentityProvider({ + name: providerName, + capabilities: { + auth_modes: ['service'], credential_types: [], presentation_kinds: ['none'], + handoff_modes: ['none'], refreshable: false, delegation: false, + trust_levels: ['supervised'], approval_mechanisms: [], + }, + validateProfile: () => ({ valid: true }), + resolveSession: () => { + writeFileSync(resolvedMarker, 'resolved'); + return { + provider: providerName, + subject: { principal: 'agent://tests/inspection', issuer: null, run_as: null }, + trust: { declared_level: 'supervised', effective_level: 'supervised' }, + delegation_validation: { valid: true, depth: 0 }, + credentials: {}, + provider_assertions: {}, + }; + }, + describeSession: session => ({ provider: session.provider, subject: session.subject }), + materialize: () => { + writeFileSync(materializedMarker, 'materialized'); + return { materialized: false, env_vars: {}, cleanup_required: false }; + }, + cleanup: () => ({ cleaned: true, warnings: [] }), + }); + const manifest = { + version: '0.2', + identity_profiles: [{ id: 'identity', provider: providerName }], + workflows: [{ + id: 'ops', name: 'Ops', tasks: [{ + id: 'task', name: 'Task', target: { session_target: 'shell' }, + shell: { program: 'sh', args: ['-c', `touch ${JSON.stringify(taskMarker)}`] }, + schedule: { cron: '0 * * * *' }, + identity: { ref: 'identity' }, + contract: { sandbox: 'permissive', network: 'unrestricted', audit: 'always' }, + }], + }], + }; + + try { + const result = await inspectTaskIdentity(manifest, { taskId: 'task', env }); + assert.equal(result.ok, true); + assert.equal(result.principal_used, 'agent://tests/inspection'); + assert.equal(existsSync(resolvedMarker), true); + assert.equal(existsSync(materializedMarker), false); + assert.equal(existsSync(taskMarker), false); + assert.equal(existsSync(getAgentcliPaths({ env }).audit), false); + } finally { + cleanup(); + } +}); + +test('explicit authorization evaluation returns deny without executing or auditing the task', async () => { + const { root, env, cleanup } = isolatedEnvironment('agentcli-evaluate-auth-'); + const taskMarker = join(root, 'task-ran'); + const providerName = `authorization-inspection-${process.pid}-${Date.now()}`; + registerAuthorizationProvider({ + name: providerName, + capabilities: { decision_kinds: ['permit', 'deny'], escalation: false }, + validateProfile: () => ({ valid: true }), + authorize: () => ({ decision: 'deny', reason: 'policy denied the action' }), + describeDecision: decision => ({ + decision: decision.decision, + reason: decision.reason ?? null, + }), + }); + const manifest = { + version: '0.2', + authorization_profiles: [{ id: 'policy', provider: providerName }], + workflows: [{ + id: 'ops', name: 'Ops', tasks: [{ + id: 'task', name: 'Task', target: { session_target: 'shell' }, + shell: { program: 'sh', args: ['-c', `touch ${JSON.stringify(taskMarker)}`] }, + schedule: { cron: '0 * * * *' }, + authorization: { ref: 'policy' }, + contract: { sandbox: 'permissive', network: 'unrestricted', audit: 'always' }, + }], + }], + }; + + try { + const result = await evaluateTaskAuthorization(manifest, { taskId: 'task', env }); + assert.equal(result.ok, true); + assert.equal(result.authorization.decision, 'deny'); + assert.equal(existsSync(taskMarker), false); + assert.equal(existsSync(getAgentcliPaths({ env }).audit), false); + } finally { + cleanup(); + } +}); + +test('explicit proof verification resolves proof input without executing or auditing the task', async () => { + const { root, env, cleanup } = isolatedEnvironment('agentcli-inspect-proof-'); + const proofMarker = join(root, 'proof-ran'); + const taskMarker = join(root, 'task-ran'); + const manifest = { + version: '0.2', + authorization_proof_profiles: [{ + id: 'proof', + method: 'none', + proof: { + value_from: { + command: `touch ${JSON.stringify(proofMarker)} && printf proof-value`, + }, + }, + verify: { required: false }, + }], + workflows: [{ + id: 'ops', name: 'Ops', tasks: [{ + id: 'task', name: 'Task', target: { session_target: 'shell' }, + shell: { program: 'sh', args: ['-c', `touch ${JSON.stringify(taskMarker)}`] }, + schedule: { cron: '0 * * * *' }, + authorization_proof: { ref: 'proof' }, + contract: { sandbox: 'permissive', network: 'unrestricted', audit: 'always' }, + }], + }], + }; + + try { + const result = await verifyTaskAuthorizationProof(manifest, { taskId: 'task', env }); + assert.equal(result.ok, true); + assert.equal(result.authorization_proof.method, 'none'); + assert.equal(existsSync(proofMarker), true); + assert.equal(existsSync(taskMarker), false); + assert.equal(existsSync(getAgentcliPaths({ env }).audit), false); + } finally { + cleanup(); + } +}); diff --git a/test/foundation.test.js b/test/foundation.test.js new file mode 100644 index 0000000..8c51312 --- /dev/null +++ b/test/foundation.test.js @@ -0,0 +1,271 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + buildEffectiveExecutionBinding, + canonicalDigest, + canonicalStringify, + computeEffectiveTaskHash, + convertManifestV1toV2, + normalizeError, + addToRegistry, + ensureAgentcliHome, + listRegistry, + showRegistryEntry, + writeAuditRecord, +} from '../src/index.js'; + +function manifestWithSecrets() { + return { + version: '0.2', + identity_profiles: [{ + id: 'operator', + provider: 'env-bearer', + auth: { + provider_config: { client_secret: 'profile-secret' }, + inputs: { token: { literal: 'input-secret' } }, + }, + }], + workflows: [{ + id: 'ops', + name: 'Ops', + tasks: [{ + id: 'deploy', + name: 'Deploy', + target: { session_target: 'shell' }, + shell: { + program: 'printf', + args: ['argument-secret'], + env: { TOKEN: 'environment-secret' }, + stdin: 'stdin-secret', + }, + schedule: { cron: '0 * * * *' }, + identity: { ref: 'operator' }, + approval: { + policy: 'manual', + risk_level: 'high', + timeout_s: 120, + approver_scope: 'domain:example.com', + }, + contract: { sandbox: 'permissive', network: 'unrestricted', audit: 'always' }, + }], + }], + }; +} + +test('canonical serialization is deterministic for nested object key order', () => { + const first = { z: 1, a: { y: [3, { b: 2, a: 1 }], x: true } }; + const second = { a: { x: true, y: [3, { a: 1, b: 2 }] }, z: 1 }; + assert.equal(canonicalStringify(first), canonicalStringify(second)); + assert.equal(canonicalDigest(first), canonicalDigest(second)); +}); + +test('effective execution binding covers governed fields without exposing secrets', () => { + const manifest = manifestWithSecrets(); + const workflow = manifest.workflows[0]; + const task = workflow.tasks[0]; + const binding = buildEffectiveExecutionBinding({ manifest, expanded: manifest, workflow, task }); + const serialized = canonicalStringify(binding); + + for (const secret of [ + 'profile-secret', + 'input-secret', + 'argument-secret', + 'environment-secret', + 'stdin-secret', + ]) { + assert.equal(serialized.includes(secret), false, `binding leaked ${secret}`); + } + + assert.equal(binding.approval.approver_scope, 'domain:example.com'); + assert.equal(binding.approval.timeout_s, 120); + assert.match(binding.command.stdin_hash, /^sha256:/); + assert.match(binding.command.env_hashes.TOKEN, /^sha256:/); +}); + +test('effective task hash changes for env, stdin, profile, contract, and verify changes', () => { + const original = manifestWithSecrets(); + const hash = value => { + const workflow = value.workflows[0]; + const task = workflow.tasks[0]; + return computeEffectiveTaskHash(buildEffectiveExecutionBinding({ + manifest: value, + expanded: value, + workflow, + task, + })); + }; + const originalHash = hash(original); + + const mutations = [ + value => { value.workflows[0].tasks[0].shell.env.TOKEN = 'changed'; }, + value => { value.workflows[0].tasks[0].shell.stdin = 'changed'; }, + value => { value.identity_profiles[0].auth.provider_config.client_secret = 'changed'; }, + value => { value.workflows[0].tasks[0].contract.network = 'none'; }, + value => { value.workflows[0].tasks[0].verify = { shell: 'test -f output' }; }, + ]; + + for (const mutate of mutations) { + const changed = structuredClone(original); + mutate(changed); + assert.notEqual(hash(changed), originalHash); + } +}); + +test('normalizeError exposes a closed error type and a separate detailed code', () => { + const normalized = normalizeError(Object.assign(new Error('approval missing'), { + code: 'approval_required', + })); + assert.equal(normalized.code, 'approval_required'); + assert.equal(normalized.error_type, 'validation_error'); + + const unknown = normalizeError(Object.assign(new Error('library failure'), { + code: 'ERR_SOMETHING_PRIVATE', + })); + assert.equal(unknown.code, 'internal_error'); + assert.equal(unknown.error_type, 'internal_error'); +}); + +test('registry reports overwrites accurately and stores entries with private permissions', () => { + const home = mkdtempSync(join(tmpdir(), 'agentcli-registry-')); + const env = { ...process.env, AGENTCLI_HOME: home }; + const manifest = { + version: '0.2', + workflows: [{ + id: 'registry-test', + name: 'Registry Test', + tasks: [{ + id: 'run', + name: 'Run', + target: { session_target: 'shell' }, + shell: { program: 'true', args: [] }, + schedule: { cron: '0 * * * *' }, + output: { format: 'text' }, + contract: { sandbox: 'permissive', network: 'unrestricted', audit: 'always' }, + }], + }], + }; + + try { + const first = addToRegistry(manifest, { name: 'private-entry', env }); + const second = addToRegistry(manifest, { name: 'private-entry', env }); + assert.equal(first.overwritten, false); + assert.equal(second.overwritten, true); + if (process.platform !== 'win32') { + assert.equal(statSync(first.path).mode & 0o777, 0o600); + assert.equal(statSync(join(home, 'registry')).mode & 0o777, 0o700); + } + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test('audit append refuses symbolic-link destinations', { skip: process.platform === 'win32' }, () => { + const root = mkdtempSync(join(tmpdir(), 'agentcli-audit-link-')); + const target = join(root, 'outside.ndjson'); + const auditPath = join(root, 'audit.ndjson'); + try { + writeFileSync(target, 'unchanged\n', 'utf8'); + symlinkSync(target, auditPath); + assert.throws( + () => writeAuditRecord({ execution_id: 'blocked' }, { auditPath }), + error => error.code === 'ELOOP' || error.code === 'EACCES' + ); + assert.equal(readFileSync(target, 'utf8'), 'unchanged\n'); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('registry refuses symbolic-link entries', { skip: process.platform === 'win32' }, () => { + const home = mkdtempSync(join(tmpdir(), 'agentcli-registry-link-')); + const env = { ...process.env, AGENTCLI_HOME: home }; + const manifest = { + version: '0.2', + workflows: [{ + id: 'registry-link', name: 'Registry Link', tasks: [{ + id: 'run', name: 'Run', target: { session_target: 'shell' }, + shell: { program: 'true', args: [] }, + schedule: { cron: '0 * * * *' }, + }], + }], + }; + try { + addToRegistry(manifest, { name: 'safe', env }); + const target = join(home, 'outside.json'); + writeFileSync(target, '{"outside":true}\n', 'utf8'); + symlinkSync(target, join(home, 'registry', 'linked.json')); + assert.throws( + () => addToRegistry(manifest, { name: 'linked', env }), + /symbolic-link/ + ); + assert.throws(() => showRegistryEntry('linked', { env }), /symbolic-link/); + const linked = listRegistry({ env }).find(entry => entry.name === 'linked'); + assert.equal(linked.symlink_refused, true); + assert.equal(readFileSync(target, 'utf8'), '{"outside":true}\n'); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test('agentcli home stores state and scaffold files with private permissions', { + skip: process.platform === 'win32', +}, () => { + const home = mkdtempSync(join(tmpdir(), 'agentcli-private-home-')); + const env = { ...process.env, AGENTCLI_HOME: home }; + try { + const result = ensureAgentcliHome({ env }); + for (const directory of [ + result.paths.root, + result.paths.manifests, + result.paths.output, + result.paths.state, + result.paths.registry, + ]) { + assert.equal(statSync(directory).mode & 0o777, 0o700); + } + for (const file of [result.paths.readme, result.paths.sampleManifest]) { + assert.equal(statSync(file).mode & 0o777, 0o600); + } + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test('v0.1 conversion produces unique valid profile ids for colliding principal slugs', () => { + const manifest = { + version: '0.1', + workflows: [{ + id: 'convert-collisions', + name: 'Convert Collisions', + tasks: [ + { + id: 'first', name: 'First', target: { session_target: 'shell' }, + shell: { program: 'true', args: [] }, + schedule: { cron: '0 * * * *' }, + identity: { principal: 'agent/a@b' }, + }, + { + id: 'second', name: 'Second', target: { session_target: 'shell' }, + shell: { program: 'true', args: [] }, + schedule: { cron: '5 * * * *' }, + identity: { principal: 'agent/a/b' }, + }, + ], + }], + }; + const converted = convertManifestV1toV2(manifest); + const ids = converted.identity_profiles.map(profile => profile.id); + assert.equal(new Set(ids).size, 2); + assert.equal(converted.version, '0.2'); +}); diff --git a/test/identity-security.test.js b/test/identity-security.test.js new file mode 100644 index 0000000..171f402 --- /dev/null +++ b/test/identity-security.test.js @@ -0,0 +1,448 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + chmodSync, + existsSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { createSign, generateKeyPairSync } from 'node:crypto'; +import { createServer } from 'node:http'; + +import '../src/validate.js'; +import { getProvider, listProviders } from '../src/identity/index.js'; +import { validateSecureEndpoint } from '../src/identity/session.js'; +import { verifyJwtSvid } from '../src/identity/spiffe-jwt-svid.js'; + +function envBearerProfile(overrides = {}) { + return { + provider: 'env-bearer', + auth: { + mode: 'service', + required: true, + provider_config: { token_env: 'TEST_BEARER_TOKEN' }, + ...(overrides.auth || {}), + }, + trust: { level: 'supervised' }, + presentation: { handoff: 'none', ...(overrides.presentation || {}) }, + ...Object.fromEntries(Object.entries(overrides).filter(([key]) => !['auth', 'presentation'].includes(key))), + }; +} + +function oidcProfile(tokenEndpoint) { + return { + provider: 'oidc-client-credentials', + auth: { + mode: 'service', + provider_config: { + token_endpoint: tokenEndpoint, + client_id: 'client-id', + client_secret: { value_from: { env: 'OIDC_CLIENT_SECRET' } }, + }, + }, + trust: { level: 'supervised' }, + presentation: { handoff: 'none' }, + }; +} + +function stripeProfile(apiBase) { + return { + provider: 'stripe-api-key', + auth: { + mode: 'service', + provider_config: { + key_strategy: 'dynamic', + account_mode: 'test', + master_key_source: { env: 'STRIPE_MASTER_KEY' }, + api_base: apiBase, + allow_insecure_http: true, + }, + }, + trust: { level: 'supervised' }, + presentation: { handoff: 'none' }, + }; +} + +function signedJwt(privateKey, payload, header = { alg: 'RS256', typ: 'JWT', kid: 'test-key' }) { + const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64url'); + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url'); + const signingInput = `${encodedHeader}.${encodedPayload}`; + const signer = createSign('RSA-SHA256'); + signer.update(signingInput); + return `${signingInput}.${signer.sign(privateKey).toString('base64url')}`; +} + +test('every registered identity provider applies structural profile validation', async () => { + for (const name of listProviders()) { + const validation = await getProvider(name).validateProfile({ + auth: [], + subject: [], + trust: [], + presentation: { bindings: 'not-an-array' }, + }); + assert.equal(validation.valid, false, `${name} accepted a structurally invalid profile`); + assert.ok(validation.errors.length > 0); + } +}); + +test('unsupported cache, refresh, delegation, and handoff declarations fail closed', async () => { + const provider = getProvider('env-bearer'); + + const cached = await provider.validateProfile(envBearerProfile({ auth: { cache: 'memory' } })); + assert.equal(cached.valid, false); + assert.match(cached.errors.join(' '), /cache/); + + const refreshed = await provider.validateProfile(envBearerProfile({ auth: { refresh: 'auto' } })); + assert.equal(refreshed.valid, false); + assert.match(refreshed.errors.join(' '), /refresh/); + + const delegated = await provider.validateProfile(envBearerProfile({ + auth: { delegation_policy: { max_depth: 1 } }, + })); + assert.equal(delegated.valid, false); + assert.match(delegated.errors.join(' '), /delegation/); + + const handedOff = await provider.validateProfile(envBearerProfile({ + presentation: { handoff: 'downscope' }, + })); + assert.equal(handedOff.valid, false); + assert.match(handedOff.errors.join(' '), /handoff/); + + const azure = getProvider('azure-managed-identity'); + const refreshableProfile = { + provider: 'azure-managed-identity', + auth: { + mode: 'service', + refresh: 'auto', + provider_config: { resource: 'https://management.azure.com/' }, + }, + trust: { level: 'supervised' }, + presentation: { handoff: 'none' }, + }; + assert.equal((await azure.validateProfile(refreshableProfile)).valid, false); + assert.equal((await azure.validateProfile(refreshableProfile, { + structural: true, + })).valid, true); + assert.equal((await azure.validateProfile(refreshableProfile, { + runtimeCapabilities: { credentialRefresh: true }, + })).valid, true); + + const stripe = getProvider('stripe-api-key'); + const portableHandoff = { + ...stripeProfile('https://api.stripe.com'), + presentation: { handoff: 'downscope' }, + }; + assert.equal((await stripe.validateProfile(portableHandoff)).valid, false); + assert.equal((await stripe.validateProfile(portableHandoff, { + structural: true, + })).valid, true); +}); + +test('user-configurable HTTP endpoints are limited to loopback hosts', async () => { + assert.deepEqual(validateSecureEndpoint('https://issuer.example/token'), []); + assert.deepEqual(validateSecureEndpoint('http://127.25.3.9/token'), []); + assert.deepEqual(validateSecureEndpoint('http://[::1]/token'), []); + assert.notDeepEqual(validateSecureEndpoint('http://192.0.2.20/token'), []); + + const oidc = getProvider('oidc-client-credentials'); + assert.equal((await oidc.validateProfile(oidcProfile('http://127.0.0.1/token'))).valid, true); + assert.equal((await oidc.validateProfile( + oidcProfile('http://identity.example.test/token'), + { allowInsecure: true } + )).valid, false); + + const stripe = getProvider('stripe-api-key'); + assert.equal((await stripe.validateProfile(stripeProfile('http://localhost:8123'))).valid, true); + assert.equal((await stripe.validateProfile(stripeProfile('http://192.0.2.30:8123'))).valid, false); +}); + +test('required presentation bindings fail and stdin bindings materialize exactly once', () => { + const provider = getProvider('env-bearer'); + const session = provider.resolveSession( + { profile: envBearerProfile() }, + { env: { TEST_BEARER_TOKEN: 'credential-sentinel' } } + ); + + assert.throws( + () => provider.materialize(session, { + bindings: [{ + source: 'credentials.missing.value', + target: { kind: 'env', name: 'TOKEN' }, + required: true, + }], + }), + error => error.code === 'presentation_binding_missing' + ); + + const materialized = provider.materialize(session, { + bindings: [{ + source: 'credentials.access_token.value', + target: { kind: 'stdin' }, + required: true, + }], + }); + assert.equal(materialized.stdin, 'credential-sentinel'); + assert.deepEqual(materialized.env_vars, {}); +}); + +test('named credential files are private and cleanup is idempotent', () => { + const provider = getProvider('env-bearer'); + const session = provider.resolveSession( + { profile: envBearerProfile() }, + { env: { TEST_BEARER_TOKEN: 'file-credential-sentinel' } } + ); + const materialized = provider.materialize(session, { + bindings: [{ + source: 'credentials.access_token.value', + target: { kind: 'file', name: 'access-token.txt', expose_as: 'ACCESS_TOKEN_FILE' }, + required: true, + }], + }); + + const path = materialized.env_vars.ACCESS_TOKEN_FILE; + assert.equal(readFileSync(path, 'utf8'), 'file-credential-sentinel'); + assert.equal(statSync(path).mode & 0o777, 0o600); + assert.equal(statSync(dirname(path)).mode & 0o777, 0o700); + + assert.deepEqual(provider.cleanup(materialized).warnings ?? [], []); + assert.equal(existsSync(path), false); + assert.deepEqual(provider.cleanup(materialized).warnings ?? [], []); + assert.throws( + () => provider.materialize(session, { + bindings: [{ + source: 'credentials.access_token.value', + target: { kind: 'file', name: '../escape' }, + required: true, + }], + }), + error => error.code === 'presentation_target_invalid' + ); +}); + +test('session descriptions redact credentials, source paths, and credential identifiers', () => { + const provider = getProvider('env-bearer'); + const described = provider.describeSession({ + provider: 'env-bearer', + credentials: { + access_token: { kind: 'bearer', value: 'description-secret-value' }, + }, + child_credentials: { + token: 'child-secret-value', + }, + provider_assertions: { + token_file: '/private/credential-source', + token_endpoint: 'https://user:password@example.test/token?secret=value', + }, + delegation_chain: [{ + principal: 'AKIA1234567890ABCDEF', + grant: 'test', + validated: true, + }], + }); + + const json = JSON.stringify(described); + assert.doesNotMatch(json, /description-secret-value|child-secret-value|credential-source|AKIA1234567890ABCDEF|password|secret=value/); + assert.match(json, /\[REDACTED\]/); +}); + +test('provider resolution errors do not expose credential source locations', () => { + const provider = getProvider('file-bearer'); + const sourcePath = '/private/nonexistent/credential-source'; + const profile = { + provider: 'file-bearer', + auth: { + mode: 'service', + required: true, + provider_config: { token_file: sourcePath }, + }, + trust: { level: 'supervised' }, + presentation: { handoff: 'none' }, + }; + assert.throws( + () => provider.resolveSession({ profile }, { env: {} }), + error => error.code === 'token_file_not_found' && !error.message.includes(sourcePath) + ); +}); + +test('command credential sources receive only the explicit command environment', () => { + const directory = mkdtempSync(join(tmpdir(), 'agentcli-command-env-')); + const tokenPath = join(directory, 'token'); + writeFileSync(tokenPath, 'command-env-token', { mode: 0o600 }); + const provider = getProvider('file-bearer'); + const profile = { + provider: 'file-bearer', + auth: { + mode: 'service', + required: true, + inputs: { + token_file: { + value_from: { command: 'printf %s "$PRIVATE_TOKEN_PATH"' }, + }, + }, + }, + trust: { level: 'supervised' }, + presentation: { handoff: 'none' }, + }; + + try { + assert.throws( + () => provider.resolveSession({ profile }, { + env: { PRIVATE_TOKEN_PATH: tokenPath }, + commandEnv: {}, + }), + error => error.code === 'token_file_not_found' + ); + const session = provider.resolveSession({ profile }, { + env: { PRIVATE_TOKEN_PATH: tokenPath }, + commandEnv: { PRIVATE_TOKEN_PATH: tokenPath }, + }); + assert.equal(session.credentials.access_token.value, 'command-env-token'); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test('delegation policy enforces allowed delegators and validated grants', () => { + const provider = getProvider('aws-sts-assume-role'); + const result = provider.validateDelegation([ + { principal: 'agent://not-allowed', grant: 'assume-role', validated: true }, + ], { + max_depth: 1, + allowed_delegators: ['agent://allowed'], + require_grant_per_hop: true, + }); + assert.equal(result.valid, false); + assert.match(result.errors.join(' '), /not allowed/); +}); + +test('dynamic provider cleanup is idempotent after successful revocation', async () => { + let deleteCount = 0; + const server = createServer((request, response) => { + if (request.method === 'DELETE') deleteCount += 1; + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end('{}'); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + const apiBase = `http://127.0.0.1:${address.port}`; + const provider = getProvider('stripe-api-key'); + const session = { + provider: 'stripe-api-key', + credentials: { + api_key: { kind: 'bearer', value: 'rk_test_child_credential', scope: 'payments' }, + }, + provider_assertions: { + key_strategy: 'dynamic', + account_mode: 'test', + scope: 'payments', + stripe_key_id: 'rk_test_cleanup_id', + api_base: apiBase, + }, + }; + const materialization = provider.materialize(session, {}); + const context = { + env: { STRIPE_MASTER: 'sk_test_master_key_for_cleanup' }, + commandEnv: {}, + provider_config: { + master_key_source: { env: 'STRIPE_MASTER' }, + api_base: apiBase, + }, + }; + + try { + assert.deepEqual(await provider.cleanup(materialization, context), { cleaned: true }); + assert.deepEqual(await provider.cleanup(materialization, context), { cleaned: true }); + assert.equal(deleteCount, 1); + } finally { + await new Promise(resolve => server.close(resolve)); + } +}); + +test('SPIFFE provider accepts only trusted, audience-bound, file-mounted JWT-SVIDs', async () => { + const directory = mkdtempSync(join(tmpdir(), 'agentcli-spiffe-test-')); + const svidPath = join(directory, 'svid.jwt'); + const { publicKey, privateKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + const now = Math.floor(Date.now() / 1000); + const token = signedJwt(privateKey, { + sub: 'spiffe://example.test/workload/api', + iss: 'spiffe://example.test', + aud: ['agentcli', 'other-audience'], + iat: now - 5, + exp: now + 300, + }); + writeFileSync(svidPath, token, { mode: 0o600 }); + chmodSync(svidPath, 0o600); + + const profile = { + provider: 'spiffe-jwt-svid', + subject: { kind: 'workload' }, + auth: { + mode: 'service', + required: true, + audience: 'agentcli', + provider_config: { svid_file: svidPath, public_key_pem: publicKey }, + }, + trust: { level: 'supervised' }, + presentation: { handoff: 'none' }, + }; + const provider = getProvider('spiffe-jwt-svid'); + + try { + assert.equal((await provider.validateProfile(profile)).valid, true); + const session = provider.resolveSession({ profile }); + assert.equal(session.provider_assertions.signature_verified, true); + assert.equal(session.subject.principal, 'spiffe://example.test/workload/api'); + assert.equal(provider.describeSession(session).credentials.jwt_svid.value, '[REDACTED]'); + + assert.throws( + () => verifyJwtSvid(token, profile.auth.provider_config, 'wrong-audience'), + error => error.code === 'spiffe_audience_mismatch' + ); + const tokenSegments = token.split('.'); + tokenSegments[2] = `${tokenSegments[2][0] === 'A' ? 'B' : 'A'}${tokenSegments[2].slice(1)}`; + const tampered = tokenSegments.join('.'); + assert.throws( + () => verifyJwtSvid(tampered, profile.auth.provider_config, 'agentcli'), + error => error.code === 'spiffe_signature_invalid' + ); + const expiredToken = signedJwt(privateKey, { + sub: 'spiffe://example.test/workload/api', + aud: 'agentcli', + iat: now - 600, + exp: now - 500, + }); + assert.throws( + () => verifyJwtSvid(expiredToken, profile.auth.provider_config, 'agentcli'), + error => error.code === 'spiffe_svid_expired' + ); + + const socketProfile = structuredClone(profile); + socketProfile.auth.provider_config.workload_api_socket = 'unix:///run/spire/sockets/agent.sock'; + assert.equal((await provider.validateProfile(socketProfile)).valid, false); + + const untrustedProfile = structuredClone(profile); + delete untrustedProfile.auth.provider_config.public_key_pem; + assert.equal((await provider.validateProfile(untrustedProfile)).valid, false); + + const malformedTrustProfile = structuredClone(profile); + malformedTrustProfile.auth.provider_config.public_key_pem = 'not-a-public-key'; + assert.equal((await provider.validateProfile(malformedTrustProfile)).valid, false); + + const optionalProfile = structuredClone(profile); + optionalProfile.auth.required = false; + delete optionalProfile.auth.audience; + delete optionalProfile.auth.provider_config.public_key_pem; + assert.deepEqual(provider.resolveSession({ profile: optionalProfile }).credentials, {}); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/test/integration-scheduler.test.js b/test/integration-scheduler.test.js index 3700270..78d38e4 100644 --- a/test/integration-scheduler.test.js +++ b/test/integration-scheduler.test.js @@ -77,11 +77,11 @@ const schedulerRuntime = existsSync(schedulerPkg) }; const v02RuntimeSkipReason = schedulerRuntime.ok - && schedulerRuntime.capabilities?.handoff_version === '2' + && Number.parseInt(schedulerRuntime.capabilities?.handoff_version || '0', 10) >= 2 && schedulerRuntime.capabilities?.features?.trust_evaluation === true && schedulerRuntime.capabilities?.features?.authorization_hook === true ? null - : 'scheduler under test does not advertise handoff_version=2 with trust_evaluation=true and authorization_hook=true'; + : 'scheduler under test does not advertise handoff_version>=2 with trust_evaluation=true and authorization_hook=true'; if (!schedulerRuntime.ok) { describe('integration-scheduler (skipped)', { skip: schedulerRuntime.reason }, () => { diff --git a/test/proof-evidence.test.js b/test/proof-evidence.test.js new file mode 100644 index 0000000..c943ac7 --- /dev/null +++ b/test/proof-evidence.test.js @@ -0,0 +1,642 @@ +import assert from 'node:assert/strict'; +import { + createSign, + generateKeyPairSync, +} from 'node:crypto'; +import { + chmodSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; + +import { canonicalDigest, canonicalStringify, hashString } from '../src/canonical.js'; +import { resolveCommandValue, resolveValueFrom } from '../src/command.js'; +import { + assertValidAuthorizationProofProfile, + validateAuthorizationProofProfile, + verifyAuthorizationProof, +} from '../src/authorization-proof/index.js'; +import { + resolveCertificateVerificationContext, + certificateVerifier, +} from '../src/authorization-proof/certificate.js'; +import { detachedSignatureVerifier } from '../src/authorization-proof/detached-signature.js'; +import { jwtVerifier } from '../src/authorization-proof/jwt.js'; +import { + buildCompleteEvidencePayload, + serializePayload, + validateCompleteEvidencePayload, + validateEvidenceRecordBinding, +} from '../src/evidence/payload.js'; +import { sshEvidenceProvider } from '../src/evidence/ssh.js'; +import { verifyEvidenceEnvelope } from '../src/evidence/index.js'; +import { + generateExecutionId, + readAuditLog, + writeAuditRecord, +} from '../src/audit.js'; +import { executeTask } from '../src/exec.js'; + +function base64Url(value) { + return Buffer.from(JSON.stringify(value)).toString('base64url'); +} + +function signedJwt(payload, privateKey) { + const header = base64Url({ alg: 'RS256', typ: 'JWT' }); + const body = base64Url(payload); + const signingInput = `${header}.${body}`; + const signer = createSign('RSA-SHA256'); + signer.update(signingInput); + return `${signingInput}.${signer.sign(privateKey).toString('base64url')}`; +} + +function unsignedJwt(payload) { + return `${base64Url({ alg: 'none', typ: 'JWT' })}.${base64Url(payload)}.`; +} + +function evidencePayload(overrides = {}) { + return buildCompleteEvidencePayload({ + executionId: 'execution-1', + timestamp: new Date().toISOString(), + source: { workflow_id: 'workflow', task_id: 'task' }, + manifest: { version: '0.2', workflows: [] }, + effectiveTask: { binding_version: 1, command: { program: 'echo' } }, + declaredIdentity: { provider: 'none' }, + resolvedIdentity: { + principal: 'agent://test', + credentials: { access_token: 'secret-identity-token' }, + }, + authorizationProof: { method: 'jwt', verified: true }, + authorization: { decision: 'permit', provider_data: { api_key: 'secret-auth-key' } }, + actorContext: { principal: 'agent://test' }, + contract: { audit: 'always' }, + command: { + program: 'echo', + args: ['secret-argument'], + cwd: '/tmp', + env: { TOKEN: 'secret-environment' }, + stdin: 'secret-stdin', + }, + result: { + exit_code: 0, + timed_out: false, + duration_ms: 10, + output_hash: hashString('secret-output'), + stdout: 'secret-output', + stderr: '', + structured: { secret: true }, + }, + verify: { + passed: true, + stdout: 'verify-output', + stderr: '', + }, + complianceContext: { policy_version: 'policy-1' }, + ...overrides, + }); +} + +test('resolveValueFrom disables command execution until explicitly allowed', () => { + const workdir = mkdtempSync(join(tmpdir(), 'agentcli-value-from-')); + const marker = join(workdir, 'marker'); + try { + assert.throws( + () => resolveValueFrom( + { command: `printf blocked > "${marker}"; printf value` }, + { cwd: workdir } + ), + /disabled until the caller explicitly opts in/ + ); + assert.throws(() => readFileSync(marker, 'utf8')); + + const value = resolveValueFrom( + { command: `printf allowed > "${marker}"; printf value` }, + { cwd: workdir, allowCommand: true } + ); + assert.equal(value, 'value'); + assert.equal(readFileSync(marker, 'utf8'), 'allowed'); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test('resolveValueFrom rejects ambiguous sources and resolves relative files', () => { + const workdir = mkdtempSync(join(tmpdir(), 'agentcli-value-file-')); + try { + writeFileSync(join(workdir, 'proof.txt'), 'proof-value\n'); + assert.equal( + resolveValueFrom({ file: 'proof.txt' }, { cwd: workdir }), + 'proof-value' + ); + assert.throws( + () => resolveValueFrom({ env: 'TOKEN', literal: 'value' }, { env: { TOKEN: 'token' } }), + /exactly one source/ + ); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test('value resolution separates lookup environment from command environment', () => { + const env = { SECRET_PROOF: 'proof-from-full-environment' }; + const commandEnv = { AGENTCLI_MANIFEST_DIGEST: 'sha256:digest' }; + assert.equal( + resolveValueFrom({ env: 'SECRET_PROOF' }, { env, commandEnv }), + 'proof-from-full-environment' + ); + + const runner = (_program, _args, options) => { + assert.deepEqual(options.env, commandEnv); + return { status: 0, stdout: 'command-proof', stderr: '' }; + }; + assert.equal( + resolveValueFrom( + { command: 'generate-proof' }, + { env, commandEnv, allowCommand: true, runner } + ), + 'command-proof' + ); + assert.equal( + resolveCommandValue('generate-proof', { env, commandEnv, runner }), + 'command-proof' + ); +}); + +test('JWT claims-only parsing is never reported as verified', () => { + const token = unsignedJwt({ sub: 'agent', exp: Math.floor(Date.now() / 1000) + 300 }); + const result = jwtVerifier.verifyProof(token, {}, { requireSignature: false }); + assert.equal(result.claims_validated, true); + assert.equal(result.signature_verified, false); + assert.equal(result.verified, false); +}); + +test('JWT verification succeeds only with a trusted signing key', async () => { + const keys = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + const manifest = { version: '0.2', workflows: [] }; + const token = signedJwt({ + sub: 'agent', + manifest_digest: canonicalDigest(manifest), + secret_claim: 'audit-secret-value', + }, keys.privateKey); + const profile = { + method: 'jwt', + public_key: keys.publicKey, + proof: { value_from: { env: 'JWT' } }, + claims: { subject: 'agent', secret_claim: 'audit-secret-value' }, + verify: { required: true }, + }; + const result = await verifyAuthorizationProof(token, profile, { manifest }); + assert.equal(result.verified, true); + assert.equal(result.signature_verified, true); + assert.equal(result.manifest_bound, true); + assert.equal(result.decoded_claims.secret_claim, undefined); + assert.equal(JSON.stringify(jwtVerifier.describeVerification(result, {})).includes('audit-secret-value'), false); + + const changed = await verifyAuthorizationProof(token, profile, { + manifest: { version: '0.2', workflows: [{ id: 'changed' }] }, + }); + assert.equal(changed.verified, false); + assert.equal(changed.signature_verified, true); + assert.equal(changed.manifest_bound, false); +}); + +test('authorization proof profile validation fails closed', () => { + const unknown = validateAuthorizationProofProfile({ method: 'unknown' }); + assert.equal(unknown.valid, false); + + const missingProof = validateAuthorizationProofProfile({ + method: 'jwt', + public_key: 'not-used-during-profile-validation', + }); + assert.equal(missingProof.valid, false); + assert.throws( + () => assertValidAuthorizationProofProfile({ method: 'jwt' }), + error => error.code === 'authorization_proof_invalid' + ); +}); + +test('detached signatures verify canonical manifest content and reject changes', () => { + const keys = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + const manifest = { version: '0.2', metadata: { b: 2, a: 1 }, workflows: [] }; + const signer = createSign('RSA-SHA256'); + signer.update(canonicalStringify(manifest)); + const signature = signer.sign(keys.privateKey).toString('base64'); + const profile = { + method: 'detached-signature', + public_key: keys.publicKey, + proof: { value_from: { env: 'SIGNATURE' } }, + verify: { required: true }, + }; + + const verified = detachedSignatureVerifier.verifyProof(signature, profile, { + manifest: { workflows: [], metadata: { a: 1, b: 2 }, version: '0.2' }, + }); + assert.equal(verified.verified, true); + assert.match(verified.manifest_digest, /^sha256:/); + + const changed = detachedSignatureVerifier.verifyProof(signature, profile, { + manifest: { ...manifest, metadata: { a: 1, b: 3 } }, + }); + assert.equal(changed.verified, false); +}); + +test('manifest-bound proof methods reject circular inline proof values', () => { + for (const verifier of [jwtVerifier, detachedSignatureVerifier, certificateVerifier]) { + const result = verifier.validateProfile({ + method: verifier.name, + public_key: 'configured-trust', + proof: { value_from: { literal: 'inline-proof' } }, + verify: { required: true }, + }); + assert.equal(result.valid, false); + assert.ok(result.errors.some(error => error.field === 'proof.value_from.literal')); + } +}); + +test('certificate context resolves a CA from safe value_from sources', () => { + const context = resolveCertificateVerificationContext({ + ca_certificate_from: { env: 'CA_CERT' }, + }, { + env: { CA_CERT: 'certificate-data' }, + manifest: { version: '0.2' }, + }); + assert.equal(context.caCert, 'certificate-data'); + assert.equal(context.caCertError, null); + assert.match(context.manifestDigest, /^sha256:/); +}); + +test('certificate proof requires a trusted chain and manifest proof of possession', () => { + const workdir = mkdtempSync(join(tmpdir(), 'agentcli-certificate-')); + const caKeyPath = join(workdir, 'ca-key.pem'); + const caCertPath = join(workdir, 'ca-cert.pem'); + const keyPath = join(workdir, 'leaf-key.pem'); + const csrPath = join(workdir, 'leaf.csr'); + const certPath = join(workdir, 'leaf-cert.pem'); + const extensionPath = join(workdir, 'leaf.ext'); + try { + const generatedCa = spawnSync('openssl', [ + 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', + '-keyout', caKeyPath, + '-out', caCertPath, + '-subj', '/CN=agentcli-test-ca', + '-addext', 'basicConstraints=critical,CA:TRUE', + '-days', '1', + ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + assert.equal(generatedCa.status, 0, generatedCa.stderr); + + const generatedCsr = spawnSync('openssl', [ + 'req', '-new', '-newkey', 'rsa:2048', '-nodes', + '-keyout', keyPath, + '-out', csrPath, + '-subj', '/CN=agentcli-test', + ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + assert.equal(generatedCsr.status, 0, generatedCsr.stderr); + writeFileSync( + extensionPath, + 'basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature\n' + ); + const generatedLeaf = spawnSync('openssl', [ + 'x509', '-req', + '-in', csrPath, + '-CA', caCertPath, + '-CAkey', caKeyPath, + '-CAcreateserial', + '-out', certPath, + '-days', '1', + '-sha256', + '-extfile', extensionPath, + ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + assert.equal(generatedLeaf.status, 0, generatedLeaf.stderr); + + const manifest = { version: '0.2', workflows: [] }; + const signer = createSign('SHA256'); + signer.update(canonicalStringify(manifest)); + const signature = signer.sign(readFileSync(keyPath, 'utf8')).toString('base64'); + const certificate = readFileSync(certPath, 'utf8'); + const profile = { + method: 'certificate', + ca_certificate: readFileSync(caCertPath, 'utf8'), + proof: { value_from: { env: 'CERTIFICATE_PROOF' } }, + claims: { subject: 'agentcli-test' }, + verify: { required: true }, + }; + const proof = JSON.stringify({ certificate, signature }); + + const verified = certificateVerifier.verifyProof(proof, profile, { manifest }); + assert.equal(verified.verified, true, verified.signature_verification_reason); + assert.equal(verified.signature_verified, true); + assert.equal(verified.proof_of_possession_verified, true); + + const partialSubject = certificateVerifier.verifyProof(proof, { + ...profile, + claims: { subject: 'agentcli' }, + }, { manifest }); + assert.equal(partialSubject.verified, false); + assert.equal(partialSubject.claims_validated, false); + + const changed = certificateVerifier.verifyProof(proof, profile, { + manifest: { version: '0.2', workflows: [{ id: 'changed' }] }, + }); + assert.equal(changed.verified, false); + assert.equal(changed.proof_of_possession_verified, false); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test('complete evidence binds all execution controls without retaining raw inputs', () => { + const payload = evidencePayload(); + const validation = validateCompleteEvidencePayload(payload); + assert.deepEqual(validation, { valid: true, errors: [] }); + assert.match(payload.bindings.manifest_digest, /^sha256:/); + assert.match(payload.bindings.effective_task_hash, /^sha256:/); + assert.equal(payload.command.args, undefined); + assert.equal(payload.command.env, undefined); + assert.equal(payload.command.stdin, undefined); + assert.equal(payload.result.stdout, undefined); + assert.equal(payload.result.structured, undefined); + assert.equal(payload.verify.stdout, undefined); + assert.match(payload.command.args_hashes[0], /^sha256:/); + assert.match(payload.command.env_hash, /^sha256:/); + assert.match(payload.command.stdin_hash, /^sha256:/); + assert.match(payload.result.stdout_hash, /^sha256:/); + assert.match(payload.verify.stdout_hash, /^sha256:/); + + const serialized = serializePayload(payload); + for (const secret of [ + 'secret-argument', + 'secret-environment', + 'secret-stdin', + 'secret-output', + 'verify-output', + 'secret-identity-token', + 'secret-auth-key', + ]) { + assert.equal(serialized.includes(secret), false); + } +}); + +test('complete evidence rejects caller-supplied binding digest mismatches', () => { + assert.throws( + () => evidencePayload({ manifestDigest: 'sha256:not-the-manifest' }), + /manifestDigest does not match/ + ); + assert.throws( + () => evidencePayload({ effectiveTaskHash: 'sha256:not-the-task' }), + /effectiveTaskHash does not match/ + ); +}); + +test('verified evidence cannot be transplanted onto another audit record', () => { + const payload = evidencePayload(); + const record = { + execution_id: payload.execution_id, + timestamp: payload.timestamp, + source: payload.source, + manifest_digest: payload.bindings.manifest_digest, + effective_task_hash: payload.bindings.effective_task_hash, + declared_identity: { provider: 'none' }, + resolved_identity: { + principal: 'agent://test', + credentials: { access_token: 'secret-identity-token' }, + }, + authorization_proof: { method: 'jwt', verified: true }, + authorization: { decision: 'permit', provider_data: { api_key: 'secret-auth-key' } }, + actor_context: { principal: 'agent://test' }, + contract: { audit: 'always' }, + command: Object.fromEntries([ + 'program', 'cwd', 'args_count', 'args_hashes', 'env_keys', 'env_hashes', + 'stdin_present', 'stdin_hash', + ].map(field => [ + field, + field === 'stdin_present' + ? payload.command.stdin_hash != null + : payload.command[field] ?? null, + ])), + result: Object.fromEntries([ + 'exit_code', 'signal', 'timed_out', 'duration_ms', 'stdout_bytes', + 'stderr_bytes', 'output_hash', + ].map(field => [field, payload.result[field] ?? null])), + verify: { + passed: true, + stdout: 'verify-output', + stderr: '', + }, + }; + assert.deepEqual( + validateEvidenceRecordBinding(payload, record), + { valid: true, errors: [] } + ); + const transplanted = validateEvidenceRecordBinding(payload, { + ...record, + execution_id: 'different-execution', + }); + assert.equal(transplanted.valid, false); + assert.match(transplanted.errors[0], /execution_id/); + + const rewrittenIdentity = validateEvidenceRecordBinding(payload, { + ...record, + resolved_identity: { ...record.resolved_identity, principal: 'agent://attacker' }, + }); + assert.equal(rewrittenIdentity.valid, false); + assert.ok(rewrittenIdentity.errors.some(error => /resolved_identity/.test(error))); +}); + +test('SSH evidence persists a versioned envelope that can be independently verified', async () => { + const workdir = mkdtempSync(join(tmpdir(), 'agentcli-evidence-')); + const keyPath = join(workdir, 'evidence-key'); + const allowedSignersPath = join(workdir, 'allowed_signers'); + try { + const generated = spawnSync('ssh-keygen', [ + '-q', '-t', 'ed25519', '-N', '', '-f', keyPath, + ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + assert.equal(generated.status, 0, generated.stderr); + writeFileSync( + allowedSignersPath, + `agentcli ${readFileSync(`${keyPath}.pub`, 'utf8').trim()}\n`, + { mode: 0o600 } + ); + + const payload = serializePayload(evidencePayload()); + const config = sshEvidenceProvider.resolve({ + key_path: keyPath, + principal: 'agentcli', + }); + const attested = sshEvidenceProvider.attest(payload, config); + assert.equal(attested.attested, true, attested.reason); + assert.equal(attested.envelope.schema, 'agentcli.evidence.envelope'); + assert.equal(attested.envelope.version, 1); + assert.ok(attested.envelope.signature.includes('BEGIN SSH SIGNATURE')); + + const verified = await verifyEvidenceEnvelope(attested.envelope, { + allowedSignersPath, + principal: 'agentcli', + }); + assert.equal(verified.verified, true, verified.reason); + assert.equal(verified.payload.execution_id, 'execution-1'); + + const tampered = { + ...attested.envelope, + signed_payload: attested.envelope.signed_payload.replace('execution-1', 'execution-2'), + }; + const rejected = await verifyEvidenceEnvelope(tampered, { + allowedSignersPath, + principal: 'agentcli', + }); + assert.equal(rejected.verified, false); + assert.match(rejected.reason, /digest mismatch/); + + const wrongFingerprint = await verifyEvidenceEnvelope({ + ...attested.envelope, + key_fingerprint: 'SHA256:wrong', + }, { + allowedSignersPath, + principal: 'agentcli', + }); + assert.equal(wrongFingerprint.verified, false); + assert.match(wrongFingerprint.reason, /signature|fingerprint/i); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test('exec persists complete evidence that binds back to its audit record', async () => { + const workdir = mkdtempSync(join(tmpdir(), 'agentcli-exec-evidence-')); + const keyPath = join(workdir, 'evidence-key'); + const allowedSignersPath = join(workdir, 'allowed_signers'); + const agentcliHome = join(workdir, 'home'); + try { + const generated = spawnSync('ssh-keygen', [ + '-q', '-t', 'ed25519', '-N', '', '-f', keyPath, + ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + assert.equal(generated.status, 0, generated.stderr); + writeFileSync( + allowedSignersPath, + `agentcli ${readFileSync(`${keyPath}.pub`, 'utf8').trim()}\n`, + { mode: 0o600 } + ); + + const manifest = { + version: '0.2', + evidence_profiles: [{ + id: 'signed-evidence', + provider: 'ssh', + provider_config: { key_path: keyPath, principal: 'agentcli' }, + payload: { format: 'canonical-json' }, + verify: { required: true }, + }], + workflows: [{ + id: 'evidence-workflow', + name: 'Evidence Workflow', + tasks: [{ + id: 'evidence-task', + name: 'Evidence Task', + shell: { program: 'printf', args: ['evidence-output'] }, + target: { session_target: 'shell' }, + schedule: { cron: '0 * * * *' }, + contract: { audit: 'always' }, + evidence: { ref: 'signed-evidence' }, + }], + }], + }; + const env = { ...process.env, AGENTCLI_HOME: agentcliHome }; + const result = await executeTask(manifest, { + workflowId: 'evidence-workflow', + taskId: 'evidence-task', + env, + cwd: workdir, + signer: 'none', + }); + assert.equal(result.ok, true); + assert.equal(result.evidence.attested, true); + assert.ok(result.evidence.envelope.signature); + + const auditPath = join(agentcliHome, 'state', 'audit.ndjson'); + const records = readAuditLog({ auditPath }); + assert.equal(records.length, 1); + const payload = JSON.parse(records[0].evidence.envelope.signed_payload); + assert.deepEqual( + validateEvidenceRecordBinding(payload, records[0]), + { valid: true, errors: [] } + ); + const verified = await verifyEvidenceEnvelope(records[0].evidence.envelope, { + allowedSignersPath, + principal: 'agentcli', + }); + assert.equal(verified.verified, true, verified.reason); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test('SSH evidence provider refuses to sign incomplete payloads', () => { + const workdir = mkdtempSync(join(tmpdir(), 'agentcli-evidence-incomplete-')); + const keyPath = join(workdir, 'evidence-key'); + try { + const generated = spawnSync('ssh-keygen', [ + '-q', '-t', 'ed25519', '-N', '', '-f', keyPath, + ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + assert.equal(generated.status, 0, generated.stderr); + const result = sshEvidenceProvider.attest( + JSON.stringify({ result: { exit_code: 0 } }), + { keyPath, principal: 'agentcli' } + ); + assert.equal(result.attested, false); + assert.match(result.reason, /incomplete evidence payload/); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test('audit log skips malformed and partial JSONL records with diagnostics', () => { + const workdir = mkdtempSync(join(tmpdir(), 'agentcli-audit-')); + const auditPath = join(workdir, 'state', 'audit.ndjson'); + try { + writeAuditRecord({ execution_id: 'one' }, { auditPath }); + writeFileSync( + auditPath, + '{"execution_id":"one"}\nnot-json\n{"execution_id":"two"}\n{"partial":', + { mode: 0o600 } + ); + const malformed = []; + const records = readAuditLog({ + auditPath, + onMalformed: diagnostic => malformed.push(diagnostic), + }); + assert.deepEqual(records.map(record => record.execution_id), ['one', 'two']); + assert.deepEqual(malformed.map(item => item.lineNumber), [2, 4]); + chmodSync(auditPath, 0o644); + writeAuditRecord({ execution_id: 'three' }, { auditPath }); + assert.deepEqual( + readAuditLog({ auditPath }).map(record => record.execution_id), + ['one', 'two', 'three'] + ); + assert.equal(statSync(auditPath).mode & 0o777, 0o600); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test('execution IDs remain unique for identical same-millisecond executions', () => { + const ids = new Set(); + for (let index = 0; index < 1000; index += 1) { + ids.add(generateExecutionId('workflow', 'task', 'same-timestamp')); + } + assert.equal(ids.size, 1000); + for (const id of ids) assert.match(id, /^[a-f0-9]{32}$/); +}); diff --git a/test/run-workflow.test.js b/test/run-workflow.test.js index e01a937..aba208b 100644 --- a/test/run-workflow.test.js +++ b/test/run-workflow.test.js @@ -244,3 +244,78 @@ test('cli run --all-roots dry-run plans every selected root graph', async () => rmSync(tempHome, { recursive: true, force: true }); } }); + +test('runWorkflow skips disabled tasks and their dependent trigger branches', async () => { + const manifest = { + version: '0.1', + workflows: [{ + id: 'disabled-flow', + name: 'Disabled Flow', + tasks: [ + { + id: 'root', + name: 'Root', + enabled: false, + shell: { program: 'sh', args: ['-c', 'exit 99'] }, + target: { session_target: 'shell' }, + schedule: { cron: '0 * * * *' }, + }, + { + id: 'child', + name: 'Child', + shell: { program: 'sh', args: ['-c', 'exit 98'] }, + target: { session_target: 'shell' }, + trigger: { parent: 'root', on: 'failure' }, + }, + { + id: 'grandchild', + name: 'Grandchild', + shell: { program: 'sh', args: ['-c', 'exit 97'] }, + target: { session_target: 'shell' }, + trigger: { parent: 'child', on: 'complete' }, + }, + ], + }], + }; + + const result = await runWorkflow(manifest, { rootTaskId: 'root', signer: 'none' }); + assert.equal(result.ok, true); + assert.equal(result.summary.total, 3); + assert.equal(result.summary.failed, 0); + assert.equal(result.summary.skipped, 3); + assert(result.tasks.every(task => task.status === 'skipped')); + assert.match(result.tasks[0].reason, /disabled/); + assert.match(result.tasks[1].reason, /ancestor task/); +}); + +test('runWorkflow dry-run marks disabled branches as skipped instead of planned', async () => { + const manifest = { + version: '0.1', + workflows: [{ + id: 'disabled-plan', + name: 'Disabled Plan', + tasks: [ + { + id: 'root', + name: 'Root', + enabled: false, + shell: { program: 'echo', args: ['root'] }, + target: { session_target: 'shell' }, + schedule: { cron: '0 * * * *' }, + }, + { + id: 'child', + name: 'Child', + shell: { program: 'echo', args: ['child'] }, + target: { session_target: 'shell' }, + trigger: { parent: 'root', on: 'success' }, + }, + ], + }], + }; + + const result = await runWorkflow(manifest, { rootTaskId: 'root', dryRun: true }); + assert.equal(result.summary.planned, 0); + assert.equal(result.summary.skipped, 2); + assert(result.tasks.every(task => task.status === 'skipped')); +}); diff --git a/test/sandbox.test.js b/test/sandbox.test.js new file mode 100644 index 0000000..0e5dff1 --- /dev/null +++ b/test/sandbox.test.js @@ -0,0 +1,152 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + mkdtempSync, + mkdirSync, + realpathSync, + rmSync, + symlinkSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + buildMacOSSandboxProfile, + canonicalizeSandboxPath, + prepareSandboxedShellCommand, + resolveSandboxSupport, +} from '../src/sandbox.js'; + +const shell = { program: '/bin/echo', args: ['ok'], cwd: null }; + +test('sandbox support resolution performs no executable probe', () => { + assert.equal(resolveSandboxSupport({ platform: 'linux', env: {} }), null); + assert.deepEqual(resolveSandboxSupport({ platform: 'darwin', env: {} }), { + kind: 'sandbox-exec', + command: '/usr/bin/sandbox-exec', + }); + assert.deepEqual(resolveSandboxSupport({ + platform: 'darwin', + env: { AGENTCLI_SANDBOX_EXEC: '/custom/sandbox-exec' }, + }), { + kind: 'sandbox-exec', + command: '/usr/bin/sandbox-exec', + }); + assert.equal(resolveSandboxSupport({ + platform: 'darwin', + env: { AGENTCLI_SANDBOX: 'disabled' }, + }), null); +}); + +test('strict and network-restricted contracts fail closed without enforcement', () => { + for (const contract of [ + { sandbox: 'strict', network: 'unrestricted' }, + { sandbox: 'none', network: 'none' }, + { sandbox: 'permissive', network: 'restricted' }, + { sandbox: 'permissive', network: 'unrestricted', allowed_paths: [tmpdir()] }, + ]) { + assert.throws( + () => prepareSandboxedShellCommand(shell, contract, { platform: 'linux', env: {} }), + error => error.code === 'sandbox_enforcement_unavailable' + ); + } +}); + +test('allowed_paths creates a filesystem boundary even without sandbox strict', () => { + const profile = buildMacOSSandboxProfile({ + contract: { + sandbox: 'permissive', + network: 'unrestricted', + allowed_paths: [tmpdir()], + }, + cwd: tmpdir(), + shellCwd: tmpdir(), + }); + assert.match(profile, /\(deny default\)/); + assert.match(profile, /allow file-write/); +}); + +test('permissive isolation remains advisory only without a network restriction', () => { + const result = prepareSandboxedShellCommand(shell, { + sandbox: 'permissive', + network: 'unrestricted', + }, { platform: 'linux', env: {} }); + assert.equal(result.sandboxed, false); + assert.equal(result.program, shell.program); + assert.equal(result.warnings.length, 1); +}); + +test('darwin enforcement wraps the command without probing the runner', () => { + const result = prepareSandboxedShellCommand(shell, { + sandbox: 'strict', + network: 'none', + }, { + platform: 'darwin', + env: { AGENTCLI_SANDBOX_EXEC: '/custom/sandbox-exec' }, + }); + assert.equal(result.sandboxed, true); + assert.equal(result.program, '/usr/bin/sandbox-exec'); + assert.deepEqual(result.args.slice(-2), ['/bin/echo', 'ok']); + assert.doesNotMatch(result.profile, /allow network/); +}); + +test('sandbox paths resolve existing symlinks and anchor nonexistent descendants', () => { + const root = mkdtempSync(join(tmpdir(), 'agentcli-sandbox-test-')); + const requestedRoot = join(root, 'requested'); + const realRoot = join(root, 'real'); + mkdirSync(requestedRoot); + mkdirSync(realRoot); + const link = join(requestedRoot, 'link'); + symlinkSync(realRoot, link); + const requested = join(link, 'future', 'output'); + const canonical = join(realpathSync(realRoot), 'future', 'output'); + + try { + assert.equal(canonicalizeSandboxPath(requested), canonical); + const profile = buildMacOSSandboxProfile({ + contract: { sandbox: 'strict', network: 'none', allowed_paths: [requested] }, + cwd: requestedRoot, + shellCwd: requested, + }); + assert.match(profile, new RegExp(canonical.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + assert.doesNotMatch(profile, new RegExp(requested.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('sandbox rejects a working-directory symlink escape from allowed roots', () => { + const root = mkdtempSync(join(tmpdir(), 'agentcli-sandbox-escape-')); + const allowed = join(root, 'allowed'); + const outside = join(root, 'outside'); + mkdirSync(allowed); + mkdirSync(outside); + const link = join(allowed, 'link'); + symlinkSync(outside, link); + try { + assert.throws( + () => buildMacOSSandboxProfile({ + contract: { sandbox: 'strict', network: 'none', allowed_paths: [allowed] }, + cwd: allowed, + shellCwd: link, + }), + error => error.code === 'sandbox_path_escape' + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('dangling symlink sandbox roots are rejected rather than treated as missing', () => { + const root = mkdtempSync(join(tmpdir(), 'agentcli-sandbox-dangling-')); + const link = join(root, 'dangling'); + symlinkSync(join(root, 'missing-target'), link); + try { + assert.throws( + () => canonicalizeSandboxPath(join(link, 'child')), + error => error.code === 'sandbox_path_invalid' + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/test/scheduler-conformance.test.js b/test/scheduler-conformance.test.js new file mode 100644 index 0000000..a87dac2 --- /dev/null +++ b/test/scheduler-conformance.test.js @@ -0,0 +1,178 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + applyManifestToScheduler, + negotiateSchedulerFieldVersion, + requiredSchedulerFieldVersion, + schedulerCreateSpec, +} from '../src/apply.js'; +import { validateManifestCapabilities } from '../src/capabilities.js'; +import { compileManifestToScheduler } from '../src/compiler/openclaw-scheduler.js'; +import { compileManifestToStandalone } from '../src/compiler/standalone.js'; +import { compileManifestForDispatch } from '../src/runtime/openclaw-scheduler.js'; +import { TARGETS } from '../src/targets.js'; + +function governedManifest({ approvalPolicy = 'manual', inlineSecrets = false } = {}) { + return { + version: '0.2', + identity_profiles: [{ + id: 'operator', + provider: 'none', + provider_config: { client_secret: 'identity-secret' }, + subject: { + kind: 'agent', + principal: 'agent://tests/operator', + attributes: { tenant_secret: 'tenant-secret' }, + }, + auth: { + provider_config: { token: 'auth-secret' }, + inputs: { password: 'input-secret' }, + }, + presentation: { handoff: 'none', cleanup: 'always' }, + }], + workflows: [{ + id: 'governed', + name: 'Governed', + identity: { ref: 'operator' }, + tasks: [{ + id: 'root', + name: 'Root', + target: { session_target: 'shell' }, + shell: { + program: 'printf', + args: ['{"ok":true}'], + ...(inlineSecrets + ? { env: { API_TOKEN: 'task-secret' }, stdin: 'stdin-secret' } + : {}), + }, + schedule: { cron: '0 * * * *' }, + approval: { + policy: approvalPolicy, + risk_level: 'high', + approver_scope: 'domain:example.com', + }, + output: { format: 'json' }, + contract: { sandbox: 'permissive', network: 'unrestricted', audit: 'always' }, + }], + }], + }; +} + +function schedulerRunner({ handoffVersion = '3', features = {} } = {}) { + return { + invocation: { label: 'mock-scheduler' }, + queryCapabilities() { + return { + scheduler_version: 'test', + handoff_version: handoffVersion, + features: { + root_approval_gate: true, + approval_scope_enforcement: true, + structured_output_format: true, + ...features, + }, + }; + }, + listJobs() { return []; }, + addJob() { throw new Error('dry-run must not add jobs'); }, + updateJob() { throw new Error('dry-run must not update jobs'); }, + deleteJob() { throw new Error('dry-run must not delete jobs'); }, + }; +} + +test('scheduler compiler disables auto-reject jobs instead of dispatching them', () => { + const compiled = compileManifestToScheduler(governedManifest({ approvalPolicy: 'auto-reject' })); + assert.equal(compiled.jobs[0].enabled, 0); +}); + +test('standalone and scheduler artifacts do not persist raw execution or profile secrets', () => { + const manifest = governedManifest(); + const standalone = JSON.stringify(compileManifestToStandalone(manifest)); + const scheduler = JSON.stringify(compileManifestToScheduler(manifest)); + for (const secret of [ + 'identity-secret', + 'tenant-secret', + 'auth-secret', + 'input-secret', + ]) { + assert.equal(standalone.includes(secret), false, `standalone leaked ${secret}`); + assert.equal(scheduler.includes(secret), false, `scheduler leaked ${secret}`); + } +}); + +test('scheduler compiler refuses inline shell environment and stdin persistence', () => { + const manifest = governedManifest({ inlineSecrets: true }); + assert.throws( + () => compileManifestToScheduler(manifest), + error => { + const paths = error.validation?.errors?.map(item => item.path) || []; + return paths.some(path => path.endsWith('.shell.env')) + && paths.some(path => path.endsWith('.shell.stdin')); + } + ); +}); + +test('scheduler capability validation rejects unenforceable root gates, scopes, and output formats', () => { + const compiled = compileManifestToScheduler(governedManifest()); + const result = validateManifestCapabilities(compiled, { + features: { + root_approval_gate: false, + approval_scope_enforcement: false, + structured_output_format: false, + }, + }); + assert.deepEqual( + new Set(result.errors.map(error => error.feature)), + new Set(['root_approval_gate', 'approval_scope_enforcement', 'structured_output_format']) + ); +}); + +test('handoff negotiation never silently drops fields required by a newer version', async () => { + const jobs = compileManifestToScheduler(governedManifest()).jobs; + assert.equal(requiredSchedulerFieldVersion(jobs), 3); + assert.throws( + () => negotiateSchedulerFieldVersion(jobs, '2'), + error => error.code === 'unsupported_capability' && error.required_handoff_version === '3' + ); + await assert.rejects( + applyManifestToScheduler(governedManifest(), { + dryRun: true, + runner: schedulerRunner({ handoffVersion: '2' }), + }), + error => error.code === 'unsupported_capability' + ); + + const result = await applyManifestToScheduler(governedManifest(), { + dryRun: true, + runner: schedulerRunner({ handoffVersion: '3' }), + }); + assert.equal(result.handoff.field_version, '3'); +}); + +test('versioned scheduler projection includes governed v3 fields only at v3', () => { + const job = compileManifestToScheduler(governedManifest()).jobs[0]; + const v2 = schedulerCreateSpec(job, { fieldVersion: '2' }); + const v3 = schedulerCreateSpec(job, { fieldVersion: '3' }); + assert.equal('approval_risk_level' in v2, false); + assert.equal('approval_approver_scope' in v2, false); + assert.equal('output_format' in v2, false); + assert.equal(v3.approval_risk_level, 'high'); + assert.equal(v3.approval_approver_scope, 'domain:example.com'); + assert.equal(v3.output_format, 'json'); +}); + +test('dispatch compilation reflects in-place manifest edits instead of returning stale cached output', () => { + const manifest = governedManifest(); + const first = compileManifestForDispatch(manifest); + manifest.workflows[0].tasks[0].shell.args = ['{"ok":false}']; + const second = compileManifestForDispatch(manifest); + assert.notEqual(first.jobs[0].payload_message, second.jobs[0].payload_message); +}); + +test('standalone compiler capabilities agree with target discovery', () => { + const compiled = compileManifestToStandalone(governedManifest()); + for (const [feature, support] of Object.entries(TARGETS.standalone.features)) { + assert.deepEqual(compiled.capabilities[feature], support, feature); + } +}); From 8da7e14ed04901b1eed86fddddd8e885e5cd64d9 Mon Sep 17 00:00:00 2001 From: amittell Date: Sat, 11 Jul 2026 14:03:42 -0400 Subject: [PATCH 2/9] Close pre-release trust boundary gaps --- .github/workflows/publish.yml | 6 +- README.md | 2 +- docs/execution-identity.md | 4 +- docs/field-reference.md | 6 +- docs/guide-identity.md | 41 ++- src/apply.js | 6 +- src/approvals.js | 13 + src/authorization-proof/detached-signature.js | 3 + src/authorization-proof/jwt.js | 5 + src/authorization/opa.js | 29 +- src/cli.js | 17 +- src/compiler/openclaw-scheduler.js | 4 +- src/compiler/shared.js | 56 +++- src/convert.js | 21 +- src/errors.js | 11 + src/evidence/ssh.js | 11 + src/exec.js | 154 ++++++---- src/identity/spiffe-jwt-svid.js | 14 +- src/io.js | 2 + src/run.js | 2 +- src/schema.js | 7 + src/validate.js | 15 +- test/agentcli.test.js | 282 +++++++++++++++++- test/approvals.test.js | 153 +++++++++- test/cli-rpc-validation.test.js | 36 +++ test/foundation.test.js | 36 +++ test/identity-security.test.js | 57 ++++ test/integration-scheduler.test.js | 1 + test/proof-evidence.test.js | 172 ++++++++++- test/run-workflow.test.js | 31 +- 30 files changed, 1089 insertions(+), 108 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fe49e68..159c0dd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,8 +5,7 @@ on: tags: ['v*'] permissions: - contents: write - id-token: write + contents: read jobs: test: @@ -44,6 +43,9 @@ jobs: publish: needs: test runs-on: ubuntu-latest + permissions: + contents: write + id-token: write steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 diff --git a/README.md b/README.md index 3e58f68..b038abc 100644 --- a/README.md +++ b/README.md @@ -301,7 +301,7 @@ For the full guide: [Identity Setup](docs/guide-identity.md) | [Wrapping CLI Too | `azure-managed-identity` | Acquires a token from the Azure Instance Metadata Service (IMDS). Works on Azure VMs, App Service, and Container Instances. | | `aws-sts-assume-role` | Assumes an AWS IAM role via STS and returns temporary credentials. Includes AWS Signature V4 signing. | | `gcp-workload-identity` | Acquires a token from the GCP metadata server. Works on Compute Engine, Cloud Run, and GKE. | -| `spiffe-jwt-svid` | Acquires a JWT-SVID from the SPIFFE Workload API or a projected volume file. Works in SPIFFE-enabled Kubernetes clusters. | +| `spiffe-jwt-svid` | Reads a file-mounted JWT-SVID and verifies its issuer, audience, lifetime, subject, and signature against exactly one local trust source. Workload API sockets are not accepted. | | `entra-agent-id` | Acquires a token via Microsoft Entra Agent ID using JWT bearer client assertion. Supports Agent Registry, Conditional Access, and IMDS fallback. | | `stripe-api-key` | Resolves Stripe API keys with scope-aware permissions. Supports precreated restricted keys by scope name and dynamic key minting via the Stripe API. | diff --git a/docs/execution-identity.md b/docs/execution-identity.md index ae53912..613debd 100644 --- a/docs/execution-identity.md +++ b/docs/execution-identity.md @@ -2356,12 +2356,12 @@ Implementation note: `apply.js` was made async to support dynamic import of auth - [DONE] `oidc-client-credentials` as the initial cloud-neutral credential acquisition provider - [DONE] `oidc-token-exchange` for RFC 8693 token exchange (supports delegation chains and downscope handoff) -- [DONE] add enterprise and cloud-specific providers: `azure-managed-identity` (IMDS), `aws-sts-assume-role` (STS with Signature V4), `gcp-workload-identity` (metadata server), `spiffe-jwt-svid` (Workload API + file-based SVID) +- [DONE] add enterprise and cloud-specific providers: `azure-managed-identity` (IMDS), `aws-sts-assume-role` (STS with Signature V4), `gcp-workload-identity` (metadata server), `spiffe-jwt-svid` (cryptographically verified file-mounted SVID) - [DONE] `entra-agent-id` provider -- authenticates via Entra token endpoint with JWT bearer client assertion, supports IMDS fallback, GUID validation, downscope handoff, and delegation - [DONE] providers implement trust level capabilities from the start - [DONE] ship at least one authorization provider: `opa` provider implements OPA REST API integration via fetch() -Implementation note: All eleven identity providers are fully implemented and functional. Enterprise providers (`azure-managed-identity`, `aws-sts-assume-role`, `gcp-workload-identity`, `spiffe-jwt-svid`, `entra-agent-id`) use their platform's native metadata endpoints and fail with clear error messages when not running in the target environment. The `aws-sts-assume-role` provider includes a minimal AWS Signature V4 implementation using Node's built-in `crypto` module. The `spiffe-jwt-svid` provider supports both file-based SVID acquisition (for Kubernetes projected volumes) and HTTP-based Workload API access. +Implementation note: All eleven identity providers are fully implemented within their documented boundaries. Enterprise providers fail with clear errors when required platform services or local trust material are unavailable. The `aws-sts-assume-role` provider includes a minimal AWS Signature V4 implementation using Node's built-in `crypto` module. The `spiffe-jwt-svid` provider deliberately supports only file-mounted JWT-SVID acquisition with local cryptographic trust material; it rejects Workload API sockets rather than approximating the gRPC protocol. ### Additional Implementation Items diff --git a/docs/field-reference.md b/docs/field-reference.md index c04288e..954bb02 100644 --- a/docs/field-reference.md +++ b/docs/field-reference.md @@ -374,6 +374,8 @@ Each element in the `bindings` array is an object with these fields: |-------|------|----------|--------|-------------| | `kind` | string | No | `env`, `file`, `stdin`, `none` | Mechanism for exposing the credential. | | `name` | string | No | -- | Environment variable name (for `env`) or file name (for `file`). | +| `prefix` | string | No | -- | Safe prefix of at most 80 characters for a generated credential file. | +| `expose_as` | string | No | -- | Environment variable that receives the generated credential file path. | --- @@ -384,7 +386,7 @@ Each element in the `bindings` array is an object with these fields: | Field | Type | Required | Description | |-------|------|----------|-------------| | `id` | string | Yes | Unique identifier within the manifest. Must be an identifier. | -| `provider` | string | Yes | Identity provider name. Free-form string. Known providers: `none`, `env-bearer`, `file-bearer`, `oidc-client-credentials`, `oidc-token-exchange`, `azure-managed-identity`, `aws-sts-assume-role`, `gcp-workload-identity`, `spiffe-jwt-svid`, `entra-agent-id`. | +| `provider` | string | Yes | Identity provider name. Free-form string. Known providers: `none`, `env-bearer`, `file-bearer`, `oidc-client-credentials`, `oidc-token-exchange`, `azure-managed-identity`, `aws-sts-assume-role`, `gcp-workload-identity`, `spiffe-jwt-svid`, `entra-agent-id`, `stripe-api-key`. | | `subject` | object | No | Subject descriptor. Same fields as [Identity Subject Fields](#identity-subject-fields). | | `auth` | object | No | Authentication configuration. Same fields as [Identity Auth Fields](#identity-auth-fields). | | `trust` | object | No | Trust level declaration. Same fields as [Identity Trust Fields](#identity-trust-fields). | @@ -519,6 +521,8 @@ Current include values are: | `context` | object | No | -- | Additional context included in the evidence record. Free-form. | | `format` | string | No | `canonical-json`, `json` | Evidence payload serialization format. | +The `ssh` evidence provider accepts only `canonical-json`. When `verify.required` is `true`, configure a signing `key_path`, the signing `principal`, and an `allowed_signers_path` that trusts that principal and public key. Verification fails closed when the trust file is missing or does not contain the signer. + ### verify (on evidence profiles) | Field | Type | Required | Description | diff --git a/docs/guide-identity.md b/docs/guide-identity.md index d0dd5ef..155220e 100644 --- a/docs/guide-identity.md +++ b/docs/guide-identity.md @@ -102,6 +102,7 @@ to pass it to the tool process. "args": ["-lc", "curl -H \"Authorization: Bearer $TOOL_ACCESS_TOKEN\" https://api.example.com/deploy"] }, "target": { "session_target": "shell" }, + "schedule": { "cron": "0 0 * * *", "tz": "UTC" }, "identity": { "ref": "api-service" } } ] @@ -222,6 +223,7 @@ using the client credentials grant (RFC 6749 Section 4.4). "args": ["sync.py"] }, "target": { "session_target": "shell" }, + "schedule": { "cron": "0 0 * * *", "tz": "UTC" }, "identity": { "ref": "oidc-service" } } ] @@ -361,6 +363,7 @@ token at `/var/run/secrets/kubernetes.io/serviceaccount/token`. "args": ["get", "pods"] }, "target": { "session_target": "shell" }, + "schedule": { "cron": "0 0 * * *", "tz": "UTC" }, "identity": { "ref": "k8s-service" } } ] @@ -466,6 +469,7 @@ or type. Implements OAuth 2.0 Token Exchange (RFC 8693). "args": ["-lc", "curl -H \"Authorization: Bearer $EXCHANGED_TOKEN\" https://downstream.example.com/api"] }, "target": { "session_target": "shell" }, + "schedule": { "cron": "0 0 * * *", "tz": "UTC" }, "identity": { "ref": "exchange-service" } } ] @@ -567,6 +571,7 @@ identity enabled. The provider acquires tokens from the Azure Instance Metadata "args": ["-lc", "curl -H \"Authorization: Bearer $AZURE_ACCESS_TOKEN\" \"https://management.azure.com/subscriptions?api-version=2022-12-01\""] }, "target": { "session_target": "shell" }, + "schedule": { "cron": "0 0 * * *", "tz": "UTC" }, "identity": { "ref": "azure-service" } } ] @@ -674,6 +679,7 @@ AWS Signature Version 4 signing with no external dependencies. "args": ["s3", "ls"] }, "target": { "session_target": "shell" }, + "schedule": { "cron": "0 0 * * *", "tz": "UTC" }, "identity": { "ref": "aws-service" } } ] @@ -777,6 +783,7 @@ attached. The provider acquires tokens from the GCP metadata server at "args": ["-lc", "curl -H \"Authorization: Bearer $GCP_ACCESS_TOKEN\" \"https://compute.googleapis.com/compute/v1/projects/my-project/zones/us-central1-a/instances\""] }, "target": { "session_target": "shell" }, + "schedule": { "cron": "0 0 * * *", "tz": "UTC" }, "identity": { "ref": "gcp-service" } } ] @@ -814,9 +821,9 @@ is specified, the metadata server returns a token for that specific service acco ## Quick Setup: spiffe-jwt-svid -Use this in SPIFFE-enabled Kubernetes clusters running SPIRE or Istio. The provider -acquires JWT-SVIDs (SPIFFE Verifiable Identity Documents) from a file on disk or the -SPIFFE Workload API. +Use this in SPIFFE-enabled Kubernetes clusters running SPIRE or Istio when a +JWT-SVID and its verification key or JWKS are mounted as local files. The provider +does not implement the gRPC SPIFFE Workload API. ### Manifest @@ -837,7 +844,8 @@ SPIFFE Workload API. "audience": "spiffe://example.org/downstream", "required": true, "provider_config": { - "svid_file": "/var/run/secrets/spiffe/svid.jwt" + "svid_file": "/var/run/secrets/spiffe/svid.jwt", + "public_key_file": "/var/run/secrets/spiffe/jwt-svid-public.pem" } }, "trust": { @@ -846,7 +854,7 @@ SPIFFE Workload API. "presentation": { "bindings": [ { - "source": "credentials.access_token.value", + "source": "credentials.jwt_svid.value", "target": { "kind": "env", "name": "SPIFFE_JWT_SVID" }, "required": true, "redact": true @@ -874,6 +882,7 @@ SPIFFE Workload API. "args": ["-lc", "curl -H \"Authorization: Bearer $SPIFFE_JWT_SVID\" https://peer.example.svc.cluster.local/api"] }, "target": { "session_target": "shell" }, + "schedule": { "cron": "0 0 * * *", "tz": "UTC" }, "identity": { "ref": "spiffe-service" } } ] @@ -887,12 +896,11 @@ SPIFFE Workload API. | Field | Location | Required | |---|---|---| | `audience` | `auth.audience` or `auth.provider_config.audience` | Yes | -| `svid_file` | `auth.provider_config.svid_file` | No (path to a file containing the JWT-SVID, e.g. Kubernetes projected volume) | -| `workload_api_socket` | `auth.provider_config.workload_api_socket` | No (defaults to `SPIFFE_ENDPOINT_SOCKET` env var; supports `http://` or `https://` endpoints) | +| `svid_file` | `auth.provider_config.svid_file` | Yes when `auth.required` is true | +| One trust source | `auth.provider_config.public_key_pem`, `public_key_file`, `jwks`, or `jwks_file` | Exactly one when `auth.required` is true | -The provider tries `svid_file` first, then falls back to the Workload API socket. For -Unix domain sockets (the standard SPIRE agent configuration), use the file-based approach -since standard Node `fetch()` does not support UDS connections. +`workload_api_socket` and remote `jwks_uri` values are rejected. Project the +JWT-SVID and a local public key or JWKS into the workload instead. ### Run it @@ -909,12 +917,10 @@ agentcli identity resolve manifest.json call-peer ### When to use this provider Use `spiffe-jwt-svid` in Kubernetes clusters with SPIRE agent or Istio that project -JWT-SVIDs into pod volumes. The `svid_file` approach works with Kubernetes projected -service account tokens and SPIRE agent projected volumes. The HTTP-based Workload API -approach works with SPIRE agents or Envoy SDS sidecars configured with TCP listeners. -The provider parses JWT claims (sub, aud, exp, iss) from the SVID for audit purposes -but does not verify the signature, as that is the responsibility of the consuming service's -SPIFFE trust bundle verifier. +JWT-SVIDs and trust material into pod volumes. The declared principal, when present, +must exactly match the cryptographically verified `sub` claim. The provider verifies +the signature, issuer, requested audience, activation time, expiration, and SPIFFE ID +shape before returning a session. ## Quick Setup: entra-agent-id @@ -980,6 +986,7 @@ assertions and supports agent-specific Conditional Access policies and lifecycle "args": ["-lc", "curl -H \"Authorization: Bearer $ENTRA_ACCESS_TOKEN\" https://graph.microsoft.com/v1.0/me"] }, "target": { "session_target": "shell" }, + "schedule": { "cron": "0 0 * * *", "tz": "UTC" }, "identity": { "ref": "entra-agent" } } ] @@ -1220,6 +1227,7 @@ For production-grade isolation on Linux or Windows, the recommended path today i "name": "Deploy", "shell": { "program": "deploy.sh", "args": [] }, "target": { "session_target": "shell" }, + "schedule": { "cron": "0 0 * * *", "tz": "UTC" }, "identity": { "ref": "prod-agent" } } ] @@ -1469,6 +1477,7 @@ and checks the attestation signature against the recorded principal. "name": "Build", "shell": { "program": "make", "args": ["build"] }, "target": { "session_target": "shell" }, + "schedule": { "cron": "0 0 * * *", "tz": "UTC" }, "identity": { "ref": "build-agent" }, "evidence": { "ref": "ssh-evidence" } } diff --git a/src/apply.js b/src/apply.js index 5170f0f..6c6c94a 100644 --- a/src/apply.js +++ b/src/apply.js @@ -255,7 +255,9 @@ export async function applyManifestToScheduler( const compiled = compileManifestToScheduler(manifest, { includeExplain }); const verificationByTask = new Map(); const resolvedProofsByTask = buildResolvedAuthorizationProofsByTask(manifest); - const hasV02Features = compiled.jobs.some(jobRequiresCapabilityNegotiation); + const requiredHandoffVersion = requiredSchedulerFieldVersion(compiled.jobs); + const requiresCapabilityNegotiation = + requiredHandoffVersion > 1 || compiled.jobs.some(jobRequiresCapabilityNegotiation); // Construct the scheduler runner once; runtime capability negotiation is only // needed when the compiled manifest actually uses v0.2 runtime-gated fields. @@ -270,7 +272,7 @@ export async function applyManifestToScheduler( let effectiveResult = resolveEffectiveFeatures('openclaw-scheduler', null); let handoffVersion = '1'; let capabilityWarnings = []; - if (hasV02Features) { + if (requiresCapabilityNegotiation) { const runtimeCaps = querySchedulerCapabilities(schedulerRunner); effectiveResult = resolveEffectiveFeatures('openclaw-scheduler', runtimeCaps); diff --git a/src/approvals.js b/src/approvals.js index 9c061bb..5842021 100644 --- a/src/approvals.js +++ b/src/approvals.js @@ -111,6 +111,9 @@ export function computeTaskApprovalHash({ task: suppliedTask, taskId, cwd = process.cwd(), + env = process.env, + timeoutMs, + instanceId, } = {}) { if (binding) return computeEffectiveTaskHash(binding); @@ -140,6 +143,9 @@ export function computeTaskApprovalHash({ workflow: compatibilityWorkflow, task, cwd, + env, + timeoutMs, + instanceId, })); } @@ -405,6 +411,9 @@ export function grantApproval({ signer, signingKey, env = process.env, + cwd = process.cwd(), + timeoutMs, + instanceId, now = Date.now(), }) { if (!manifest || typeof manifest !== 'object') { @@ -475,6 +484,10 @@ export function grantApproval({ expanded, workflow, task, + cwd, + env, + timeoutMs, + instanceId, }); const taskHash = computeTaskApprovalHash({ binding }); const grantedAt = new Date(now).toISOString(); diff --git a/src/authorization-proof/detached-signature.js b/src/authorization-proof/detached-signature.js index c0613f1..064b40e 100644 --- a/src/authorization-proof/detached-signature.js +++ b/src/authorization-proof/detached-signature.js @@ -16,6 +16,8 @@ import process from 'node:process'; import { canonicalStringify, hashString } from '../canonical.js'; import { registerVerifier } from './index.js'; +const PRIVATE_KEY_PEM = /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----/; + /** * Attempt to auto-detect a suitable verification algorithm from a PEM public key. * @@ -26,6 +28,7 @@ import { registerVerifier } from './index.js'; * @returns {string|null|undefined} Algorithm identifier, null for EdDSA, or undefined for unsupported keys. */ function detectAlgorithm(pem) { + if (PRIVATE_KEY_PEM.test(String(pem))) return undefined; try { const keyObj = createPublicKey(pem); const type = keyObj.asymmetricKeyType; diff --git a/src/authorization-proof/jwt.js b/src/authorization-proof/jwt.js index ada6175..cfa6b3d 100644 --- a/src/authorization-proof/jwt.js +++ b/src/authorization-proof/jwt.js @@ -29,6 +29,7 @@ const AUDIT_SAFE_CLAIMS = [ 'manifest_digest', ]; const jwksCache = new Map(); +const PRIVATE_KEY_PEM = /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----/; // -- JWT Helpers -- @@ -99,11 +100,15 @@ function normalizeVerificationKey(publicKey) { } if (typeof publicKey === 'string' || Buffer.isBuffer(publicKey)) { + if (PRIVATE_KEY_PEM.test(String(publicKey))) { + throw new Error('private key material is forbidden in public_key'); + } return createPublicKey(publicKey); } if (typeof publicKey === 'object' && publicKey !== null) { if ('kty' in publicKey) { + if ('d' in publicKey) throw new Error('private JWK material is forbidden in public_key'); return createPublicKey({ key: publicKey, format: 'jwk' }); } return createPublicKey(publicKey); diff --git a/src/authorization/opa.js b/src/authorization/opa.js index 93b0ad0..4e42ae2 100644 --- a/src/authorization/opa.js +++ b/src/authorization/opa.js @@ -11,6 +11,20 @@ */ import { registerAuthorizationProvider } from './index.js'; +import { validateSecureEndpoint } from '../identity/session.js'; + +function auditSafeEndpointReference(value) { + try { + const parsed = new URL(value); + parsed.username = ''; + parsed.password = ''; + parsed.search = ''; + parsed.hash = ''; + return parsed.toString(); + } catch { + return null; + } +} const opaAuthorizationProvider = { name: 'opa', @@ -35,6 +49,8 @@ const opaAuthorizationProvider = { const endpoint = profile?.provider_config?.endpoint; if (typeof endpoint !== 'string' || endpoint.trim() === '') { errors.push('provider_config.endpoint must be a non-empty string'); + } else { + errors.push(...validateSecureEndpoint(endpoint, 'provider_config.endpoint')); } if (errors.length > 0) { @@ -57,6 +73,11 @@ const opaAuthorizationProvider = { */ async authorize(request, profile, _ctx) { const endpoint = profile.provider_config.endpoint; + const endpointErrors = validateSecureEndpoint(endpoint, 'provider_config.endpoint'); + if (endpointErrors.length > 0) { + throw Object.assign(new Error(endpointErrors.join('; ')), { code: 'authorization_error' }); + } + const policyRef = auditSafeEndpointReference(endpoint); const onError = profile.on_error || 'deny'; const input = { input: request }; @@ -74,7 +95,7 @@ const opaAuthorizationProvider = { decision: 'permit', provider: 'opa', reason: 'authorization provider error (on_error: warn)', - policy_ref: endpoint, + policy_ref: policyRef, }; } throw Object.assign( @@ -89,7 +110,7 @@ const opaAuthorizationProvider = { decision: 'permit', provider: 'opa', reason: 'authorization provider error (on_error: warn)', - policy_ref: endpoint, + policy_ref: policyRef, }; } throw Object.assign( @@ -107,7 +128,7 @@ const opaAuthorizationProvider = { decision: 'permit', provider: 'opa', reason: 'authorization provider error (on_error: warn)', - policy_ref: endpoint, + policy_ref: policyRef, }; } throw Object.assign( @@ -136,7 +157,7 @@ const opaAuthorizationProvider = { decision, provider: 'opa', reason, - policy_ref: endpoint, + policy_ref: policyRef, }; }, diff --git a/src/cli.js b/src/cli.js index ebd03f0..deef655 100644 --- a/src/cli.js +++ b/src/cli.js @@ -61,7 +61,8 @@ Commands: inspect [--db path] [--fields a,b,c] [--limit n] [--sanitize basic] [--ndjson] audit [--limit n] approve [--workflow id] [--by principal] [--reason text] - [--ttl-s seconds] [--signer ssh|none] [--signing-key path] + [--ttl-s seconds] [--timeout ms] [--instance-id id] + [--signer ssh|none] [--signing-key path] approvals list [--status pending|consumed|expired|revoked|all] [--workflow id] [--task id] approvals revoke [--by principal] [--reason text] verify [--allowed-signers path] @@ -174,6 +175,8 @@ const COMMAND_FLAGS = Object.freeze({ by: VALUE_FLAG, reason: VALUE_FLAG, 'ttl-s': VALUE_FLAG, + timeout: VALUE_FLAG, + 'instance-id': VALUE_FLAG, signer: VALUE_FLAG, 'signing-key': VALUE_FLAG, }, @@ -676,7 +679,7 @@ export async function runCli( const taskId = positionals[2]; if (!manifestInput || !taskId) { throw Object.assign( - new Error('Usage: agentcli approve [--workflow id] [--by principal] [--reason text] [--ttl-s seconds] [--signer ssh|none] [--signing-key path]'), + new Error('Usage: agentcli approve [--workflow id] [--by principal] [--reason text] [--ttl-s seconds] [--timeout ms] [--instance-id id] [--signer ssh|none] [--signing-key path]'), { code: 'invalid_argument' } ); } @@ -688,6 +691,13 @@ export async function runCli( { code: 'invalid_argument' } ); } + const rawTimeout = flags.timeout; + if (rawTimeout != null && (typeof rawTimeout !== 'string' || !/^[1-9][0-9]*$/.test(rawTimeout))) { + throw Object.assign( + new Error(`Invalid --timeout value: ${rawTimeout}. Must be a positive integer (milliseconds).`), + { code: 'invalid_argument' } + ); + } const approver = flags.by || derivedEnv.USER || derivedEnv.LOGNAME; if (!approver) { throw Object.assign( @@ -705,6 +715,9 @@ export async function runCli( signer: flags.signer || undefined, signingKey: flags['signing-key'] || undefined, env: derivedEnv, + cwd, + timeoutMs: rawTimeout ? Number(rawTimeout) : undefined, + instanceId: flags['instance-id'] || undefined, }); return formatOutput({ ok: true, approval: record }, { mode: outputMode, pretty }); } diff --git a/src/compiler/openclaw-scheduler.js b/src/compiler/openclaw-scheduler.js index 312c8be..af9af59 100644 --- a/src/compiler/openclaw-scheduler.js +++ b/src/compiler/openclaw-scheduler.js @@ -336,8 +336,8 @@ export function compileManifestToScheduler(manifest, { includeExplain = false } approval_required: plan.approval.required, approval_timeout_s: plan.approval.timeout_s ?? SCHEDULER_DEFAULT_APPROVAL_TIMEOUT_S, approval_auto: plan.approval.auto ?? SCHEDULER_DEFAULT_APPROVAL_AUTO, - approval_risk_level: plan.approval.risk_level, - approval_approver_scope: plan.approval.approver_scope, + approval_risk_level: task.approval?.risk_level ?? null, + approval_approver_scope: task.approval?.approver_scope ?? null, context_retrieval: plan.context.retrieval, context_retrieval_limit: plan.context.limit, ...outputPolicy, diff --git a/src/compiler/shared.js b/src/compiler/shared.js index b00628a..c93a1ef 100644 --- a/src/compiler/shared.js +++ b/src/compiler/shared.js @@ -1,7 +1,35 @@ import { createHash } from 'node:crypto'; +import { realpathSync } from 'node:fs'; +import { resolve as resolvePath } from 'node:path'; import { normalizeShellExecution, renderShellExecution } from '../shell.js'; import { canonicalDigest, canonicalStringify, hashNullableString, hashString } from '../canonical.js'; +export const OPERATIONAL_ENV_KEYS = new Set([ + 'PATH', 'HOME', 'TMPDIR', 'TMP', 'TEMP', + 'LANG', 'SHELL', 'USER', 'LOGNAME', 'TZ', 'TERM', + 'SystemRoot', 'WINDIR', 'ComSpec', 'PATHEXT', +]); + +export function buildChildEnvironment(env = {}, declaredEnv = {}) { + const inherited = {}; + for (const [key, value] of Object.entries(env || {})) { + if (OPERATIONAL_ENV_KEYS.has(key) || key.startsWith('LC_')) { + inherited[key] = value; + } + } + return { ...inherited, ...declaredEnv }; +} + +export function resolveExecutionCwd(shellCwd, cwd = process.cwd()) { + const resolved = resolvePath(cwd, shellCwd || '.'); + try { + return realpathSync(resolved); + } catch (error) { + if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return resolved; + throw error; + } +} + function isObjectLike(value) { return value && typeof value === 'object' && !Array.isArray(value); } @@ -594,17 +622,21 @@ function bindEvidence(evidence) { }; } -export function commandBindingForShell(shell, { cwd = process.cwd() } = {}) { +export function commandBindingForShell(shell, { + cwd = process.cwd(), + env = process.env, +} = {}) { const normalized = normalizeShellExecution(shell || {}); + const effectiveEnv = buildChildEnvironment(env, normalized.env || {}); const envHashes = {}; - for (const key of Object.keys(normalized.env || {}).sort()) { - envHashes[key] = hashString(normalized.env[key]); + for (const key of Object.keys(effectiveEnv).sort()) { + envHashes[key] = hashString(effectiveEnv[key]); } return { program: normalized.program, args_hashes: (normalized.args || []).map(arg => hashString(arg)), args_count: (normalized.args || []).length, - cwd: normalized.cwd || cwd, + cwd: resolveExecutionCwd(normalized.cwd, cwd), env_keys: Object.keys(envHashes), env_hashes: envHashes, stdin_hash: hashNullableString(normalized.stdin), @@ -617,6 +649,9 @@ export function buildEffectiveExecutionBinding({ workflow, task, cwd = process.cwd(), + env = process.env, + timeoutMs, + instanceId, } = {}) { if (!workflow || !task) { throw new TypeError('workflow and task are required to build an execution binding'); @@ -651,13 +686,20 @@ export function buildEffectiveExecutionBinding({ return { binding_version: 1, manifest_version: expanded?.version ?? manifest?.version ?? null, - manifest_digest: expanded ? canonicalDigest(expanded) : null, + manifest_digest: manifest + ? canonicalDigest(manifest) + : expanded + ? canonicalDigest(expanded) + : null, source: { workflow_id: workflow.id, task_id: task.id }, enabled: task.enabled ?? true, target: task.target ?? null, - command: task.shell ? commandBindingForShell(task.shell, { cwd }) : null, + command: task.shell ? commandBindingForShell(task.shell, { cwd, env }) : null, prompt_hash: hashNullableString(task.prompt), - runtime: { timeout_ms: task.runtime?.timeout_ms ?? null }, + runtime: { + timeout_ms: timeoutMs ?? task.runtime?.timeout_ms ?? null, + instance_id: instanceId ?? null, + }, approval: approvalPolicyForTask(task), identity: bindIdentity(identity), contract: resolveContract(workflow, task), diff --git a/src/convert.js b/src/convert.js index 1b7db98..1857056 100644 --- a/src/convert.js +++ b/src/convert.js @@ -27,6 +27,16 @@ function attestationProfileId(attestation) { return `legacy-${identifierSlug(attestation, 'attestation')}-${shortHash(attestation)}`; } +function mergeLegacyIdentity(workflowIdentity, scopedIdentity) { + const base = workflowIdentity || {}; + const scoped = scopedIdentity || {}; + return { + principal: scoped.principal ?? base.principal ?? null, + run_as: scoped.run_as ?? base.run_as ?? null, + attestation: scoped.attestation ?? base.attestation ?? null, + }; +} + /** * Add an authorization_proof_profile for the attestation if one does not * already exist in the converted manifest. @@ -144,7 +154,10 @@ export function convertManifestV1toV2(manifest) { } for (const task of (workflow.tasks || [])) { - const taskProfileRef = ensureIdentityProfile(task.identity); + const effectiveTaskIdentity = task.identity + ? mergeLegacyIdentity(workflow.identity, task.identity) + : null; + const taskProfileRef = ensureIdentityProfile(effectiveTaskIdentity); const convertedTask = { ...task, @@ -160,7 +173,11 @@ export function convertManifestV1toV2(manifest) { } if (task.on_failure?.identity) { - const failureProfileRef = ensureIdentityProfile(task.on_failure.identity); + const effectiveFailureIdentity = mergeLegacyIdentity( + workflow.identity, + task.on_failure.identity + ); + const failureProfileRef = ensureIdentityProfile(effectiveFailureIdentity); convertedTask.on_failure = { ...task.on_failure, identity: failureProfileRef ? { ref: failureProfileRef } : null, diff --git a/src/errors.js b/src/errors.js index 4299b50..e9a897f 100644 --- a/src/errors.js +++ b/src/errors.js @@ -27,6 +27,17 @@ export const ERROR_CODES = Object.freeze([ 'identity_delegation_invalid', 'unknown_identity_provider', 'identity_provider_error', + 'spiffe_svid_invalid', + 'spiffe_trust_invalid', + 'spiffe_trust_required', + 'spiffe_algorithm_unsupported', + 'spiffe_svid_expired', + 'spiffe_svid_not_active', + 'spiffe_audience_mismatch', + 'spiffe_issuer_mismatch', + 'spiffe_signature_invalid', + 'spiffe_svid_unavailable', + 'spiffe_refresh_unavailable', 'resolution_failed', 'token_not_found', 'token_file_empty', diff --git a/src/evidence/ssh.js b/src/evidence/ssh.js index e1f594a..2d58c14 100644 --- a/src/evidence/ssh.js +++ b/src/evidence/ssh.js @@ -160,6 +160,17 @@ const sshEvidenceProvider = { name: 'ssh', methods: ['ssh-signature'], + validateProfile(profile = {}) { + const format = profile.payload?.format; + if (format != null && format !== 'canonical-json') { + return { + valid: false, + errors: ['payload.format must be "canonical-json" for the SSH evidence provider'], + }; + } + return { valid: true }; + }, + /** * Resolve signing credentials from config and context. * diff --git a/src/exec.js b/src/exec.js index a13dde2..05dc849 100644 --- a/src/exec.js +++ b/src/exec.js @@ -1,6 +1,7 @@ import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { isAbsolute, relative, resolve as resolvePath } from 'node:path'; +import { isAbsolute, relative } from 'node:path'; +import { canonicalDigest } from './canonical.js'; import { validateManifest } from './validate.js'; import { resolveValueFrom } from './command.js'; import { expandManifestShorthands } from './shorthand.js'; @@ -10,12 +11,14 @@ import { mergeAuthorizationProofProfile, mergeEvidenceProfile, mergeIdentityProfile, + buildChildEnvironment, buildEffectiveExecutionBinding, computeEffectiveTaskHash, resolveAuthorization, resolveAuthorizationProof, resolveContract, resolveEvidence, + resolveExecutionCwd, resolveIdentity, resolveVerify } from './compiler/shared.js'; @@ -52,9 +55,9 @@ import './identity/entra-agent-id.js'; import { compareTrustLevels, redactSession, buildCredentialSummary } from './identity/session.js'; // v0.2 evidence providers -import { resolveEvidenceProvider } from './evidence/index.js'; +import { resolveEvidenceProvider, verifyEvidenceEnvelope } from './evidence/index.js'; import './evidence/none.js'; -import './evidence/ssh.js'; +import { resolveAllowedSigners as resolveEvidenceAllowedSigners } from './evidence/ssh.js'; import { buildCompleteEvidencePayload, serializePayload, @@ -85,11 +88,11 @@ function isPathWithin(targetPath, rootPath) { function preflightContractChecks(contract, shell, { cwd = process.cwd() } = {}) { const violations = []; const warnings = []; - const executionCwd = resolvePath(cwd, shell.cwd || '.'); + const executionCwd = resolveExecutionCwd(shell.cwd, cwd); if (contract.allowed_paths?.length) { const allowed = contract.allowed_paths.some(p => - isPathWithin(executionCwd, resolvePath(cwd, p)) + isPathWithin(executionCwd, resolveExecutionCwd(p, cwd)) ); if (!allowed) { violations.push({ @@ -128,7 +131,7 @@ function runVerify(verify, { usesSandbox ? ['-p', sandboxCommand.profile, verifyProgram, ...verifyArgs] : verifyArgs, { cwd, - env: { ...process.env, ...env }, + env, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 1 * 1024 * 1024, @@ -147,6 +150,24 @@ function runVerify(verify, { stderr, timed_out: timedOut, duration_ms: durationMs, + stdout_bytes: Buffer.byteLength(stdout, 'utf8'), + stderr_bytes: Buffer.byteLength(stderr, 'utf8'), + }; +} + +function auditSafeVerifyResult(result) { + if (!result) return null; + const { stdout, stderr, structured, ...safe } = result; + const hash = value => value == null + ? null + : `sha256:${createHash('sha256').update(String(value), 'utf8').digest('hex')}`; + return { + ...safe, + stdout_hash: hash(stdout), + stderr_hash: hash(stderr), + structured_hash: structured == null + ? null + : canonicalDigest(structured), }; } @@ -158,26 +179,10 @@ function resolvePrincipal(identity) { return `${user}@${host}`; } -const OPERATIONAL_ENV_KEYS = new Set([ - 'PATH', 'HOME', 'TMPDIR', 'TMP', 'TEMP', - 'LANG', 'SHELL', 'USER', 'LOGNAME', 'TZ', 'TERM', - 'SystemRoot', 'WINDIR', 'ComSpec', 'PATHEXT', -]); - -function buildChildEnvironment(env, declaredEnv = {}) { - const inherited = {}; - for (const [key, value] of Object.entries(env || {})) { - if (OPERATIONAL_ENV_KEYS.has(key) || key.startsWith('LC_')) { - inherited[key] = value; - } - } - return { ...inherited, ...declaredEnv }; -} - function safeCommandMetadata(binding, shell, cwd) { return { program: shell.program, - cwd: shell.cwd || cwd, + cwd: binding.command?.cwd ?? resolveExecutionCwd(shell.cwd, cwd), args_count: binding.command?.args_count ?? shell.args.length, args_hashes: binding.command?.args_hashes ?? [], env_keys: binding.command?.env_keys ?? Object.keys(shell.env), @@ -372,6 +377,7 @@ function resolveCommonState(manifest, { return { requiresDelegation: true, manifest: expanded, + sourceManifest: manifest, expanded, workflow, task, @@ -418,6 +424,7 @@ function resolveCommonState(manifest, { const verify = resolveVerify(workflow, task); const auditPolicy = contract.audit ?? 'always'; const shell = normalizeShellExecution(task.shell); + const executionCwd = resolveExecutionCwd(shell.cwd, cwd); const effectiveTimeout = timeoutMs ?? task.runtime?.timeout_ms ?? null; const { violations, warnings: preflightWarnings } = preflightContractChecks(contract, shell, { cwd }); const warnings = [...preflightWarnings]; @@ -430,9 +437,10 @@ function resolveCommonState(manifest, { } return { + sourceManifest: manifest, expanded, workflow, task, isV2, identity, contract, verify, auditPolicy, shell, effectiveTimeout, violations, warnings, - signer, explicitSigningKey, cwd, env, + signer, explicitSigningKey, cwd, executionCwd, env, }; } @@ -479,8 +487,12 @@ function buildDryRunResult(common, { binding, taskHash, timestamp, executionId } }; } -function prepareLiveCommon(common, { signer, signingKey, cwd, env }) { - const sandboxCommand = prepareSandboxedShellCommand(common.shell, common.contract, { cwd, env }); +function prepareLiveCommon(common, { signer, signingKey, env }) { + const sandboxCommand = prepareSandboxedShellCommand( + { ...common.shell, cwd: common.executionCwd }, + common.contract, + { cwd: common.cwd, env } + ); common.warnings.push(...sandboxCommand.warnings); const provider = resolveProvider({ signer, env }); const providerConfig = provider.resolve({ env, signingKey }); @@ -556,6 +568,9 @@ export function executeTask(manifest, { workflow: common.workflow, task: common.task, cwd, + env, + timeoutMs: common.effectiveTimeout, + instanceId, }); const taskHash = computeEffectiveTaskHash(binding); @@ -627,6 +642,9 @@ async function inspectTaskGovernance(manifest, mode, { workflow: common.workflow, task: common.task, cwd, + env, + timeoutMs: common.effectiveTimeout, + instanceId, }); const taskHash = computeEffectiveTaskHash(binding); @@ -810,7 +828,7 @@ function executeDelegated(common, options) { function executeV1(common, { approvalUsed, binding, taskHash, timestamp, executionId }) { const { workflow, task, identity, contract, verify, auditPolicy, shell, sandboxCommand, - effectiveTimeout, warnings, provider, providerConfig, cwd, env, + effectiveTimeout, warnings, provider, providerConfig, cwd, executionCwd, env, } = common; let declaredIdentity = null; @@ -849,7 +867,7 @@ function executeV1(common, { approvalUsed, binding, taskHash, timestamp, executi const spawnEnv = buildChildEnvironment(env, shell.env); const spawnOpts = { - cwd: shell.cwd || cwd, + cwd: executionCwd, env: spawnEnv, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, @@ -934,18 +952,19 @@ function executeV1(common, { approvalUsed, binding, taskHash, timestamp, executi let verifyFailed = false; if (verify && exitCode === 0) { verifyResult = runVerify(verify, { - cwd: shell.cwd || cwd, + cwd: executionCwd, env: spawnEnv, sandboxCommand, }); if (!verifyResult.passed) { if (verify.on_failure === 'warn') { - warnings.push(`Verify command failed (exit ${verifyResult.exit_code}): ${verifyResult.stderr || verifyResult.stdout || '(no output)'}`); + warnings.push(`Verify command failed (exit ${verifyResult.exit_code}); raw verify output is omitted from audit records`); } else { verifyFailed = true; } } } + const auditVerifyResult = auditSafeVerifyResult(verifyResult); const effectiveOk = exitCode === 0 && !verifyFailed; @@ -971,7 +990,7 @@ function executeV1(common, { approvalUsed, binding, taskHash, timestamp, executi signer: provider.name, attestation, attestation_note, - verify: verifyResult, + verify: auditVerifyResult, warnings, dry_run: false, result: auditResult, @@ -982,14 +1001,11 @@ function executeV1(common, { approvalUsed, binding, taskHash, timestamp, executi } if (verifyFailed) { - const verifyStdout = verifyResult.stdout || ''; - const verifyStderr = verifyResult.stderr || ''; - const detail = verifyStderr || verifyStdout || '(no output)'; throw Object.assign( - new Error(`Verify command failed (exit ${verifyResult.exit_code}): ${detail}`), + new Error(`Verify command failed (exit ${verifyResult.exit_code}); raw verify output is omitted`), { code: 'verify_failed', - verify: verifyResult, + verify: auditVerifyResult, execution_id: executionId, source: { workflow_id: workflow.id, task_id: task.id }, } @@ -1074,16 +1090,16 @@ async function executeV2Core(common, { inspectionMode = null, }, cleanupState) { const { - expanded, workflow, task, identity, contract, verify, auditPolicy, shell, sandboxCommand, - effectiveTimeout, warnings, provider, providerConfig, cwd, + sourceManifest, expanded, workflow, task, identity, contract, verify, auditPolicy, shell, + sandboxCommand, effectiveTimeout, warnings, provider, providerConfig, cwd, executionCwd, } = common; const authorizationCommand = shell ? { program: shell.program, args: shell.args, - cwd: shell.cwd || cwd, - env_keys: Object.keys(shell.env), + cwd: executionCwd, + env_keys: binding.command?.env_keys ?? Object.keys(shell.env), stdin_present: shell.stdin != null, } : { @@ -1153,7 +1169,7 @@ async function executeV2Core(common, { : null; verificationResult = proofValue ? await verifyAuthorizationProof(proofValue, authorizationProofDeclaration, { - manifest: expanded, + manifest: sourceManifest, manifestDigest, env: proofEnv, cwd, @@ -1630,7 +1646,7 @@ async function executeV2Core(common, { : null; const spawnOpts = { - cwd: shell.cwd || cwd, + cwd: executionCwd, env: spawnEnv, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, @@ -1731,24 +1747,26 @@ async function executeV2Core(common, { let verifyFailed = false; if (verify && exitCode === 0) { verifyResult = runVerify(verify, { - cwd: shell.cwd || cwd, + cwd: executionCwd, env: spawnEnv, sandboxCommand, }); if (!verifyResult.passed) { if (verify.on_failure === 'warn') { - warnings.push(`Verify command failed (exit ${verifyResult.exit_code}): ${verifyResult.stderr || verifyResult.stdout || '(no output)'}`); + warnings.push(`Verify command failed (exit ${verifyResult.exit_code}); raw verify output is omitted from audit records`); } else { verifyFailed = true; } } } + const auditVerifyResult = auditSafeVerifyResult(verifyResult); // ------------------------------------------------------------------ // Phase 6: Complete, versioned evidence // ------------------------------------------------------------------ - const evidenceRequired = requireEvidence || evidenceDeclaration?.verify?.required === true; + const evidenceVerificationRequired = evidenceDeclaration?.verify?.required === true; + const evidenceProductionRequired = requireEvidence || evidenceVerificationRequired; try { if (evidRef && evidenceDeclaration) { const evProvider = resolveEvidenceProvider({ @@ -1778,7 +1796,7 @@ async function executeV2Core(common, { stdin: spawnOpts.input ?? null, }, result, - verify: verifyResult, + verify: auditVerifyResult, complianceContext: complianceCtx, }); const serialized = serializePayload( @@ -1791,6 +1809,33 @@ async function executeV2Core(common, { ...evProvider.describe(attestResult.envelope, {}), envelope: attestResult.envelope, }; + if (evidenceVerificationRequired) { + const providerConfig = evidenceDeclaration.provider_config || {}; + const paths = getAgentcliPaths({ env }); + const allowedSignersPath = providerConfig.allowed_signers || + providerConfig.allowed_signers_path || + resolveEvidenceAllowedSigners({ env, statePath: paths.allowed_signers }); + const verification = await verifyEvidenceEnvelope(attestResult.envelope, { + ...providerConfig, + allowedSignersPath, + principal: providerConfig.principal || attestResult.envelope?.principal || null, + }, { env }); + evidenceMetadata.verification = { + required: true, + verified: verification.verified === true, + reason: verification.reason || null, + principal: verification.principal || null, + key_fingerprint: verification.key_fingerprint || null, + payload_digest: verification.payload_digest || null, + envelope_version: verification.envelope_version || null, + }; + if (!verification.verified) { + throw Object.assign( + new Error(`Evidence verification required but failed: ${verification.reason || 'verification did not succeed'}`), + { code: 'evidence_failed' } + ); + } + } } else { evidenceMetadata = { provider: evProvider.name, @@ -1799,15 +1844,15 @@ async function executeV2Core(common, { envelope: null, }; } - if (evidenceRequired && !attestResult.attested) { + if (evidenceProductionRequired && !attestResult.attested) { throw Object.assign( new Error(`Evidence required but attestation failed: ${attestResult.reason}`), { code: 'evidence_failed' } ); } - } else if (evidenceRequired) { + } else if (evidenceProductionRequired) { throw Object.assign( - new Error('Evidence verification is required but no evidence block resolved'), + new Error('Evidence is required but no evidence block resolved'), { code: 'evidence_failed' } ); } @@ -1834,7 +1879,7 @@ async function executeV2Core(common, { identity: binding.identity, effective_task_hash: taskHash, manifest_digest: binding.manifest_digest, - verify: verifyResult, + verify: auditVerifyResult, evidence: evidenceMetadata, evidence_error: evidenceFailure, warnings, @@ -1882,7 +1927,7 @@ async function executeV2Core(common, { signer: provider.name, attestation, attestation_note, - verify: verifyResult, + verify: auditVerifyResult, warnings, dry_run: false, result: auditResult, @@ -1893,14 +1938,11 @@ async function executeV2Core(common, { } if (verifyFailed) { - const verifyStdout = verifyResult.stdout || ''; - const verifyStderr = verifyResult.stderr || ''; - const detail = verifyStderr || verifyStdout || '(no output)'; throw Object.assign( - new Error(`Verify command failed (exit ${verifyResult.exit_code}): ${detail}`), + new Error(`Verify command failed (exit ${verifyResult.exit_code}); raw verify output is omitted`), { code: 'verify_failed', - verify: verifyResult, + verify: auditVerifyResult, execution_id: executionId, source: { workflow_id: workflow.id, task_id: task.id }, } diff --git a/src/identity/spiffe-jwt-svid.js b/src/identity/spiffe-jwt-svid.js index d347f6d..adcd19e 100644 --- a/src/identity/spiffe-jwt-svid.js +++ b/src/identity/spiffe-jwt-svid.js @@ -265,6 +265,11 @@ const spiffeJwtSvidProvider = { const config = auth.provider_config || {}; const required = auth.required !== false; const audience = auth.audience || config.audience; + const declaredPrincipal = profile?.subject?.principal; + + if (declaredPrincipal != null && !validateSpiffeId(declaredPrincipal)) { + errors.push('subject.principal must be a valid SPIFFE ID when declared'); + } if (config.workload_api_socket != null) { errors.push('auth.provider_config.workload_api_socket is unsupported; mount a JWT-SVID file instead'); @@ -358,7 +363,14 @@ const spiffeJwtSvidProvider = { } const claims = verified.claims; const trustLevel = profile?.trust?.level || 'supervised'; - const principal = profile?.subject?.principal || claims.sub; + const declaredPrincipal = profile?.subject?.principal ?? null; + if (declaredPrincipal != null && declaredPrincipal !== claims.sub) { + throw providerError( + 'identity_resolution_failed', + 'Declared SPIFFE principal does not match the verified JWT-SVID subject' + ); + } + const principal = claims.sub; const session = { provider: 'spiffe-jwt-svid', subject: { diff --git a/src/io.js b/src/io.js index fd169d9..13db317 100644 --- a/src/io.js +++ b/src/io.js @@ -2,6 +2,7 @@ import { closeSync, constants as fsConstants, existsSync, + fchmodSync, lstatSync, mkdirSync, openSync, @@ -154,6 +155,7 @@ export function writeJsonOutput(outputPath, payload, { cwd = process.cwd() } = { (fsConstants.O_NOFOLLOW || 0), 0o600 ); + if (process.platform !== 'win32') fchmodSync(fd, 0o600); writeFileSync(fd, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); } catch (err) { if (err?.code === 'ELOOP') { diff --git a/src/run.js b/src/run.js index 1c8a01d..d8857bc 100644 --- a/src/run.js +++ b/src/run.js @@ -444,7 +444,7 @@ export async function runWorkflow(manifest, { let payload = null; try { - payload = await executeTask(expanded, { + payload = await executeTask(manifest, { workflowId: workflow.id, taskId: task.id, dryRun: false, diff --git a/src/schema.js b/src/schema.js index 4506dde..0faac2a 100644 --- a/src/schema.js +++ b/src/schema.js @@ -986,6 +986,13 @@ const jsonSchemaDefs = { presentationTarget: objectSchema({ kind: nullableSchema({ type: 'string', enum: ['env', 'file', 'stdin', 'none'] }), name: nullableStringSchema, + prefix: nullableSchema({ + type: 'string', + minLength: 1, + maxLength: 80, + pattern: '^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$', + }), + expose_as: nullableSchema({ type: 'string', pattern: ENV_NAME_PATTERN }), }), presentationBinding: objectSchema({ source: { type: 'string', minLength: 1 }, diff --git a/src/validate.js b/src/validate.js index 25d38fd..4b01a49 100644 --- a/src/validate.js +++ b/src/validate.js @@ -30,6 +30,7 @@ const CHILD_CREDENTIAL_POLICIES = ['none', 'inherit', 'downscope', 'independent' const IDENTIFIER_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; const TOKEN_RE = /^[A-Za-z0-9@:_./-]+$/; const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; +const FILE_PREFIX_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/; const KNOWN_MANIFEST_KEYS = new Set([ 'version', 'workflows', @@ -81,7 +82,7 @@ const V2_KEYS = Object.freeze({ trustConstraints: new Set(['escalation', 'max_autonomy', 'escalation_timeout', 'require_justification']), presentation: new Set(['bindings', 'handoff', 'cleanup', 'default_redaction']), presentationBinding: new Set(['source', 'target', 'required', 'redact', 'format']), - presentationTarget: new Set(['kind', 'name']), + presentationTarget: new Set(['kind', 'name', 'prefix', 'expose_as']), identity: new Set(['ref', 'scope', 'subject', 'auth', 'trust', 'presentation']), contract: new Set(['sandbox', 'allowed_paths', 'network', 'max_cost_usd', 'audit', 'required_trust_level', 'trust_enforcement']), authorizationProofRef: new Set(['ref', 'claims', 'verify']), @@ -421,6 +422,18 @@ function validatePresentation(errors, path, value) { if (checkOptionalObject(errors, `${bp}.target`, binding.target)) { checkEnum(errors, `${bp}.target.kind`, binding.target.kind, ['env', 'file', 'stdin', 'none']); checkString(errors, `${bp}.target.name`, binding.target.name, { required: false }); + checkString(errors, `${bp}.target.prefix`, binding.target.prefix, { required: false }); + if (binding.target.prefix != null && + typeof binding.target.prefix === 'string' && + !FILE_PREFIX_RE.test(binding.target.prefix)) { + addError(errors, `${bp}.target.prefix`, 'must be a safe file prefix of at most 80 characters'); + } + checkString(errors, `${bp}.target.expose_as`, binding.target.expose_as, { required: false }); + if (binding.target.expose_as != null && + typeof binding.target.expose_as === 'string' && + !ENV_NAME_RE.test(binding.target.expose_as)) { + addError(errors, `${bp}.target.expose_as`, 'must be a valid environment variable name'); + } } checkBoolean(errors, `${bp}.required`, binding.required); checkBoolean(errors, `${bp}.redact`, binding.redact); diff --git a/test/agentcli.test.js b/test/agentcli.test.js index 54003f4..cdca25a 100644 --- a/test/agentcli.test.js +++ b/test/agentcli.test.js @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { generateKeyPairSync, createSign } from 'node:crypto'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { delimiter, join } from 'node:path'; import { tmpdir } from 'node:os'; import { createServer } from 'node:http'; @@ -27,8 +27,8 @@ import { resolveCommandValue } from '../src/command.js'; import { runCli } from '../src/cli.js'; import { inspectSchedulerState } from '../src/inspect.js'; import { handleJsonRpcRequest } from '../src/jsonrpc.js'; -import { ensureAgentcliHome } from '../src/home.js'; -import { stableId, resolveIdentityV2, resolveVerify } from '../src/compiler/shared.js'; +import { ensureAgentcliHome, getAgentcliPaths } from '../src/home.js'; +import { OPERATIONAL_ENV_KEYS, stableId, resolveIdentityV2, resolveVerify } from '../src/compiler/shared.js'; import { applyFieldMask, parseFieldMask } from '../src/fields.js'; import { resolveSafeOutputPath } from '../src/io.js'; import { buildOnFailureTask } from '../src/shorthand.js'; @@ -3963,7 +3963,8 @@ test('exec dry-run does not spawn a process', () => { assert.equal(result.dry_run, true); assert.equal(result.command.program, 'df'); assert.equal(result.command.args_count, 1); - assert.deepEqual(result.command.env_keys, []); + assert.ok(result.command.env_keys.includes('PATH')); + assert.ok(result.command.env_keys.every(key => OPERATIONAL_ENV_KEYS.has(key) || key.startsWith('LC_'))); assert.match(result.command.args_hashes[0], /^sha256:[a-f0-9]{64}$/); assert.deepEqual(result.result, { status: 'dry_run' }); assert.ok(Object.values(result.phases).every(phase => phase === 'skipped')); @@ -4011,6 +4012,39 @@ test('exec enforces contract.allowed_paths against shell.cwd', () => { ); }); +test('exec resolves symlinks before enforcing contract.allowed_paths', { + skip: process.platform === 'win32', +}, () => { + const root = mkdtempSync(join(tmpdir(), 'agentcli-cwd-symlink-')); + const allowed = join(root, 'allowed'); + const outside = join(root, 'outside'); + const linked = join(allowed, 'linked'); + try { + mkdirSync(allowed); + mkdirSync(outside); + symlinkSync(outside, linked); + const manifest = { + version: '0.1', + workflows: [{ + id: 'w', name: 'W', + tasks: [{ + id: 't', name: 'T', + shell: { program: 'true', args: [], cwd: linked }, + target: { session_target: 'shell' }, + schedule: { cron: '0 * * * *' }, + contract: { allowed_paths: [allowed], audit: 'none' }, + }], + }], + }; + assert.throws( + () => executeTask(manifest, { taskId: 't' }), + error => error.code === 'contract_violation' + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test('exec allows cwd under an allowed path', () => { const workdir = mkdtempSync(join(tmpdir(), 'agentcli-cwd-')); try { @@ -7108,6 +7142,48 @@ test('convertManifestV1toV2 preserves workflow identity', async () => { assert.strictEqual(profile.subject.principal, 'deploy-bot@infra.example.com'); }); +test('convertManifestV1toV2 preserves inherited legacy identity fields under partial overrides', async () => { + const { convertManifestV1toV2 } = await import('../src/convert.js'); + const v1 = { + version: '0.1', + workflows: [{ + id: 'partial-identity', + name: 'Partial Identity', + identity: { + principal: 'workflow@example.test', + run_as: 'workflow-user', + attestation: 'ssh', + }, + tasks: [{ + id: 'task', + name: 'Task', + shell: { program: 'true', args: [] }, + target: { session_target: 'shell' }, + schedule: { cron: '0 * * * *' }, + identity: { run_as: 'task-user' }, + on_failure: { + shell: { program: 'true', args: [] }, + identity: { principal: 'handler@example.test' }, + }, + }], + }], + }; + const converted = convertManifestV1toV2(v1); + const workflow = converted.workflows[0]; + const task = workflow.tasks[0]; + const taskProfile = converted.identity_profiles.find(profile => profile.id === task.identity.ref); + const failureProfile = converted.identity_profiles.find(profile => profile.id === task.on_failure.identity.ref); + + assert.equal(taskProfile.subject.principal, 'workflow@example.test'); + assert.equal(taskProfile.subject.run_as, 'task-user'); + assert.equal(failureProfile.subject.principal, 'handler@example.test'); + assert.equal(failureProfile.subject.run_as, 'workflow-user'); + assert.ok(workflow.authorization_proof?.ref); + assert.equal(task.authorization_proof, undefined); + assert.equal(task.on_failure.authorization_proof, undefined); + assert.equal(validateManifest(converted).ok, true); +}); + test('convertManifestV1toV2 preserves legacy attestation as a non-verifying declaration', async () => { const { convertManifestV1toV2 } = await import('../src/convert.js'); const v1 = JSON.parse(readFileSync(new URL('../examples/identity-contract.json', import.meta.url), 'utf8')); @@ -8558,6 +8634,83 @@ test('applyManifestToScheduler without capabilities rejects v0.2 fields', async assert.strictEqual(calls.length, 0); }); +test('v0.1 governance and verify fields require handoff v2 and project without loss', async () => { + const manifest = { + version: '0.1', + workflows: [{ + id: 'legacy-governed', + name: 'Legacy Governed', + identity: { + principal: 'deploy@example.test', + run_as: 'deployer', + attestation: 'ssh', + }, + contract: { + sandbox: 'permissive', + network: 'unrestricted', + audit: 'always', + }, + tasks: [{ + id: 'task', + name: 'Task', + shell: { program: 'true', args: [] }, + target: { session_target: 'shell' }, + schedule: { cron: '0 * * * *' }, + verify: { shell: 'test -n "$PATH"', timeout_seconds: 7, on_failure: 'warn' }, + }], + }], + }; + const capabilityResponse = handoffVersion => ({ + scheduler_version: 'test', + handoff_version: handoffVersion, + features: { + runtime_execution: true, + identity_declaration: true, + runtime_identity_resolution: true, + trust_evaluation: true, + authorization_proof_verification: true, + authorization_hook: true, + evidence_generation: true, + credential_handoff: true, + audit_export: true, + }, + }); + const makeRunner = (handoffVersion, calls) => ({ + invocation: { label: `fake-scheduler-v${handoffVersion}` }, + queryCapabilities: () => capabilityResponse(handoffVersion), + listJobs: () => [], + addJob(spec) { + calls.push(spec); + return { ok: true, job: spec }; + }, + updateJob() { throw new Error('should not update'); }, + }); + + const v1Calls = []; + await assert.rejects( + applyManifestToScheduler(manifest, { runner: makeRunner('1', v1Calls) }), + error => ( + error.code === 'unsupported_capability' && + error.required_handoff_version === '2' && + error.advertised_handoff_version === '1' + ) + ); + assert.equal(v1Calls.length, 0); + + const v2Calls = []; + const applied = await applyManifestToScheduler(manifest, { + runner: makeRunner('2', v2Calls), + }); + assert.equal(applied.ok, true); + assert.equal(v2Calls.length, 1); + assert.equal(v2Calls[0].verify_shell, 'test -n "$PATH"'); + assert.equal(v2Calls[0].verify_timeout_s, 7); + assert.equal(v2Calls[0].verify_on_failure, 'warn'); + assert.equal(v2Calls[0].contract_audit, 'always'); + assert.equal(v2Calls[0].identity_principal, 'deploy@example.test'); + assert.equal(v2Calls[0].identity_run_as, 'deployer'); +}); + test('applyManifestToScheduler with handoff_version 2 passes v0.2 fields to updateJob', async () => { const manifest = { version: '0.2', @@ -12066,6 +12219,80 @@ test('exec verify: on_failure=warn adds warning but returns ok', () => { assert.ok(result.warnings.some(w => w.includes('Verify command failed'))); }); +test('exec verify receives only the explicit child environment', () => { + const manifest = { + version: '0.1', + workflows: [{ + id: 'w', name: 'W', + tasks: [{ + id: 't', name: 'T', + shell: { program: 'true', args: [] }, + target: { session_target: 'shell' }, + schedule: { cron: '0 * * * *' }, + contract: { audit: 'none' }, + verify: { shell: 'test -z "$AMBIENT_VERIFY_SECRET"' }, + }], + }], + }; + const result = executeTask(manifest, { + taskId: 't', + env: { ...process.env, AMBIENT_VERIFY_SECRET: 'must-not-be-inherited' }, + }); + assert.equal(result.verify.passed, true); +}); + +test('v0.1 verify output is hashed rather than copied into audits or errors', () => { + const home = mkdtempSync(join(tmpdir(), 'agentcli-verify-audit-v1-')); + const canary = 'verify-output-secret-v1'; + const env = { ...process.env, AGENTCLI_HOME: home }; + try { + for (const onFailure of ['warn', 'error']) { + const manifest = { + version: '0.1', + workflows: [{ + id: `w-${onFailure}`, name: 'W', + tasks: [{ + id: 't', name: 'T', + shell: { + program: 'true', + args: [], + env: { DECLARED_VERIFY_SECRET: canary }, + }, + target: { session_target: 'shell' }, + schedule: { cron: '0 * * * *' }, + contract: { audit: 'always' }, + verify: { + shell: 'printf "%s" "$DECLARED_VERIFY_SECRET"; printf "%s" "$DECLARED_VERIFY_SECRET" >&2; exit 1', + on_failure: onFailure, + }, + }], + }], + }; + if (onFailure === 'warn') { + const result = executeTask(manifest, { taskId: 't', env }); + assert.equal(result.ok, true); + } else { + assert.throws( + () => executeTask(manifest, { taskId: 't', env }), + error => error.code === 'verify_failed' && !JSON.stringify(error).includes(canary) + ); + } + } + + const records = readAuditLog({ auditPath: getAgentcliPaths({ env }).audit }); + assert.equal(records.length, 2); + assert.equal(JSON.stringify(records).includes(canary), false); + for (const record of records) { + assert.match(record.verify.stdout_hash, /^sha256:[a-f0-9]{64}$/); + assert.match(record.verify.stderr_hash, /^sha256:[a-f0-9]{64}$/); + assert.equal(record.verify.stdout, undefined); + assert.equal(record.verify.stderr, undefined); + } + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + test('exec verify: not run when shell command fails', () => { const manifest = { version: '0.1', @@ -12302,6 +12529,53 @@ test('exec verify: v0.2 path verify failure with on_failure=error throws', async ); }); +test('v0.2 evidence failures do not copy verify output into audits or errors', async () => { + const home = mkdtempSync(join(tmpdir(), 'agentcli-verify-audit-v2-')); + const canary = 'verify-output-secret-v2'; + const env = { ...process.env, AGENTCLI_HOME: home }; + const manifest = { + version: '0.2', + evidence_profiles: [{ + id: 'required-evidence', + provider: 'none', + verify: { required: true }, + }], + workflows: [{ + id: 'w', name: 'W', + tasks: [{ + id: 't', name: 'T', + shell: { + program: 'true', + args: [], + env: { DECLARED_VERIFY_SECRET: canary }, + }, + target: { session_target: 'shell' }, + schedule: { cron: '0 * * * *' }, + contract: { audit: 'always' }, + verify: { + shell: 'printf "%s" "$DECLARED_VERIFY_SECRET"; printf "%s" "$DECLARED_VERIFY_SECRET" >&2; exit 1', + on_failure: 'warn', + }, + evidence: { ref: 'required-evidence' }, + }], + }], + }; + + try { + await assert.rejects( + executeTask(manifest, { taskId: 't', env, signer: 'none' }), + error => error.code === 'evidence_failed' && !JSON.stringify(error).includes(canary) + ); + const records = readAuditLog({ auditPath: getAgentcliPaths({ env }).audit }); + assert.equal(records.length, 1); + assert.equal(JSON.stringify(records).includes(canary), false); + assert.match(records[0].verify.stdout_hash, /^sha256:[a-f0-9]{64}$/); + assert.equal(records[0].verify.stdout, undefined); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + // --------------------------------------------------------------------------- // buildActorContext // --------------------------------------------------------------------------- diff --git a/test/approvals.test.js b/test/approvals.test.js index 3e2ab6c..018a862 100644 --- a/test/approvals.test.js +++ b/test/approvals.test.js @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, readFileSync, writeFileSync, appendFileSync, existsSync, rmSync, mkdirSync, statSync, symlinkSync, utimesSync } from 'node:fs'; +import { mkdtempSync, readFileSync, realpathSync, writeFileSync, appendFileSync, existsSync, rmSync, mkdirSync, statSync, symlinkSync, utimesSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { Worker } from 'node:worker_threads'; @@ -21,6 +21,10 @@ import { import { executeTask } from '../src/exec.js'; import { readAuditLog } from '../src/audit.js'; import { getAgentcliPaths } from '../src/home.js'; +import { + buildEffectiveExecutionBinding, + canonicalExecutionBindingString, +} from '../src/compiler/shared.js'; function makeManifest({ approval, program = 'printf', args = ['ok'] } = {}) { return { @@ -113,6 +117,153 @@ test('task hash is stable and binds shell+identity+risk', () => { assert.notEqual(h1, h4); }); +test('task hash binds effective cwd, operational environment, timeout, and instance without raw values', () => { + const firstCwd = mkdtempSync(join(tmpdir(), 'agentcli-approval-cwd-a-')); + const secondCwd = mkdtempSync(join(tmpdir(), 'agentcli-approval-cwd-b-')); + try { + const manifest = makeManifest({ approval: { policy: 'manual', risk_level: 'high' } }); + manifest.workflows[0].tasks[0].shell.cwd = '.'; + const workflow = manifest.workflows[0]; + const task = workflow.tasks[0]; + const envA = { PATH: '/approval/path-a', HOME: '/approval/home-a' }; + const envB = { PATH: '/approval/path-b', HOME: '/approval/home-a' }; + const base = { + manifest, + expanded: manifest, + workflow, + task, + env: envA, + cwd: firstCwd, + timeoutMs: 1000, + instanceId: 'instance-a', + }; + const binding = buildEffectiveExecutionBinding(base); + const serialized = canonicalExecutionBindingString(binding); + + assert.notEqual(computeTaskApprovalHash(base), computeTaskApprovalHash({ ...base, cwd: secondCwd })); + assert.notEqual(computeTaskApprovalHash(base), computeTaskApprovalHash({ ...base, env: envB })); + assert.notEqual(computeTaskApprovalHash(base), computeTaskApprovalHash({ ...base, timeoutMs: 2000 })); + assert.notEqual(computeTaskApprovalHash(base), computeTaskApprovalHash({ ...base, instanceId: 'instance-b' })); + assert.equal(serialized.includes('/approval/path-a'), false); + assert.equal(serialized.includes('/approval/home-a'), false); + assert.match(binding.command.env_hashes.PATH, /^sha256:[a-f0-9]{64}$/); + } finally { + rmSync(firstCwd, { recursive: true, force: true }); + rmSync(secondCwd, { recursive: true, force: true }); + } +}); + +test('exec refuses approvals minted for a different cwd, PATH, or timeout', async () => { + const { env, cleanup } = isolatedEnv(); + const firstCwd = mkdtempSync(join(tmpdir(), 'agentcli-approval-exec-a-')); + const secondCwd = mkdtempSync(join(tmpdir(), 'agentcli-approval-exec-b-')); + try { + const manifest = makeManifest({ approval: { policy: 'manual', risk_level: 'high' } }); + manifest.workflows[0].tasks[0].shell.cwd = '.'; + + grantApproval({ + manifest, + taskId: 'echo-task', + approver: 'alice', + cwd: firstCwd, + env, + }); + await assert.rejects( + executeTask(manifest, { taskId: 'echo-task', cwd: secondCwd, env }), + error => error.code === 'approval_required' + ); + + const pathA = { ...env, PATH: '/approval/path-a' }; + const pathB = { ...env, PATH: '/approval/path-b' }; + grantApproval({ + manifest, + taskId: 'echo-task', + approver: 'alice', + cwd: firstCwd, + env: pathA, + }); + await assert.rejects( + executeTask(manifest, { taskId: 'echo-task', cwd: firstCwd, env: pathB }), + error => error.code === 'approval_required' + ); + + grantApproval({ + manifest, + taskId: 'echo-task', + approver: 'alice', + cwd: firstCwd, + timeoutMs: 1000, + env, + }); + await assert.rejects( + executeTask(manifest, { taskId: 'echo-task', cwd: firstCwd, timeoutMs: 2000, env }), + error => error.code === 'approval_required' + ); + } finally { + rmSync(firstCwd, { recursive: true, force: true }); + rmSync(secondCwd, { recursive: true, force: true }); + cleanup(); + } +}); + +test('exec refuses an approval after a symlinked cwd is retargeted', { + skip: process.platform === 'win32', +}, async () => { + const { env, cleanup } = isolatedEnv(); + const root = mkdtempSync(join(tmpdir(), 'agentcli-approval-cwd-link-')); + const first = join(root, 'first'); + const second = join(root, 'second'); + const linked = join(root, 'current'); + try { + mkdirSync(first); + mkdirSync(second); + symlinkSync(first, linked); + const manifest = makeManifest({ approval: { policy: 'manual', risk_level: 'high' } }); + manifest.workflows[0].tasks[0].shell.cwd = '.'; + grantApproval({ + manifest, + taskId: 'echo-task', + approver: 'alice', + cwd: linked, + env, + }); + rmSync(linked); + symlinkSync(second, linked); + await assert.rejects( + executeTask(manifest, { taskId: 'echo-task', cwd: linked, env }), + error => error.code === 'approval_required' + ); + } finally { + rmSync(root, { recursive: true, force: true }); + cleanup(); + } +}); + +test('exec uses the same bound relative cwd after approval', async () => { + const { env, cleanup } = isolatedEnv(); + const cwd = mkdtempSync(join(tmpdir(), 'agentcli-approval-bound-cwd-')); + try { + const manifest = makeManifest({ + approval: { policy: 'manual', risk_level: 'high' }, + program: process.execPath, + args: ['-e', 'process.stdout.write(process.cwd())'], + }); + manifest.workflows[0].tasks[0].shell.cwd = '.'; + grantApproval({ + manifest, + taskId: 'echo-task', + approver: 'alice', + cwd, + env, + }); + const result = await executeTask(manifest, { taskId: 'echo-task', cwd, env }); + assert.equal(result.result.stdout, realpathSync(cwd)); + } finally { + rmSync(cwd, { recursive: true, force: true }); + cleanup(); + } +}); + test('grant writes a pending approval; list + find work', () => { const { env, cleanup } = isolatedEnv(); try { diff --git a/test/cli-rpc-validation.test.js b/test/cli-rpc-validation.test.js index f4e8cf6..537a4a5 100644 --- a/test/cli-rpc-validation.test.js +++ b/test/cli-rpc-validation.test.js @@ -116,6 +116,42 @@ test('v0.2 validation keeps deliberate provider and claims extension maps open', assert.equal(result.ok, true, JSON.stringify(result.errors)); }); +test('file presentation targets validate prefix and expose_as in manifests and JSON Schema', () => { + const manifest = validManifest({ + identity_profiles: [{ + id: 'identity', + provider: 'env-bearer', + auth: { + required: true, + provider_config: { token_env: 'PRESENTATION_TOKEN' }, + }, + presentation: { + bindings: [{ + source: 'credentials.access_token.value', + target: { + kind: 'file', + prefix: 'agentcli-credential', + expose_as: 'AGENTCLI_CREDENTIAL_FILE', + }, + }], + }, + }], + }); + manifest.workflows[0].identity = { ref: 'identity' }; + assert.equal(validateManifest(manifest).ok, true); + + const targetSchema = MANIFEST_JSON_SCHEMA.$defs.presentationTarget; + assert.ok(targetSchema.properties.prefix); + assert.ok(targetSchema.properties.expose_as); + + const invalidPrefix = structuredClone(manifest); + invalidPrefix.identity_profiles[0].presentation.bindings[0].target.prefix = '../escape'; + assert.equal(validateManifest(invalidPrefix).ok, false); + const invalidEnv = structuredClone(manifest); + invalidEnv.identity_profiles[0].presentation.bindings[0].target.expose_as = 'not-valid'; + assert.equal(validateManifest(invalidEnv).ok, false); +}); + test('profile provider existence and synchronous structural validation are enforced', () => { const unknown = validManifest({ identity_profiles: [{ id: 'identity', provider: 'does-not-exist' }], diff --git a/test/foundation.test.js b/test/foundation.test.js index 8c51312..b6da45c 100644 --- a/test/foundation.test.js +++ b/test/foundation.test.js @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { + chmodSync, mkdtempSync, readFileSync, rmSync, @@ -22,7 +23,9 @@ import { ensureAgentcliHome, listRegistry, showRegistryEntry, + validateManifest, writeAuditRecord, + writeJsonOutput, } from '../src/index.js'; function manifestWithSecrets() { @@ -187,6 +190,22 @@ test('audit append refuses symbolic-link destinations', { skip: process.platform } }); +test('JSON output tightens permissions when overwriting an existing file', { + skip: process.platform === 'win32', +}, () => { + const root = mkdtempSync(join(tmpdir(), 'agentcli-output-mode-')); + const outputPath = join(root, 'result.json'); + try { + writeFileSync(outputPath, '{"old":true}\n', { mode: 0o644 }); + chmodSync(outputPath, 0o644); + writeJsonOutput('result.json', { ok: true }, { cwd: root }); + assert.equal(statSync(outputPath).mode & 0o777, 0o600); + assert.deepEqual(JSON.parse(readFileSync(outputPath, 'utf8')), { ok: true }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test('registry refuses symbolic-link entries', { skip: process.platform === 'win32' }, () => { const home = mkdtempSync(join(tmpdir(), 'agentcli-registry-link-')); const env = { ...process.env, AGENTCLI_HOME: home }; @@ -269,3 +288,20 @@ test('v0.1 conversion produces unique valid profile ids for colliding principal assert.equal(new Set(ids).size, 2); assert.equal(converted.version, '0.2'); }); + +test('complete manifest examples in the identity guide remain valid', () => { + const guide = readFileSync(new URL('../docs/guide-identity.md', import.meta.url), 'utf8'); + const manifests = [...guide.matchAll(/```json\s*\n([\s\S]*?)```/g)] + .map(match => match[1]) + .filter(block => /"version"\s*:/.test(block) && /"workflows"\s*:/.test(block)) + .map(block => JSON.parse(block)); + assert.ok(manifests.length >= 10); + for (const manifest of manifests) { + const validation = validateManifest(manifest); + assert.equal( + validation.ok, + true, + validation.errors.map(error => `${error.path}: ${error.message}`).join('; ') + ); + } +}); diff --git a/test/identity-security.test.js b/test/identity-security.test.js index 171f402..b054556 100644 --- a/test/identity-security.test.js +++ b/test/identity-security.test.js @@ -18,6 +18,8 @@ import '../src/validate.js'; import { getProvider, listProviders } from '../src/identity/index.js'; import { validateSecureEndpoint } from '../src/identity/session.js'; import { verifyJwtSvid } from '../src/identity/spiffe-jwt-svid.js'; +import { opaAuthorizationProvider } from '../src/authorization/opa.js'; +import { normalizeError } from '../src/errors.js'; function envBearerProfile(overrides = {}) { return { @@ -77,6 +79,14 @@ function signedJwt(privateKey, payload, header = { alg: 'RS256', typ: 'JWT', kid return `${signingInput}.${signer.sign(privateKey).toString('base64url')}`; } +test('SPIFFE verification failures retain a stable structured error code', () => { + const normalized = normalizeError(Object.assign(new Error('JWT-SVID signature verification failed'), { + code: 'spiffe_signature_invalid', + })); + assert.equal(normalized.code, 'spiffe_signature_invalid'); + assert.equal(normalized.error_type, 'validation_error'); +}); + test('every registered identity provider applies structural profile validation', async () => { for (const name of listProviders()) { const validation = await getProvider(name).validateProfile({ @@ -161,6 +171,34 @@ test('user-configurable HTTP endpoints are limited to loopback hosts', async () assert.equal((await stripe.validateProfile(stripeProfile('http://192.0.2.30:8123'))).valid, false); }); +test('OPA endpoints require secure transport and audit references omit URL secrets', async () => { + assert.equal(opaAuthorizationProvider.validateProfile({ + provider_config: { endpoint: 'http://policy.example.test/v1/data/agentcli/allow' }, + }).valid, false); + assert.equal(opaAuthorizationProvider.validateProfile({ + provider_config: { endpoint: 'https://user:password@policy.example.test/v1/data/agentcli/allow' }, + }).valid, false); + + const server = createServer((_request, response) => { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ result: true })); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + const endpoint = `http://127.0.0.1:${address.port}/v1/data/agentcli/allow?api_key=audit-secret#fragment`; + try { + const decision = await opaAuthorizationProvider.authorize( + { actor: { principal: 'agent://test' } }, + { provider_config: { endpoint }, on_error: 'deny' } + ); + assert.equal(decision.decision, 'permit'); + assert.equal(decision.policy_ref, `http://127.0.0.1:${address.port}/v1/data/agentcli/allow`); + assert.equal(JSON.stringify(decision).includes('audit-secret'), false); + } finally { + await new Promise(resolve => server.close(resolve)); + } +}); + test('required presentation bindings fail and stdin bindings materialize exactly once', () => { const provider = getProvider('env-bearer'); const session = provider.resolveSession( @@ -403,6 +441,25 @@ test('SPIFFE provider accepts only trusted, audience-bound, file-mounted JWT-SVI assert.equal(session.subject.principal, 'spiffe://example.test/workload/api'); assert.equal(provider.describeSession(session).credentials.jwt_svid.value, '[REDACTED]'); + const matchingPrincipal = structuredClone(profile); + matchingPrincipal.subject.principal = 'spiffe://example.test/workload/api'; + assert.equal((await provider.validateProfile(matchingPrincipal)).valid, true); + assert.equal( + provider.resolveSession({ profile: matchingPrincipal }).subject.principal, + 'spiffe://example.test/workload/api' + ); + + const mismatchedPrincipal = structuredClone(profile); + mismatchedPrincipal.subject.principal = 'spiffe://example.test/workload/admin'; + assert.throws( + () => provider.resolveSession({ profile: mismatchedPrincipal }), + error => error.code === 'identity_resolution_failed' + ); + + const invalidPrincipal = structuredClone(profile); + invalidPrincipal.subject.principal = 'agent://not-spiffe'; + assert.equal((await provider.validateProfile(invalidPrincipal)).valid, false); + assert.throws( () => verifyJwtSvid(token, profile.auth.provider_config, 'wrong-audience'), error => error.code === 'spiffe_audience_mismatch' diff --git a/test/integration-scheduler.test.js b/test/integration-scheduler.test.js index 78d38e4..49734af 100644 --- a/test/integration-scheduler.test.js +++ b/test/integration-scheduler.test.js @@ -204,6 +204,7 @@ if (!schedulerRuntime.ok) { it('apply with different manifest replaces jobs', async () => { const helloManifest = readExample('hello-world.json'); const shellManifest = readExample('shell-workflow.json'); + delete shellManifest.workflows[0].tasks[1].approval.risk_level; const db = dbPath('replace'); // Apply hello-world first diff --git a/test/proof-evidence.test.js b/test/proof-evidence.test.js index c943ac7..739d421 100644 --- a/test/proof-evidence.test.js +++ b/test/proof-evidence.test.js @@ -36,13 +36,14 @@ import { validateEvidenceRecordBinding, } from '../src/evidence/payload.js'; import { sshEvidenceProvider } from '../src/evidence/ssh.js'; -import { verifyEvidenceEnvelope } from '../src/evidence/index.js'; +import { registerEvidenceProvider, verifyEvidenceEnvelope } from '../src/evidence/index.js'; import { generateExecutionId, readAuditLog, writeAuditRecord, } from '../src/audit.js'; import { executeTask } from '../src/exec.js'; +import { compileManifestToStandalone } from '../src/compiler/standalone.js'; function base64Url(value) { return Buffer.from(JSON.stringify(value)).toString('base64url'); @@ -226,6 +227,49 @@ test('authorization proof profile validation fails closed', () => { ); }); +test('authorization proof profiles reject private keys in public_key fields', () => { + const { privateKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + for (const verifier of [jwtVerifier, detachedSignatureVerifier]) { + const profile = { + id: 'proof', + method: verifier.name, + public_key: privateKey, + proof: { value_from: { env: 'AUTHORIZATION_PROOF' } }, + verify: { required: true }, + }; + const validation = verifier.validateProfile(profile); + assert.equal(validation.valid, false); + assert.ok(validation.errors.some(error => ( + error.field === 'public_key' && /private|verification key|supported/i.test(error.message) + ))); + + const manifest = { + version: '0.2', + authorization_proof_profiles: [profile], + workflows: [{ + id: 'proof-workflow', + name: 'Proof Workflow', + authorization_proof: { ref: 'proof' }, + tasks: [{ + id: 'proof-task', + name: 'Proof Task', + target: { session_target: 'shell' }, + shell: { program: 'true', args: [] }, + schedule: { cron: '0 * * * *' }, + }], + }], + }; + assert.throws( + () => compileManifestToStandalone(manifest), + error => !JSON.stringify(error).includes(privateKey) && error.validation?.ok === false + ); + } +}); + test('detached signatures verify canonical manifest content and reject changes', () => { const keys = generateKeyPairSync('rsa', { modulusLength: 2048, @@ -457,10 +501,43 @@ test('verified evidence cannot be transplanted onto another audit record', () => assert.ok(rewrittenIdentity.errors.some(error => /resolved_identity/.test(error))); }); +test('SSH evidence profiles reject non-canonical payload serialization', () => { + const validation = sshEvidenceProvider.validateProfile({ payload: { format: 'json' } }); + assert.equal(validation.valid, false); + assert.match(validation.errors[0], /canonical-json/); + + const manifest = { + version: '0.2', + evidence_profiles: [{ + id: 'ssh-evidence', + provider: 'ssh', + payload: { format: 'json' }, + }], + workflows: [{ + id: 'evidence-workflow', + name: 'Evidence Workflow', + tasks: [{ + id: 'task', + name: 'Task', + shell: { program: 'true', args: [] }, + target: { session_target: 'shell' }, + schedule: { cron: '0 * * * *' }, + evidence: { ref: 'ssh-evidence' }, + }], + }], + }; + assert.throws( + () => compileManifestToStandalone(manifest), + error => error.validation?.ok === false && /canonical-json/.test(JSON.stringify(error.validation)) + ); +}); + test('SSH evidence persists a versioned envelope that can be independently verified', async () => { const workdir = mkdtempSync(join(tmpdir(), 'agentcli-evidence-')); const keyPath = join(workdir, 'evidence-key'); const allowedSignersPath = join(workdir, 'allowed_signers'); + const untrustedKeyPath = join(workdir, 'untrusted-key'); + const untrustedSignersPath = join(workdir, 'untrusted_signers'); try { const generated = spawnSync('ssh-keygen', [ '-q', '-t', 'ed25519', '-N', '', '-f', keyPath, @@ -490,6 +567,27 @@ test('SSH evidence persists a versioned envelope that can be independently verif assert.equal(verified.verified, true, verified.reason); assert.equal(verified.payload.execution_id, 'execution-1'); + const missingTrust = await verifyEvidenceEnvelope(attested.envelope, { + allowedSignersPath: join(workdir, 'missing_allowed_signers'), + principal: 'agentcli', + }); + assert.equal(missingTrust.verified, false); + + const generatedUntrusted = spawnSync('ssh-keygen', [ + '-q', '-t', 'ed25519', '-N', '', '-f', untrustedKeyPath, + ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + assert.equal(generatedUntrusted.status, 0, generatedUntrusted.stderr); + writeFileSync( + untrustedSignersPath, + `agentcli ${readFileSync(`${untrustedKeyPath}.pub`, 'utf8').trim()}\n`, + { mode: 0o600 } + ); + const untrusted = await verifyEvidenceEnvelope(attested.envelope, { + allowedSignersPath: untrustedSignersPath, + principal: 'agentcli', + }); + assert.equal(untrusted.verified, false); + const tampered = { ...attested.envelope, signed_payload: attested.envelope.signed_payload.replace('execution-1', 'execution-2'), @@ -536,7 +634,11 @@ test('exec persists complete evidence that binds back to its audit record', asyn evidence_profiles: [{ id: 'signed-evidence', provider: 'ssh', - provider_config: { key_path: keyPath, principal: 'agentcli' }, + provider_config: { + key_path: keyPath, + principal: 'agentcli', + allowed_signers_path: allowedSignersPath, + }, payload: { format: 'canonical-json' }, verify: { required: true }, }], @@ -564,6 +666,7 @@ test('exec persists complete evidence that binds back to its audit record', asyn }); assert.equal(result.ok, true); assert.equal(result.evidence.attested, true); + assert.equal(result.evidence.verification.verified, true); assert.ok(result.evidence.envelope.signature); const auditPath = join(agentcliHome, 'state', 'audit.ndjson'); @@ -579,11 +682,76 @@ test('exec persists complete evidence that binds back to its audit record', asyn principal: 'agentcli', }); assert.equal(verified.verified, true, verified.reason); + + const missingTrustManifest = structuredClone(manifest); + missingTrustManifest.evidence_profiles[0].provider_config.allowed_signers_path = + join(workdir, 'missing_allowed_signers'); + await assert.rejects( + executeTask(missingTrustManifest, { + workflowId: 'evidence-workflow', + taskId: 'evidence-task', + env, + cwd: workdir, + signer: 'none', + }), + error => error.code === 'evidence_failed' && /verification required but failed/i.test(error.message) + ); } finally { rmSync(workdir, { recursive: true, force: true }); } }); +test('required evidence fails closed when a provider attests but cannot verify', async () => { + const providerName = `test-evidence-unverified-${process.pid}-${Date.now()}`; + const method = `${providerName}-signature`; + registerEvidenceProvider({ + name: providerName, + methods: [method], + validateProfile: () => ({ valid: true }), + resolve: () => ({}), + attest: payload => ({ + attested: true, + envelope: { method, signed_payload: payload }, + }), + verify: () => ({ verified: false, reason: 'test verifier rejected the envelope' }), + describe: () => ({ provider: providerName, attested: true }), + }); + const home = mkdtempSync(join(tmpdir(), 'agentcli-unverified-evidence-')); + const manifest = { + version: '0.2', + evidence_profiles: [{ + id: 'required-evidence', + provider: providerName, + verify: { required: true }, + }], + workflows: [{ + id: 'evidence-workflow', + name: 'Evidence Workflow', + tasks: [{ + id: 'task', + name: 'Task', + shell: { program: 'true', args: [] }, + target: { session_target: 'shell' }, + schedule: { cron: '0 * * * *' }, + contract: { audit: 'always' }, + evidence: { ref: 'required-evidence' }, + }], + }], + }; + try { + await assert.rejects( + executeTask(manifest, { + taskId: 'task', + env: { ...process.env, AGENTCLI_HOME: home }, + signer: 'none', + }), + error => error.code === 'evidence_failed' && /test verifier rejected/.test(error.message) + ); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + test('SSH evidence provider refuses to sign incomplete payloads', () => { const workdir = mkdtempSync(join(tmpdir(), 'agentcli-evidence-incomplete-')); const keyPath = join(workdir, 'evidence-key'); diff --git a/test/run-workflow.test.js b/test/run-workflow.test.js index aba208b..6159d48 100644 --- a/test/run-workflow.test.js +++ b/test/run-workflow.test.js @@ -4,12 +4,41 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { runCli, runWorkflow } from '../src/index.js'; +import { canonicalDigest, executeTask, runCli, runWorkflow } from '../src/index.js'; function makeTempHome() { return mkdtempSync(join(tmpdir(), 'agentcli-run-')); } +test('direct exec and workflow run bind the original manifest before shorthand expansion', async () => { + const manifest = { + version: '0.1', + workflows: [{ + id: 'source-digest', + name: 'Source Digest', + tasks: [{ + id: 'root', + name: 'Root', + shell: { program: 'true', args: [] }, + target: { session_target: 'shell' }, + schedule: { cron: '0 * * * *' }, + contract: { audit: 'none' }, + on_failure: { + shell: { program: 'true', args: [] }, + contract: { audit: 'none' }, + }, + }], + }], + }; + const expectedDigest = canonicalDigest(manifest); + const direct = await executeTask(manifest, { taskId: 'root', signer: 'none' }); + const run = await runWorkflow(manifest, { rootTaskId: 'root', signer: 'none' }); + const root = run.tasks.find(task => task.source.task_id === 'root'); + + assert.equal(direct.manifest_digest, expectedDigest); + assert.equal(root.execution.manifest_digest, expectedDigest); +}); + test('runWorkflow executes a shell DAG and evaluates trigger conditions', async () => { const manifest = { version: '0.1', From 5e02aa82abc9ef380ca1ae307824ea9a3674bc65 Mon Sep 17 00:00:00 2001 From: amittell Date: Sat, 11 Jul 2026 14:06:37 -0400 Subject: [PATCH 3/9] Fix Actions runner path initialization --- .github/workflows/ci.yml | 3 ++- .github/workflows/publish.yml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a43a9e..e529ae0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,6 @@ jobs: node-version: ['22.13.0', '24.x'] env: OPENCLAW_SCHEDULER_REF: ac9ea643a8efc68e9f81c8d93125467ca62140b5 - SCHEDULER_PATH: ${{ runner.temp }}/openclaw-scheduler steps: - uses: actions/checkout@v5 - name: Check out pinned openclaw-scheduler @@ -28,6 +27,8 @@ jobs: repository: amittell/openclaw-scheduler ref: ac9ea643a8efc68e9f81c8d93125467ca62140b5 path: openclaw-scheduler + - name: Configure scheduler fixture path + run: echo "SCHEDULER_PATH=$RUNNER_TEMP/openclaw-scheduler" >> "$GITHUB_ENV" - uses: actions/setup-node@v5 with: node-version: ${{ matrix.node-version }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 159c0dd..9377547 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,7 +12,6 @@ jobs: runs-on: ubuntu-latest env: OPENCLAW_SCHEDULER_REF: ac9ea643a8efc68e9f81c8d93125467ca62140b5 - SCHEDULER_PATH: ${{ runner.temp }}/openclaw-scheduler steps: - uses: actions/checkout@v5 - name: Check out pinned openclaw-scheduler @@ -21,6 +20,8 @@ jobs: repository: amittell/openclaw-scheduler ref: ac9ea643a8efc68e9f81c8d93125467ca62140b5 path: openclaw-scheduler + - name: Configure scheduler fixture path + run: echo "SCHEDULER_PATH=$RUNNER_TEMP/openclaw-scheduler" >> "$GITHUB_ENV" - uses: actions/setup-node@v5 with: node-version: '24' From f8075c55211e7e7b8c4708410bc9ce1cd821e6b2 Mon Sep 17 00:00:00 2001 From: amittell Date: Sat, 11 Jul 2026 14:08:15 -0400 Subject: [PATCH 4/9] Align allowed-path test with fail-closed isolation --- test/agentcli.test.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/test/agentcli.test.js b/test/agentcli.test.js index cdca25a..1ae8908 100644 --- a/test/agentcli.test.js +++ b/test/agentcli.test.js @@ -4061,8 +4061,15 @@ test('exec allows cwd under an allowed path', () => { }] }] }; - const result = executeTask(manifest, { taskId: 't' }); - assert.equal(result.ok, true); + if (resolveSandboxSupport()) { + const result = executeTask(manifest, { taskId: 't' }); + assert.equal(result.ok, true); + } else { + assert.throws( + () => executeTask(manifest, { taskId: 't' }), + error => error.code === 'sandbox_enforcement_unavailable' + ); + } } finally { rmSync(workdir, { recursive: true, force: true }); } From b7494bed52e5dbd61121d9f54cace5c3cb5e07a0 Mon Sep 17 00:00:00 2001 From: amittell Date: Sat, 11 Jul 2026 14:10:56 -0400 Subject: [PATCH 5/9] Address manifest merge and registry review --- src/merge.js | 10 ++++++- src/registry.js | 2 +- test/agentcli.test.js | 60 +++++++++++++++++++++++++++++++++++++++++ test/foundation.test.js | 1 + 4 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/merge.js b/src/merge.js index 7f508f2..302b87a 100644 --- a/src/merge.js +++ b/src/merge.js @@ -26,6 +26,14 @@ export function mergeManifests(manifests) { } } + const version = manifests[0].version; + if (manifests.some(manifest => manifest.version !== version)) { + throw Object.assign( + new Error('merge requires every manifest to use the same version; convert v0.1 inputs to v0.2 before merging mixed versions'), + { code: 'invalid_argument' } + ); + } + const seenWorkflowIds = new Map(); const mergedWorkflows = []; const mergedProfiles = Object.fromEntries(PROFILE_COLLECTIONS.map(key => [key, []])); @@ -67,7 +75,7 @@ export function mergeManifests(manifests) { } const merged = { - version: '0.2', + version, ...Object.fromEntries( Object.entries(mergedProfiles).filter(([, profiles]) => profiles.length > 0) ), diff --git a/src/registry.js b/src/registry.js index dae3b42..50376c4 100644 --- a/src/registry.js +++ b/src/registry.js @@ -48,7 +48,7 @@ export function listRegistry({ env } = {}) { const filePath = join(dir, file); try { if (lstatSync(filePath).isSymbolicLink()) { - return { name, workflows: [], parse_error: true, symlink_refused: true }; + return { name, workflows: [], symlink_refused: true }; } const manifest = JSON.parse(readFileSync(filePath, 'utf8')); const workflows = (manifest.workflows || []).map(w => ({ diff --git a/test/agentcli.test.js b/test/agentcli.test.js index 1ae8908..6e94eec 100644 --- a/test/agentcli.test.js +++ b/test/agentcli.test.js @@ -5295,11 +5295,71 @@ test('mergeManifests combines two manifests', () => { }] }; const merged = mergeManifests([a, b]); + assert.equal(merged.version, '0.1'); assert.equal(merged.workflows.length, 2); assert.equal(merged.workflows[0].id, 'a'); assert.equal(merged.workflows[1].id, 'b'); }); +test('mergeManifests rejects mixed manifest versions with conversion guidance', () => { + const v1 = { + version: '0.1', + workflows: [{ + id: 'v1', name: 'V1', + tasks: [{ id: 't1', name: 'T1', prompt: 'go', target: { session_target: 'main' }, schedule: { cron: '0 * * * *' } }] + }] + }; + const v2 = { + version: '0.2', + workflows: [{ + id: 'v2', name: 'V2', + tasks: [{ id: 't2', name: 'T2', prompt: 'go', target: { session_target: 'main' }, schedule: { cron: '0 * * * *' } }] + }] + }; + assert.throws( + () => mergeManifests([v1, v2]), + error => error.code === 'invalid_argument' && /convert v0\.1 inputs to v0\.2/.test(error.message) + ); +}); + +test('mergeManifests preserves v0.2 profile collections and rejects conflicting definitions', () => { + const makeManifest = (workflowId, profileId, principal) => ({ + version: '0.2', + identity_profiles: [{ + id: profileId, + provider: 'none', + subject: { kind: 'service', principal }, + }], + workflows: [{ + id: workflowId, + name: workflowId, + tasks: [{ + id: 'task', + name: 'Task', + prompt: 'go', + target: { session_target: 'main' }, + schedule: { cron: '0 * * * *' }, + identity: { ref: profileId }, + }], + }], + }); + const first = makeManifest('first', 'first-profile', 'agent://test/first'); + const second = makeManifest('second', 'second-profile', 'agent://test/second'); + const merged = mergeManifests([first, second]); + assert.equal(merged.version, '0.2'); + assert.deepEqual(merged.identity_profiles.map(profile => profile.id), [ + 'first-profile', + 'second-profile', + ]); + assert.equal(validateManifest(merged).ok, true); + + const conflicting = makeManifest('conflicting', 'first-profile', 'agent://test/changed'); + assert.throws( + () => mergeManifests([first, conflicting]), + /Conflicting identity_profiles id "first-profile"/ + ); +}); + test('mergeManifests rejects duplicate workflow ids', () => { const a = { version: '0.1', diff --git a/test/foundation.test.js b/test/foundation.test.js index b6da45c..d6f5f3e 100644 --- a/test/foundation.test.js +++ b/test/foundation.test.js @@ -231,6 +231,7 @@ test('registry refuses symbolic-link entries', { skip: process.platform === 'win assert.throws(() => showRegistryEntry('linked', { env }), /symbolic-link/); const linked = listRegistry({ env }).find(entry => entry.name === 'linked'); assert.equal(linked.symlink_refused, true); + assert.equal(linked.parse_error, undefined); assert.equal(readFileSync(target, 'utf8'), '{"outside":true}\n'); } finally { rmSync(home, { recursive: true, force: true }); From 80f12dd05cdeb7da7b2a1fbe46d98a1f50ed0d2b Mon Sep 17 00:00:00 2001 From: amittell Date: Sat, 11 Jul 2026 14:12:34 -0400 Subject: [PATCH 6/9] Prepare v0.4.0 release --- CHANGELOG.md | 4 ++-- docs/protocol.md | 2 +- docs/roadmap.md | 6 ++++-- docs/versioning.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 11 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 590c58a..724d67b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased (2026-07-11) +## 0.4.0 (2026-07-11) - security: manual approvals now bind the canonical manifest and complete effective execution configuration, enforce `approver_scope` and `timeout_s`, reject unexpected unsigned records, and fail without writing a grant when signing fails - security: approval checks now run before proof commands, provider calls, sandbox probes, credential materialization, signing, and all other live side effects @@ -12,7 +12,7 @@ - validation: v0.2 nested objects reject unknown fields, provider-specific structural validation runs during manifest validation, and the default schema output is JSON Schema Draft 2020-12 with `--legacy` opt-in - CLI and JSON-RPC: strict flag parsing rejects unknown, duplicate, missing-value, and misplaced flags; RPC responses use stable result/error envelopes and add read-only targets, paths, audit, approvals, and registry discovery methods - execution: disabled tasks and branches are skipped by `agentcli run`; audit identifiers are collision-resistant and malformed audit lines are skipped with warnings -- conversion and merge: v0.1 conversion maps unverifiable legacy attestations to `method: "none"`; merge preserves all v0.2 profile collections and detects conflicting profile definitions +- conversion and merge: v0.1 conversion maps unverifiable legacy attestations to `method: "none"`; merge preserves same-version semantics and v0.2 profile collections, rejects mixed manifest versions, and detects conflicting profile definitions - scheduler: live capability values override static fallback values, handoff v3 preserves governed approval and output fields, auto-reject jobs compile disabled, and apply refuses inline `shell.env` or `shell.stdin` - examples: repaired invalid runtime timeout placement and fail-closed proof and credential-cache declarations; all published JSON examples are validated in the test suite - maintenance: minimum Node version is now 22.13.0 and CI also tests Node 24 with a pinned `openclaw-scheduler` integration checkout diff --git a/docs/protocol.md b/docs/protocol.md index 4e9c556..f42fb45 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -63,7 +63,7 @@ Purpose: Result: -- `{ "ok": true, "package_version": "0.3.2", "manifest_version": "0.2" }` +- `{ "ok": true, "package_version": "0.4.0", "manifest_version": "0.2" }` ### `agentcli.schema` diff --git a/docs/roadmap.md b/docs/roadmap.md index 1f2255b..b20f713 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,7 @@ # Roadmap +The `v0.1` and `v0.2` headings below are manifest specification milestones, not npm package versions. Future work is intentionally unversioned until its compatibility boundary is defined. + ## v0.1 - Manifest schema @@ -40,7 +42,7 @@ - Draft 2020-12 JSON Schema output, strict nested validation, strict CLI flags, and read-only JSON-RPC discovery methods - Scheduler handoff v3, authoritative live capabilities, governed feature gates, auto-reject disabling, and refusal to persist inline shell credentials -## v0.3 +## Future identity and runtime expansion - Additional Entra Agent ID governance features (Conditional Access policy integration, agent lifecycle hooks) - Mid-execution credential refresh for long-running tasks (runtime-managed session renewal) @@ -51,7 +53,7 @@ - Streaming watch / tail surfaces for runtime state - Scheduler lineage and causality queries -## v0.4 +## Future integration surfaces - MCP server - Event streaming / NDJSON output diff --git a/docs/versioning.md b/docs/versioning.md index f8cad0f..b1c9cb6 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -2,7 +2,7 @@ ## Current Versions -- package version: `0.3.2` +- package version: `0.4.0` - manifest spec version: `0.2` - protocol status: draft, aligned to manifest spec `0.2` diff --git a/package-lock.json b/package-lock.json index a31914a..26ac88c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@amittell/agentcli", - "version": "0.3.2", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@amittell/agentcli", - "version": "0.3.2", + "version": "0.4.0", "license": "MIT", "bin": { "agentcli": "bin/agentcli.js" diff --git a/package.json b/package.json index d4b028c..4ddecf6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@amittell/agentcli", - "version": "0.3.2", + "version": "0.4.0", "description": "Control plane for governed agent and CLI workflows with portable manifests, identity, approvals, and evidence.", "type": "module", "main": "./src/index.js", From 38980ad8f1b8a31bb6757579fc5e0e60c280a718 Mon Sep 17 00:00:00 2001 From: amittell Date: Sat, 11 Jul 2026 14:19:21 -0400 Subject: [PATCH 7/9] Align evidence provider contract documentation --- src/evidence/ssh.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/evidence/ssh.js b/src/evidence/ssh.js index 2d58c14..1bea231 100644 --- a/src/evidence/ssh.js +++ b/src/evidence/ssh.js @@ -176,7 +176,7 @@ const sshEvidenceProvider = { * * @param {object} config - Provider configuration (may contain key_path from value_from resolution). * @param {object} ctx - Execution context (may contain env, homeDir). - * @returns {{ keyPath: string }|null} Resolved credentials, or null if no key found. + * @returns {{ keyPath: string, principal: string }|null} Resolved credentials, or null if no key found. */ resolve(config = {}, ctx = {}) { const env = ctx.env || process.env; From 85b070af3220d7782076155c49647dd5606a8014 Mon Sep 17 00:00:00 2001 From: amittell Date: Sat, 11 Jul 2026 14:27:28 -0400 Subject: [PATCH 8/9] Harden output paths and sandbox diagnostics --- src/io.js | 2 +- src/sandbox.js | 3 +++ test/cli-rpc-validation.test.js | 10 ++++++++-- test/sandbox.test.js | 16 ++++++++++++++++ 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/io.js b/src/io.js index 13db317..aa2d48a 100644 --- a/src/io.js +++ b/src/io.js @@ -142,7 +142,7 @@ export function resolveSafeOutputPath(outputPath, cwd = process.cwd()) { export function writeJsonOutput(outputPath, payload, { cwd = process.cwd() } = {}) { let resolvedPath = resolveSafeOutputPath(outputPath, cwd); - mkdirSync(dirname(resolvedPath), { recursive: true }); + mkdirSync(dirname(resolvedPath), { recursive: true, mode: 0o700 }); resolvedPath = resolveSafeOutputPath(outputPath, cwd); let fd; diff --git a/src/sandbox.js b/src/sandbox.js index aadb343..4e02d16 100644 --- a/src/sandbox.js +++ b/src/sandbox.js @@ -181,6 +181,9 @@ export function prepareSandboxedShellCommand(shell, contract = {}, { if (contract.sandbox === 'strict') constraints.push('strict filesystem/process isolation'); if (contract.network === 'none') constraints.push('network denial'); if (contract.network === 'restricted') constraints.push('network restriction'); + if (Array.isArray(contract.allowed_paths) && contract.allowed_paths.length > 0) { + constraints.push('allowed_paths filesystem boundary'); + } throw Object.assign( new Error(`Required sandbox enforcement is unavailable: ${constraints.join(', ')}`), { code: 'sandbox_enforcement_unavailable', constraints } diff --git a/test/cli-rpc-validation.test.js b/test/cli-rpc-validation.test.js index 537a4a5..0bb1361 100644 --- a/test/cli-rpc-validation.test.js +++ b/test/cli-rpc-validation.test.js @@ -6,6 +6,7 @@ import { mkdtempSync, readFileSync, rmSync, + statSync, symlinkSync, writeFileSync, } from 'node:fs'; @@ -524,7 +525,12 @@ test('safe JSON output creates mode-restricted files inside cwd', (t) => { const base = mkdtempSync(join(tmpdir(), 'agentcli-output-')); t.after(() => rmSync(base, { recursive: true, force: true })); - const written = writeJsonOutput('nested/result.json', { ok: true }, { cwd: base }); - assert.equal(written, join(base, 'nested', 'result.json')); + const written = writeJsonOutput('nested/private/result.json', { ok: true }, { cwd: base }); + assert.equal(written, join(base, 'nested', 'private', 'result.json')); assert.deepEqual(JSON.parse(readFileSync(written, 'utf8')), { ok: true }); + if (process.platform !== 'win32') { + assert.equal(statSync(join(base, 'nested')).mode & 0o777, 0o700); + assert.equal(statSync(join(base, 'nested', 'private')).mode & 0o777, 0o700); + assert.equal(statSync(written).mode & 0o777, 0o600); + } }); diff --git a/test/sandbox.test.js b/test/sandbox.test.js index 0e5dff1..b62b62e 100644 --- a/test/sandbox.test.js +++ b/test/sandbox.test.js @@ -52,6 +52,22 @@ test('strict and network-restricted contracts fail closed without enforcement', } }); +test('allowed_paths enforcement failures identify the required filesystem boundary', () => { + assert.throws( + () => prepareSandboxedShellCommand(shell, { + sandbox: 'permissive', + network: 'unrestricted', + allowed_paths: [tmpdir()], + }, { platform: 'linux', env: {} }), + error => { + assert.equal(error.code, 'sandbox_enforcement_unavailable'); + assert.deepEqual(error.constraints, ['allowed_paths filesystem boundary']); + assert.match(error.message, /allowed_paths filesystem boundary/); + return true; + } + ); +}); + test('allowed_paths creates a filesystem boundary even without sandbox strict', () => { const profile = buildMacOSSandboxProfile({ contract: { From ee4eff03f6d6b7ee405262fcc013bcae67d7ab31 Mon Sep 17 00:00:00 2001 From: amittell Date: Sun, 12 Jul 2026 14:14:42 -0400 Subject: [PATCH 9/9] Close filesystem race and special-file gaps --- src/approvals.js | 22 ++-- src/audit.js | 21 ++-- src/evidence/ssh.js | 13 ++- src/home.js | 165 +++++++++++++++++++++++---- src/init.js | 18 +-- src/io.js | 24 +++- src/registry.js | 59 ++++++---- src/signing/ssh.js | 13 ++- test/agentcli.test.js | 67 ++++++++++- test/approvals.test.js | 30 ++++- test/cli-rpc-validation.test.js | 48 ++++++++ test/foundation.test.js | 196 ++++++++++++++++++++++++++++++++ test/proof-evidence.test.js | 73 +++++++++++- 13 files changed, 664 insertions(+), 85 deletions(-) diff --git a/src/approvals.js b/src/approvals.js index 5842021..3039670 100644 --- a/src/approvals.js +++ b/src/approvals.js @@ -1,12 +1,16 @@ import { - chmodSync, closeSync, constants as fsConstants, existsSync, lstatSync, - mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeSync, + closeSync, constants as fsConstants, existsSync, fchmodSync, lstatSync, + openSync, readFileSync, statSync, unlinkSync, writeSync, } from 'node:fs'; import { dirname } from 'node:path'; import { randomBytes } from 'node:crypto'; import { getProvider, resolveProvider } from './signing/index.js'; import { resolveAllowedSigners, generateAllowedSigners } from './signing/ssh.js'; -import { getAgentcliPaths } from './home.js'; +import { + assertRegularFileDescriptor, + ensurePrivateDirectory, + getAgentcliPaths, +} from './home.js'; import { canonicalStringify } from './canonical.js'; import { buildEffectiveExecutionBinding, @@ -45,8 +49,7 @@ function withApprovalsLock(approvalsPath, fn, { now = () => Date.now(), } = {}) { const stateDirectory = dirname(approvalsPath); - mkdirSync(stateDirectory, { recursive: true, mode: 0o700 }); - if (process.platform !== 'win32') chmodSync(stateDirectory, 0o700); + ensurePrivateDirectory(stateDirectory); const lockPath = `${approvalsPath}${LOCK_SUFFIX}`; const deadline = now() + timeoutMs; let fd; @@ -168,9 +171,10 @@ export function approverMatchesScope(approver, scope) { function readApprovalsLog(approvalsPath) { if (!approvalsPath || !existsSync(approvalsPath)) return []; - if (lstatSync(approvalsPath).isSymbolicLink()) { + const approvalsState = lstatSync(approvalsPath); + if (approvalsState.isSymbolicLink() || !approvalsState.isFile()) { throw Object.assign( - new Error('Refusing to read approvals from a symbolic link'), + new Error('Refusing to read approvals from a non-regular file'), { code: 'approval_log_invalid' } ); } @@ -198,14 +202,16 @@ function appendApprovalEventUnlocked(event, approvalsPath) { fsConstants.O_WRONLY | fsConstants.O_APPEND | fsConstants.O_CREAT | + (fsConstants.O_NONBLOCK || 0) | (fsConstants.O_NOFOLLOW || 0), 0o600 ); + assertRegularFileDescriptor(descriptor, approvalsPath, { code: 'approval_log_invalid' }); + if (process.platform !== 'win32') fchmodSync(descriptor, 0o600); writeSync(descriptor, JSON.stringify(event) + '\n', null, 'utf8'); } finally { if (descriptor !== undefined) closeSync(descriptor); } - if (process.platform !== 'win32') chmodSync(approvalsPath, 0o600); } function writeApprovalEvent(event, { approvalsPath }) { diff --git a/src/audit.js b/src/audit.js index 550f869..ff5fdc0 100644 --- a/src/audit.js +++ b/src/audit.js @@ -1,10 +1,9 @@ import { - chmodSync, closeSync, constants as fsConstants, existsSync, - fstatSync, - mkdirSync, + fchmodSync, + lstatSync, openSync, readFileSync, readSync, @@ -13,6 +12,7 @@ import { import { randomUUID } from 'node:crypto'; import { dirname } from 'node:path'; import process from 'node:process'; +import { assertRegularFileDescriptor, ensurePrivateDirectory } from './home.js'; export function generateExecutionId(_workflowId, _taskId, _timestamp) { return randomUUID().replaceAll('-', ''); @@ -20,8 +20,7 @@ export function generateExecutionId(_workflowId, _taskId, _timestamp) { export function writeAuditRecord(record, { auditPath }) { const auditDirectory = dirname(auditPath); - mkdirSync(auditDirectory, { recursive: true, mode: 0o700 }); - if (process.platform !== 'win32') chmodSync(auditDirectory, 0o700); + ensurePrivateDirectory(auditDirectory); let descriptor; try { descriptor = openSync( @@ -29,11 +28,14 @@ export function writeAuditRecord(record, { auditPath }) { fsConstants.O_RDWR | fsConstants.O_APPEND | fsConstants.O_CREAT | + (fsConstants.O_NONBLOCK || 0) | (fsConstants.O_NOFOLLOW || 0), 0o600 ); + const auditState = assertRegularFileDescriptor(descriptor, auditPath); + if (process.platform !== 'win32') fchmodSync(descriptor, 0o600); let separator = ''; - const existingSize = fstatSync(descriptor).size; + const existingSize = auditState.size; if (existingSize > 0) { const finalByte = Buffer.allocUnsafe(1); readSync(descriptor, finalByte, 0, 1, existingSize - 1); @@ -43,11 +45,16 @@ export function writeAuditRecord(record, { auditPath }) { } finally { if (descriptor !== undefined) closeSync(descriptor); } - if (process.platform !== 'win32') chmodSync(auditPath, 0o600); } export function readAuditLog({ auditPath, limit, onMalformed } = {}) { if (!auditPath || !existsSync(auditPath)) return []; + const auditState = lstatSync(auditPath); + if (auditState.isSymbolicLink() || !auditState.isFile()) { + throw Object.assign(new Error(`Refusing to read a non-regular audit file: ${auditPath}`), { + code: 'invalid_argument', + }); + } const content = readFileSync(auditPath, 'utf8'); if (!content.trim()) return []; diff --git a/src/evidence/ssh.js b/src/evidence/ssh.js index 1bea231..a9cfdbc 100644 --- a/src/evidence/ssh.js +++ b/src/evidence/ssh.js @@ -9,12 +9,11 @@ import { spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { - chmodSync, closeSync, constants as fsConstants, existsSync, + fchmodSync, lstatSync, - mkdirSync, openSync, readFileSync, unlinkSync, @@ -23,6 +22,7 @@ import { import { homedir, tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { canonicalStringify, hashString } from '../canonical.js'; +import { assertRegularFileDescriptor, ensurePrivateDirectory } from '../home.js'; import { registerEvidenceProvider } from './index.js'; import { validateCompleteEvidencePayload } from './payload.js'; @@ -134,23 +134,24 @@ export function generateAllowedSigners({ principal, homeDir = homedir(), outputP if (lines.length === 0) return null; const outputDirectory = dirname(outputPath); - mkdirSync(outputDirectory, { recursive: true, mode: 0o700 }); - if (process.platform !== 'win32') chmodSync(outputDirectory, 0o700); + ensurePrivateDirectory(outputDirectory); let descriptor; try { descriptor = openSync( outputPath, - fsConstants.O_WRONLY | + fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_TRUNC | + (fsConstants.O_NONBLOCK || 0) | (fsConstants.O_NOFOLLOW || 0), 0o600 ); + assertRegularFileDescriptor(descriptor, outputPath); + if (process.platform !== 'win32') fchmodSync(descriptor, 0o600); writeFileSync(descriptor, lines.join('\n') + '\n', 'utf8'); } finally { if (descriptor !== undefined) closeSync(descriptor); } - if (process.platform !== 'win32') chmodSync(outputPath, 0o600); return outputPath; } diff --git a/src/home.js b/src/home.js index 0fa72ca..2093bcf 100644 --- a/src/home.js +++ b/src/home.js @@ -1,7 +1,141 @@ -import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { + closeSync, + constants as fsConstants, + existsSync, + fchmodSync, + fstatSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + writeFileSync, +} from 'node:fs'; import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; +function invalidManagedPath(message) { + return Object.assign(new Error(message), { code: 'invalid_argument' }); +} + +function lstatIfPresent(path) { + try { + return lstatSync(path); + } catch (error) { + if (error?.code === 'ENOENT') return null; + throw error; + } +} + +function assertManagedPath(path, expectedType) { + const state = lstatIfPresent(path); + if (!state) return null; + if (state.isSymbolicLink()) { + throw invalidManagedPath(`Refusing to use symbolic-link ${expectedType}: ${path}`); + } + const valid = expectedType === 'directory' ? state.isDirectory() : state.isFile(); + if (!valid) { + throw invalidManagedPath(`Expected ${expectedType} path but found another file type: ${path}`); + } + return state; +} + +export function ensurePrivateDirectory(directoryPath, { mode = 0o700 } = {}) { + const resolvedDirectory = resolve(directoryPath); + assertManagedPath(resolvedDirectory, 'directory'); + mkdirSync(resolvedDirectory, { recursive: true, mode }); + assertManagedPath(resolvedDirectory, 'directory'); + + if (process.platform !== 'win32') { + let descriptor; + try { + descriptor = openSync( + resolvedDirectory, + fsConstants.O_RDONLY | + (fsConstants.O_DIRECTORY || 0) | + (fsConstants.O_NOFOLLOW || 0) + ); + if (!fstatSync(descriptor).isDirectory()) { + throw invalidManagedPath(`Expected directory path but found another file type: ${resolvedDirectory}`); + } + fchmodSync(descriptor, mode); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } + } + + return resolvedDirectory; +} + +export function assertRegularFileDescriptor( + descriptor, + filePath, + { code = 'invalid_argument' } = {} +) { + const state = fstatSync(descriptor); + if (!state.isFile()) { + throw Object.assign( + new Error(`Refusing to use a non-regular file: ${filePath}`), + { code } + ); + } + return state; +} + +function tightenPrivateFile(filePath, mode) { + assertManagedPath(filePath, 'regular file'); + let descriptor; + try { + descriptor = openSync( + filePath, + fsConstants.O_RDONLY | + (fsConstants.O_NONBLOCK || 0) | + (fsConstants.O_NOFOLLOW || 0) + ); + assertRegularFileDescriptor(descriptor, filePath); + if (process.platform !== 'win32') fchmodSync(descriptor, mode); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } +} + +function writePrivateFile(filePath, contents, { force, mode = 0o600 }) { + const existing = assertManagedPath(filePath, 'regular file'); + if (existing && !force) { + tightenPrivateFile(filePath, mode); + return false; + } + + let descriptor; + try { + const creationMode = force ? fsConstants.O_TRUNC : fsConstants.O_EXCL; + descriptor = openSync( + filePath, + fsConstants.O_WRONLY | + fsConstants.O_CREAT | + creationMode | + (fsConstants.O_NONBLOCK || 0) | + (fsConstants.O_NOFOLLOW || 0), + mode + ); + assertRegularFileDescriptor(descriptor, filePath); + if (process.platform !== 'win32') fchmodSync(descriptor, mode); + writeFileSync(descriptor, contents, 'utf8'); + return true; + } catch (error) { + if (!force && error?.code === 'EEXIST') { + if (descriptor !== undefined) { + closeSync(descriptor); + descriptor = undefined; + } + tightenPrivateFile(filePath, mode); + return false; + } + throw error; + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } +} + function expandLeadingTilde(input, homeDir) { if (typeof input !== 'string') return input; if (input === '~') return homeDir; @@ -36,15 +170,16 @@ function readBundledSample() { export function ensureAgentcliHome({ env = process.env, homeDir = homedir(), force = false } = {}) { const paths = getAgentcliPaths({ env, homeDir }); - for (const dir of [paths.root, paths.manifests, paths.output, paths.state, paths.registry]) { - mkdirSync(dir, { recursive: true, mode: 0o700 }); - if (process.platform !== 'win32') chmodSync(dir, 0o700); - } + const managedDirectories = [paths.root, paths.manifests, paths.output, paths.state, paths.registry]; + const managedFiles = [paths.readme, paths.sampleManifest]; + + for (const directory of managedDirectories) assertManagedPath(directory, 'directory'); + for (const file of managedFiles) assertManagedPath(file, 'regular file'); + for (const directory of managedDirectories) ensurePrivateDirectory(directory); const created = []; - if (force || !existsSync(paths.readme)) { - const readme = `# agentcli home + const readme = `# agentcli home This directory holds local manifests and output for agentcli. @@ -59,26 +194,14 @@ Typical flow: 3. Run: agentcli compile --target openclaw-scheduler --explain 4. Run: agentcli apply --db ~/.openclaw/scheduler/scheduler.db --scheduler-prefix ~/.openclaw/scheduler --dry-run `; - writeFileSync(paths.readme, readme, { encoding: 'utf8', mode: 0o600 }); - if (process.platform !== 'win32') chmodSync(paths.readme, 0o600); + if (writePrivateFile(paths.readme, readme, { force })) { created.push(paths.readme); } - if (force || !existsSync(paths.sampleManifest)) { - writeFileSync(paths.sampleManifest, `${readBundledSample().trim()}\n`, { - encoding: 'utf8', - mode: 0o600, - }); - if (process.platform !== 'win32') chmodSync(paths.sampleManifest, 0o600); + if (writePrivateFile(paths.sampleManifest, `${readBundledSample().trim()}\n`, { force })) { created.push(paths.sampleManifest); } - if (process.platform !== 'win32') { - for (const file of [paths.readme, paths.sampleManifest]) { - if (existsSync(file)) chmodSync(file, 0o600); - } - } - return { ok: true, paths, diff --git a/src/init.js b/src/init.js index 84e1733..b3a89c8 100644 --- a/src/init.js +++ b/src/init.js @@ -1,4 +1,3 @@ -import { existsSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { validateManifest } from './validate.js'; import { resolveSafeOutputPath, writeJsonOutput } from './io.js'; @@ -71,12 +70,15 @@ export function writeManifest(manifest, { output, cwd = process.cwd() } = {}) { const requestedPath = output || 'agentcli.json'; const filePath = resolveSafeOutputPath(requestedPath, cwd); - if (existsSync(filePath)) { - throw Object.assign( - new Error(`File already exists: ${filePath}. Use --output to specify a different path or remove the existing file.`), - { code: 'invalid_argument' } - ); + try { + return writeJsonOutput(requestedPath, manifest, { cwd, overwrite: false }); + } catch (error) { + if (error?.code === 'EEXIST') { + throw Object.assign( + new Error(`File already exists: ${filePath}. Use --output to specify a different path or remove the existing file.`), + { code: 'invalid_argument' } + ); + } + throw error; } - - return writeJsonOutput(requestedPath, manifest, { cwd }); } diff --git a/src/io.js b/src/io.js index aa2d48a..c73d591 100644 --- a/src/io.js +++ b/src/io.js @@ -12,7 +12,7 @@ import { } from 'node:fs'; import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; import { isatty } from 'node:tty'; -import { resolveManifestCandidate } from './home.js'; +import { assertRegularFileDescriptor, resolveManifestCandidate } from './home.js'; function looksLikeJsonLiteral(input) { if (typeof input !== 'string') return false; @@ -129,9 +129,13 @@ export function resolveSafeOutputPath(outputPath, cwd = process.cwd()) { } if (existsSync(resolvedPath)) { - if (lstatSync(resolvedPath).isSymbolicLink()) { + const outputState = lstatSync(resolvedPath); + if (outputState.isSymbolicLink()) { throw invalidOutput('Refusing to overwrite a symbolic link.'); } + if (!outputState.isFile()) { + throw invalidOutput('Refusing to overwrite a non-regular file.'); + } if (!isWithin(baseRealPath, realpathSync(resolvedPath))) { throw invalidOutput('Refusing to overwrite a file outside the current working directory.'); } @@ -140,7 +144,15 @@ export function resolveSafeOutputPath(outputPath, cwd = process.cwd()) { return resolvedPath; } -export function writeJsonOutput(outputPath, payload, { cwd = process.cwd() } = {}) { +export function writeJsonOutput( + outputPath, + payload, + { cwd = process.cwd(), overwrite = true } = {} +) { + if (typeof overwrite !== 'boolean') { + throw invalidOutput('Output overwrite option must be a boolean.'); + } + const serialized = `${JSON.stringify(payload, null, 2)}\n`; let resolvedPath = resolveSafeOutputPath(outputPath, cwd); mkdirSync(dirname(resolvedPath), { recursive: true, mode: 0o700 }); resolvedPath = resolveSafeOutputPath(outputPath, cwd); @@ -151,12 +163,14 @@ export function writeJsonOutput(outputPath, payload, { cwd = process.cwd() } = { resolvedPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | - fsConstants.O_TRUNC | + (overwrite ? fsConstants.O_TRUNC : fsConstants.O_EXCL) | + (fsConstants.O_NONBLOCK || 0) | (fsConstants.O_NOFOLLOW || 0), 0o600 ); + assertRegularFileDescriptor(fd, resolvedPath); if (process.platform !== 'win32') fchmodSync(fd, 0o600); - writeFileSync(fd, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); + writeFileSync(fd, serialized, 'utf8'); } catch (err) { if (err?.code === 'ELOOP') { throw invalidOutput('Refusing to overwrite a symbolic link.'); diff --git a/src/registry.js b/src/registry.js index 50376c4..5f83b90 100644 --- a/src/registry.js +++ b/src/registry.js @@ -1,10 +1,9 @@ import { - chmodSync, closeSync, constants as fsConstants, existsSync, + fchmodSync, lstatSync, - mkdirSync, openSync, readFileSync, readdirSync, @@ -13,18 +12,15 @@ import { } from 'node:fs'; import { join, basename, extname } from 'node:path'; import { validateManifest } from './validate.js'; -import { getAgentcliPaths } from './home.js'; +import { + assertRegularFileDescriptor, + ensurePrivateDirectory, + getAgentcliPaths, +} from './home.js'; function registryDir({ env = process.env } = {}) { const paths = getAgentcliPaths({ env }); - if (existsSync(paths.registry) && lstatSync(paths.registry).isSymbolicLink()) { - throw Object.assign( - new Error('Refusing to use a registry directory that is a symbolic link'), - { code: 'invalid_argument' } - ); - } - mkdirSync(paths.registry, { recursive: true, mode: 0o700 }); - if (process.platform !== 'win32') chmodSync(paths.registry, 0o700); + ensurePrivateDirectory(paths.registry); return paths.registry; } @@ -47,9 +43,13 @@ export function listRegistry({ env } = {}) { const name = file.replace(/\.json$/, ''); const filePath = join(dir, file); try { - if (lstatSync(filePath).isSymbolicLink()) { + const entryState = lstatSync(filePath); + if (entryState.isSymbolicLink()) { return { name, workflows: [], symlink_refused: true }; } + if (!entryState.isFile()) { + return { name, workflows: [], invalid_type_refused: true }; + } const manifest = JSON.parse(readFileSync(filePath, 'utf8')); const workflows = (manifest.workflows || []).map(w => ({ id: w.id, @@ -105,29 +105,39 @@ export function addToRegistry(manifestOrPath, { name, env, cwd = process.cwd() } const dir = registryDir({ env }); const filePath = entryPath(dir, entryName); const overwritten = existsSync(filePath); - if (overwritten && lstatSync(filePath).isSymbolicLink()) { - throw Object.assign( - new Error(`Refusing to overwrite symbolic-link registry entry: "${entryName}"`), - { code: 'invalid_argument' } - ); + if (overwritten) { + const entryState = lstatSync(filePath); + if (entryState.isSymbolicLink()) { + throw Object.assign( + new Error(`Refusing to overwrite symbolic-link registry entry: "${entryName}"`), + { code: 'invalid_argument' } + ); + } + if (!entryState.isFile()) { + throw Object.assign( + new Error(`Refusing to overwrite non-regular registry entry: "${entryName}"`), + { code: 'invalid_argument' } + ); + } } let descriptor; try { descriptor = openSync( filePath, - fsConstants.O_WRONLY | + fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_TRUNC | + (fsConstants.O_NONBLOCK || 0) | (fsConstants.O_NOFOLLOW || 0), 0o600 ); + assertRegularFileDescriptor(descriptor, filePath); + if (process.platform !== 'win32') fchmodSync(descriptor, 0o600); writeFileSync(descriptor, JSON.stringify(manifest, null, 2) + '\n', 'utf8'); } finally { if (descriptor !== undefined) closeSync(descriptor); } - if (process.platform !== 'win32') chmodSync(filePath, 0o600); - return { name: entryName, path: filePath, overwritten }; } @@ -141,12 +151,19 @@ export function showRegistryEntry(name, { env } = {}) { { code: 'invalid_argument' } ); } - if (lstatSync(filePath).isSymbolicLink()) { + const entryState = lstatSync(filePath); + if (entryState.isSymbolicLink()) { throw Object.assign( new Error(`Refusing to read symbolic-link registry entry: "${name}"`), { code: 'invalid_argument' } ); } + if (!entryState.isFile()) { + throw Object.assign( + new Error(`Refusing to read non-regular registry entry: "${name}"`), + { code: 'invalid_argument' } + ); + } try { return JSON.parse(readFileSync(filePath, 'utf8')); diff --git a/src/signing/ssh.js b/src/signing/ssh.js index b7e550a..2400e71 100644 --- a/src/signing/ssh.js +++ b/src/signing/ssh.js @@ -1,11 +1,10 @@ import { spawnSync } from 'node:child_process'; import { - chmodSync, closeSync, constants as fsConstants, existsSync, + fchmodSync, lstatSync, - mkdirSync, openSync, readFileSync, unlinkSync, @@ -15,6 +14,7 @@ import { homedir } from 'node:os'; import { join, dirname } from 'node:path'; import { tmpdir } from 'node:os'; import { registerProvider } from './index.js'; +import { assertRegularFileDescriptor, ensurePrivateDirectory } from '../home.js'; const SSH_KEY_CANDIDATES = ['id_ed25519', 'id_ecdsa', 'id_rsa']; const NAMESPACE = 'agentcli'; @@ -129,23 +129,24 @@ export function generateAllowedSigners({ principal, homeDir = homedir(), outputP if (lines.length === 0) return null; const outputDirectory = dirname(outputPath); - mkdirSync(outputDirectory, { recursive: true, mode: 0o700 }); - if (process.platform !== 'win32') chmodSync(outputDirectory, 0o700); + ensurePrivateDirectory(outputDirectory); let descriptor; try { descriptor = openSync( outputPath, - fsConstants.O_WRONLY | + fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_TRUNC | + (fsConstants.O_NONBLOCK || 0) | (fsConstants.O_NOFOLLOW || 0), 0o600 ); + assertRegularFileDescriptor(descriptor, outputPath); + if (process.platform !== 'win32') fchmodSync(descriptor, 0o600); writeFileSync(descriptor, lines.join('\n') + '\n', 'utf8'); } finally { if (descriptor !== undefined) closeSync(descriptor); } - if (process.platform !== 'win32') chmodSync(outputPath, 0o600); return outputPath; } diff --git a/test/agentcli.test.js b/test/agentcli.test.js index 6e94eec..9711330 100644 --- a/test/agentcli.test.js +++ b/test/agentcli.test.js @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { generateKeyPairSync, createSign } from 'node:crypto'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { closeSync, constants as fsConstants, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { delimiter, join } from 'node:path'; import { tmpdir } from 'node:os'; import { createServer } from 'node:http'; @@ -3100,11 +3100,14 @@ test('cli init rejects when agentcli.json already exists', async (t) => { const envOverride = { ...process.env, AGENTCLI_HOME: homeRoot }; await runCli(['init'], { cwd: workdir, env: envOverride }); + const manifestPath = join(workdir, 'agentcli.json'); + const original = readFileSync(manifestPath, 'utf8'); await assert.rejects( runCli(['init'], { cwd: workdir, env: envOverride }), /File already exists/ ); + assert.equal(readFileSync(manifestPath, 'utf8'), original); }); // --- Sweep 10 tests --- @@ -4562,6 +4565,68 @@ test('resolveSigningKey respects AGENTCLI_SIGNING_KEY env', () => { assert.equal(key, null); }); +test('generateAllowedSigners refuses a symbolic-link output directory', (t) => { + const root = mkdtempSync(join(tmpdir(), 'agentcli-signers-dir-link-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const home = join(root, 'home'); + const sshDirectory = join(home, '.ssh'); + const targetDirectory = join(root, 'target'); + const linkedDirectory = join(root, 'linked'); + mkdirSync(sshDirectory, { recursive: true }); + mkdirSync(targetDirectory); + writeFileSync(join(sshDirectory, 'id_ed25519.pub'), 'ssh-ed25519 AAAATEST agentcli@test\n'); + try { + symlinkSync( + targetDirectory, + linkedDirectory, + process.platform === 'win32' ? 'junction' : 'dir' + ); + } catch (error) { + if (process.platform === 'win32' && ['EACCES', 'EPERM'].includes(error?.code)) { + t.skip('directory link creation is unavailable on this Windows runner'); + return; + } + throw error; + } + + assert.throws( + () => generateAllowedSigners({ + principal: 'agentcli@test', + homeDir: home, + outputPath: join(linkedDirectory, 'allowed_signers'), + }), + error => error.code === 'invalid_argument' && /symbolic-link directory/.test(error.message) + ); + assert.equal(existsSync(join(targetDirectory, 'allowed_signers')), false); +}); + +test('generateAllowedSigners refuses a FIFO output without blocking', { + skip: process.platform === 'win32', +}, (t) => { + const root = mkdtempSync(join(tmpdir(), 'agentcli-signers-fifo-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const home = join(root, 'home'); + const sshDirectory = join(home, '.ssh'); + const outputPath = join(root, 'allowed_signers'); + mkdirSync(sshDirectory, { recursive: true }); + writeFileSync(join(sshDirectory, 'id_ed25519.pub'), 'ssh-ed25519 AAAATEST agentcli@test\n'); + const created = spawnSync('mkfifo', [outputPath], { encoding: 'utf8' }); + assert.equal(created.status, 0, created.stderr || created.error?.message); + const reader = openSync(outputPath, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK); + try { + assert.throws( + () => generateAllowedSigners({ + principal: 'agentcli@test', + homeDir: home, + outputPath, + }), + error => error.code === 'invalid_argument' && /non-regular file/.test(error.message) + ); + } finally { + closeSync(reader); + } +}); + test('buildAttestationPayload produces deterministic canonical JSON', () => { const fields = { executionId: 'abc123', diff --git a/test/approvals.test.js b/test/approvals.test.js index 018a862..d22a24b 100644 --- a/test/approvals.test.js +++ b/test/approvals.test.js @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, readFileSync, realpathSync, writeFileSync, appendFileSync, existsSync, rmSync, mkdirSync, statSync, symlinkSync, utimesSync } from 'node:fs'; +import { closeSync, constants as fsConstants, mkdtempSync, openSync, readFileSync, realpathSync, writeFileSync, appendFileSync, existsSync, rmSync, mkdirSync, statSync, symlinkSync, utimesSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { Worker } from 'node:worker_threads'; @@ -332,6 +332,34 @@ test('approval writes refuse symbolic-link log destinations', { skip: process.pl } }); +test('approval writes refuse FIFO log destinations without blocking', { + skip: process.platform === 'win32', +}, () => { + const { env, cleanup } = isolatedEnv(); + let reader; + try { + const paths = getAgentcliPaths({ env }); + mkdirSync(paths.state, { recursive: true }); + const created = spawnSync('mkfifo', [paths.approvals], { encoding: 'utf8' }); + assert.equal(created.status, 0, created.stderr || created.error?.message); + reader = openSync(paths.approvals, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK); + const manifest = makeManifest({ approval: { policy: 'manual', risk_level: 'high' } }); + assert.throws( + () => grantApproval({ + manifest, + taskId: 'echo-task', + approver: 'alice', + signer: 'none', + env, + }), + error => error.code === 'approval_log_invalid' && /non-regular file/.test(error.message) + ); + } finally { + if (reader !== undefined) closeSync(reader); + cleanup(); + } +}); + test('consume moves a grant out of pending', () => { const { env, cleanup } = isolatedEnv(); try { diff --git a/test/cli-rpc-validation.test.js b/test/cli-rpc-validation.test.js index 0bb1361..6d5008e 100644 --- a/test/cli-rpc-validation.test.js +++ b/test/cli-rpc-validation.test.js @@ -1,9 +1,12 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { + closeSync, + constants as fsConstants, existsSync, mkdirSync, mkdtempSync, + openSync, readFileSync, rmSync, statSync, @@ -534,3 +537,48 @@ test('safe JSON output creates mode-restricted files inside cwd', (t) => { assert.equal(statSync(written).mode & 0o777, 0o600); } }); + +test('safe JSON output can atomically refuse overwrites without side effects', (t) => { + const base = mkdtempSync(join(tmpdir(), 'agentcli-output-exclusive-')); + t.after(() => rmSync(base, { recursive: true, force: true })); + + const existingPath = join(base, 'existing.json'); + writeFileSync(existingPath, 'sentinel\n', 'utf8'); + assert.throws( + () => writeJsonOutput('existing.json', { replaced: true }, { cwd: base, overwrite: false }), + error => error.code === 'EEXIST' + ); + assert.equal(readFileSync(existingPath, 'utf8'), 'sentinel\n'); + + const circular = {}; + circular.self = circular; + assert.throws( + () => writeJsonOutput('nested/circular.json', circular, { cwd: base, overwrite: false }), + /circular/i + ); + assert.equal(existsSync(join(base, 'nested')), false); + + assert.throws( + () => writeJsonOutput('invalid.json', { ok: true }, { cwd: base, overwrite: 'no' }), + error => error.code === 'invalid_argument' && /overwrite option/.test(error.message) + ); +}); + +test('safe JSON output refuses FIFO destinations without blocking', { + skip: process.platform === 'win32', +}, (t) => { + const base = mkdtempSync(join(tmpdir(), 'agentcli-output-fifo-')); + t.after(() => rmSync(base, { recursive: true, force: true })); + const outputPath = join(base, 'result.json'); + const created = spawnSync('mkfifo', [outputPath], { encoding: 'utf8' }); + assert.equal(created.status, 0, created.stderr || created.error?.message); + const reader = openSync(outputPath, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK); + try { + assert.throws( + () => writeJsonOutput('result.json', { ok: true }, { cwd: base }), + error => error.code === 'invalid_argument' && /non-regular file/.test(error.message) + ); + } finally { + closeSync(reader); + } +}); diff --git a/test/foundation.test.js b/test/foundation.test.js index d6f5f3e..1ca1983 100644 --- a/test/foundation.test.js +++ b/test/foundation.test.js @@ -1,8 +1,14 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; import { chmodSync, + closeSync, + constants as fsConstants, + existsSync, + mkdirSync, mkdtempSync, + openSync, readFileSync, rmSync, statSync, @@ -28,6 +34,19 @@ import { writeJsonOutput, } from '../src/index.js'; +function createDirectoryLinkOrSkip(t, target, link) { + try { + symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir'); + return true; + } catch (error) { + if (process.platform === 'win32' && ['EACCES', 'EPERM'].includes(error?.code)) { + t.skip('directory link creation is unavailable on this Windows runner'); + return false; + } + throw error; + } +} + function manifestWithSecrets() { return { version: '0.2', @@ -190,6 +209,28 @@ test('audit append refuses symbolic-link destinations', { skip: process.platform } }); +test('audit append refuses FIFO destinations without blocking', { + skip: process.platform === 'win32', +}, () => { + const root = mkdtempSync(join(tmpdir(), 'agentcli-audit-fifo-')); + const auditPath = join(root, 'audit.ndjson'); + try { + const created = spawnSync('mkfifo', [auditPath], { encoding: 'utf8' }); + assert.equal(created.status, 0, created.stderr || created.error?.message); + const reader = openSync(auditPath, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK); + try { + assert.throws( + () => writeAuditRecord({ execution_id: 'blocked' }, { auditPath }), + error => error.code === 'invalid_argument' && /non-regular file/.test(error.message) + ); + } finally { + closeSync(reader); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test('JSON output tightens permissions when overwriting an existing file', { skip: process.platform === 'win32', }, () => { @@ -238,6 +279,46 @@ test('registry refuses symbolic-link entries', { skip: process.platform === 'win } }); +test('registry refuses FIFO entries without blocking', { + skip: process.platform === 'win32', +}, () => { + const home = mkdtempSync(join(tmpdir(), 'agentcli-registry-fifo-')); + const env = { ...process.env, AGENTCLI_HOME: home }; + const manifest = { + version: '0.2', + workflows: [{ + id: 'registry-fifo', name: 'Registry FIFO', tasks: [{ + id: 'run', name: 'Run', target: { session_target: 'shell' }, + shell: { program: 'true', args: [] }, + schedule: { cron: '0 * * * *' }, + }], + }], + }; + try { + ensureAgentcliHome({ env }); + const entryPath = join(home, 'registry', 'blocked.json'); + const created = spawnSync('mkfifo', [entryPath], { encoding: 'utf8' }); + assert.equal(created.status, 0, created.stderr || created.error?.message); + const reader = openSync(entryPath, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK); + try { + assert.throws( + () => addToRegistry(manifest, { name: 'blocked', env }), + error => error.code === 'invalid_argument' && /non-regular registry entry/.test(error.message) + ); + assert.throws( + () => showRegistryEntry('blocked', { env }), + error => error.code === 'invalid_argument' && /non-regular registry entry/.test(error.message) + ); + const listed = listRegistry({ env }).find(entry => entry.name === 'blocked'); + assert.equal(listed.invalid_type_refused, true); + } finally { + closeSync(reader); + } + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + test('agentcli home stores state and scaffold files with private permissions', { skip: process.platform === 'win32', }, () => { @@ -262,6 +343,121 @@ test('agentcli home stores state and scaffold files with private permissions', { } }); +test('agentcli home refuses a symbolic-link root without mutating its target', (t) => { + const root = mkdtempSync(join(tmpdir(), 'agentcli-home-root-link-')); + const target = join(root, 'target'); + const linkedHome = join(root, 'linked-home'); + try { + mkdirSync(target, { mode: 0o755 }); + if (process.platform !== 'win32') chmodSync(target, 0o755); + if (!createDirectoryLinkOrSkip(t, target, linkedHome)) return; + + assert.throws( + () => ensureAgentcliHome({ env: { ...process.env, AGENTCLI_HOME: linkedHome }, force: true }), + error => error.code === 'invalid_argument' && /symbolic-link directory/.test(error.message) + ); + assert.equal(existsSync(join(target, 'README.md')), false); + if (process.platform !== 'win32') assert.equal(statSync(target).mode & 0o777, 0o755); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('agentcli home refuses symbolic-link managed subdirectories before mutation', (t) => { + const root = mkdtempSync(join(tmpdir(), 'agentcli-home-subdir-link-')); + try { + for (const name of ['manifests', 'output', 'state', 'registry']) { + const caseRoot = join(root, name); + const home = join(caseRoot, 'home'); + const target = join(caseRoot, 'target'); + mkdirSync(home, { recursive: true }); + mkdirSync(target, { mode: 0o755 }); + if (process.platform !== 'win32') chmodSync(target, 0o755); + if (!createDirectoryLinkOrSkip(t, target, join(home, name))) return; + + assert.throws( + () => ensureAgentcliHome({ env: { ...process.env, AGENTCLI_HOME: home } }), + error => error.code === 'invalid_argument' && /symbolic-link directory/.test(error.message) + ); + assert.equal(existsSync(join(home, 'README.md')), false); + if (process.platform !== 'win32') assert.equal(statSync(target).mode & 0o777, 0o755); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('agentcli home refuses dangling links and non-directory managed paths', (t) => { + const root = mkdtempSync(join(tmpdir(), 'agentcli-home-invalid-paths-')); + try { + const missingTarget = join(root, 'missing-target'); + const danglingHome = join(root, 'dangling-home'); + if (!createDirectoryLinkOrSkip(t, missingTarget, danglingHome)) return; + assert.throws( + () => ensureAgentcliHome({ env: { ...process.env, AGENTCLI_HOME: danglingHome } }), + error => error.code === 'invalid_argument' && /symbolic-link directory/.test(error.message) + ); + assert.equal(existsSync(missingTarget), false); + + for (const name of ['root', 'manifests', 'output', 'state', 'registry']) { + const caseRoot = join(root, `wrong-${name}`); + const home = name === 'root' ? join(caseRoot, 'home-file') : join(caseRoot, 'home'); + mkdirSync(caseRoot, { recursive: true }); + if (name !== 'root') mkdirSync(home); + const wrongPath = name === 'root' ? home : join(home, name); + writeFileSync(wrongPath, 'unchanged\n', 'utf8'); + + assert.throws( + () => ensureAgentcliHome({ env: { ...process.env, AGENTCLI_HOME: home } }), + error => error.code === 'invalid_argument' && /Expected directory path/.test(error.message) + ); + assert.equal(readFileSync(wrongPath, 'utf8'), 'unchanged\n'); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('agentcli home refuses symbolic-link scaffold files even with force', (t) => { + const root = mkdtempSync(join(tmpdir(), 'agentcli-home-file-link-')); + try { + for (const [relativePath, force] of [ + ['README.md', false], + ['README.md', true], + [join('manifests', 'bot-health.json'), false], + [join('manifests', 'bot-health.json'), true], + ]) { + const caseRoot = join(root, relativePath.replaceAll('/', '-'), force ? 'force' : 'normal'); + const home = join(caseRoot, 'home'); + const target = join(caseRoot, 'target.txt'); + const env = { ...process.env, AGENTCLI_HOME: home }; + ensureAgentcliHome({ env }); + const linkedFile = join(home, relativePath); + rmSync(linkedFile); + writeFileSync(target, 'unchanged\n', { mode: 0o644 }); + if (process.platform !== 'win32') chmodSync(target, 0o644); + try { + symlinkSync(target, linkedFile, 'file'); + } catch (error) { + if (process.platform === 'win32' && ['EACCES', 'EPERM'].includes(error?.code)) { + t.skip('file link creation is unavailable on this Windows runner'); + return; + } + throw error; + } + + assert.throws( + () => ensureAgentcliHome({ env, force }), + error => error.code === 'invalid_argument' && /symbolic-link regular file/.test(error.message) + ); + assert.equal(readFileSync(target, 'utf8'), 'unchanged\n'); + if (process.platform !== 'win32') assert.equal(statSync(target).mode & 0o777, 0o644); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test('v0.1 conversion produces unique valid profile ids for colliding principal slugs', () => { const manifest = { version: '0.1', diff --git a/test/proof-evidence.test.js b/test/proof-evidence.test.js index 739d421..cdb8ae7 100644 --- a/test/proof-evidence.test.js +++ b/test/proof-evidence.test.js @@ -5,10 +5,16 @@ import { } from 'node:crypto'; import { chmodSync, + closeSync, + constants as fsConstants, + existsSync, + mkdirSync, mkdtempSync, + openSync, readFileSync, rmSync, statSync, + symlinkSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -35,7 +41,10 @@ import { validateCompleteEvidencePayload, validateEvidenceRecordBinding, } from '../src/evidence/payload.js'; -import { sshEvidenceProvider } from '../src/evidence/ssh.js'; +import { + generateAllowedSigners as generateEvidenceAllowedSigners, + sshEvidenceProvider, +} from '../src/evidence/ssh.js'; import { registerEvidenceProvider, verifyEvidenceEnvelope } from '../src/evidence/index.js'; import { generateExecutionId, @@ -532,6 +541,68 @@ test('SSH evidence profiles reject non-canonical payload serialization', () => { ); }); +test('SSH evidence allowed signers refuse a symbolic-link output directory', (t) => { + const root = mkdtempSync(join(tmpdir(), 'agentcli-evidence-signers-link-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const home = join(root, 'home'); + const sshDirectory = join(home, '.ssh'); + const targetDirectory = join(root, 'target'); + const linkedDirectory = join(root, 'linked'); + mkdirSync(sshDirectory, { recursive: true }); + mkdirSync(targetDirectory); + writeFileSync(join(sshDirectory, 'id_ed25519.pub'), 'ssh-ed25519 AAAATEST agentcli@test\n'); + try { + symlinkSync( + targetDirectory, + linkedDirectory, + process.platform === 'win32' ? 'junction' : 'dir' + ); + } catch (error) { + if (process.platform === 'win32' && ['EACCES', 'EPERM'].includes(error?.code)) { + t.skip('directory link creation is unavailable on this Windows runner'); + return; + } + throw error; + } + + assert.throws( + () => generateEvidenceAllowedSigners({ + principal: 'agentcli@test', + homeDir: home, + outputPath: join(linkedDirectory, 'allowed_signers'), + }), + error => error.code === 'invalid_argument' && /symbolic-link directory/.test(error.message) + ); + assert.equal(existsSync(join(targetDirectory, 'allowed_signers')), false); +}); + +test('SSH evidence allowed signers refuse a FIFO output without blocking', { + skip: process.platform === 'win32', +}, (t) => { + const root = mkdtempSync(join(tmpdir(), 'agentcli-evidence-signers-fifo-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const home = join(root, 'home'); + const sshDirectory = join(home, '.ssh'); + const outputPath = join(root, 'allowed_signers'); + mkdirSync(sshDirectory, { recursive: true }); + writeFileSync(join(sshDirectory, 'id_ed25519.pub'), 'ssh-ed25519 AAAATEST agentcli@test\n'); + const created = spawnSync('mkfifo', [outputPath], { encoding: 'utf8' }); + assert.equal(created.status, 0, created.stderr || created.error?.message); + const reader = openSync(outputPath, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK); + try { + assert.throws( + () => generateEvidenceAllowedSigners({ + principal: 'agentcli@test', + homeDir: home, + outputPath, + }), + error => error.code === 'invalid_argument' && /non-regular file/.test(error.message) + ); + } finally { + closeSync(reader); + } +}); + test('SSH evidence persists a versioned envelope that can be independently verified', async () => { const workdir = mkdtempSync(join(tmpdir(), 'agentcli-evidence-')); const keyPath = join(workdir, 'evidence-key');