diff --git a/docs/architecture.md b/docs/architecture.md index 2f3a935..26e680b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -68,6 +68,7 @@ Each provider file auto-registers with its registry on import (side-effect regis - **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. @@ -85,6 +86,46 @@ The architecture composes with emerging standards rather than inventing new prot - **SPIFFE/WIMSE** -- identity profiles use URI-formatted principals (`agent://`, `spiffe://`) for interoperability with workload identity infrastructure. - **OAuth 2.0** -- auth modes map to standard grant types: `service` (Client Credentials), `delegated` (Authorization Code), `on-behalf-of` (JWT Authorization Grant, RFC 7523), `exchange` (Token Exchange, RFC 8693). +### Scheduler/Child Trust Boundary + +The six-layer identity model enables a meaningful trust boundary between +the scheduler and its child tasks, but only when the child is configured +to be narrower than the parent. + +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 + 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`, + the provider mints a per-task restricted key via the credential + issuer's API, scoped to exactly the permissions the child declared. + Scope hierarchy validation ensures the child cannot escalate. + The key is revoked in cleanup. +4. **Child receives scoped creds** -- the child task runs with only the + narrowed credentials. For shell tasks, these are injected as env vars. + For agent tasks, auth-profile forwarding directs the gateway to use + the appropriate profile. + +**Trust boundary definition:** the operator controls the scheduler 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 -- +these are root-of-trust assumptions, not runtime invariants. + +**Credential strategies:** the current implementation supports both +precreated keys (operator creates restricted keys ahead of time, provider +resolves by scope name) and dynamic key minting (provider mints a +per-task restricted key via the credential issuer's API and revokes it on +cleanup). Both use the same manifest syntax; the provider's +`key_strategy` configuration determines which path runs. + +For the full trust architecture with concrete guarantees and +non-guarantees, see `openclaw-scheduler/docs/trust-architecture.md`. + ## Near-Term Roadmap 1. Stabilize manifest and target outputs. diff --git a/docs/execution-identity.md b/docs/execution-identity.md index 7a220a9..f4df500 100644 --- a/docs/execution-identity.md +++ b/docs/execution-identity.md @@ -1722,6 +1722,14 @@ Evidence verification occurs after execution (Phase 5) has already completed. A - 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 + +- 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 +- 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 7: Audit - write append-only audit record @@ -2100,6 +2108,47 @@ Authorization evaluation results SHOULD return: ## Security Model +### Execution Principal Separation + +The security model rests on a separation between the control plane +(agentcli) and the execution runtime (openclaw-scheduler or another +backend). + +**agentcli's role:** declare identity, compile trust constraints, validate +delegation chains (cycle detection via DFS on scope hierarchy), and verify +authorization proofs at apply time. agentcli does not persist runtime +credentials and does not own the dispatch queue. + +**Scheduler's role:** resolve credentials at dispatch time, enforce trust +gates, apply child credential policies, mint or resolve scoped credentials +via identity providers, materialize them into execution environments, and +record audit trails with full identity provenance. + +**When the boundary is a real security boundary:** the scheduler/child +separation is a meaningful security boundary when the child is narrower +than the parent in identity, credentials, tools, state, or +network/filesystem scope. The `child_credential_policy` field (`none`, +`inherit`, `downscope`, `independent`) controls this. When the policy is +`downscope`, the provider mints a per-task restricted key scoped to +exactly the permissions the child declared -- the child literally cannot +access the parent's full credential set. + +**When the boundary is operational:** if the child inherits the parent's +full credentials without narrowing, the boundary provides lifecycle +isolation, attribution, context isolation, and blast radius containment +for crashes -- but not credential-based access control. This is still +valuable, but operators should not treat it as a security guarantee unless +narrowing is actually configured. + +The honest summary: if you cannot make the child meaningfully narrower in +identity, tools, state, or network/filesystem scope, then the sub-agent +boundary is mostly an execution/lifecycle boundary, not a strong security +boundary. The stronger design is scheduler as broker/orchestrator, child +as bounded actor, with explicit narrowing at each level. + +For the runtime perspective on this architecture, see +`openclaw-scheduler/docs/trust-architecture.md`. + ### Secret Handling - manifests MUST NOT contain raw client secrets or bearer tokens diff --git a/docs/field-reference.md b/docs/field-reference.md index 46b2219..ba4dd10 100644 --- a/docs/field-reference.md +++ b/docs/field-reference.md @@ -42,6 +42,7 @@ constraints). When in doubt, the source code is authoritative. | `authorization` | object | No | Authorization reference (v0.2). See [Authorization Reference Fields](#authorization-reference-fields). | | `evidence` | object | No | Evidence reference (v0.2). See [Evidence Reference Fields](#evidence-reference-fields). | | `child_credential_policy` | string | No | Child credential flow policy for triggered children. See [Child Credential Policy Fields](#child-credential-policy-fields). | +| `verify` | object | No | Post-success verification command. See [Task Verify Fields](#task-verify-fields). | --- @@ -74,6 +75,7 @@ constraints). When in doubt, the source code is authoritative. | `authorization` | object | No | Authorization reference (v0.2). See [Authorization Reference Fields](#authorization-reference-fields). | | `evidence` | object | No | Evidence reference (v0.2). See [Evidence Reference Fields](#evidence-reference-fields). | | `child_credential_policy` | string | No | Child credential flow policy for triggered children. See [Child Credential Policy Fields](#child-credential-policy-fields). | +| `verify` | object | No | Post-success verification command. See [Task Verify Fields](#task-verify-fields). | | `on_failure` | object | No | Failure handler shorthand. See [On-Failure Fields](#on-failure-fields). | | `delete_after_run` | boolean | No | Remove the compiled job after first successful execution. | @@ -272,6 +274,22 @@ When `ref` is present, the referenced profile is loaded first, then inline field |-------|------|----------|--------|-------------| | `child_credential_policy` | string | No | `none`, `inherit`, `downscope`, `independent` | Controls how a child task receives or derives credentials relative to its parent. Workflow-level values act as defaults for tasks. | +`child_credential_policy: "downscope"` is validated as a capability warning when a backend lacks `credential_handoff`: the scheduler can still persist the job, but child narrowing will not be enforceable at dispatch. This is intentionally softer than `identity.presentation.handoff != "none"`, which is a hard compatibility requirement because the active runtime/backend must advertise explicit handoff semantics up front. + +--- + +## Task Verify Fields + +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. + +| Field | Type | Required | Values | Description | +|-------|------|----------|--------|-------------| +| `shell` | string | Yes | -- | Shell command to run after a successful task execution. | +| `timeout_seconds` | integer | No | `>= 1` | Timeout for the verify command. Default: `30`. | +| `on_failure` | string | No | `error`, `warn` | Whether a verify failure should fail the task or be surfaced as a warning. Default: `error`. | + ### Identity Subject Fields | Field | Type | Required | Values | Description | @@ -332,6 +350,8 @@ When `ref` is present, the referenced profile is loaded first, then inline field | `cleanup` | string | No | `always`, `on-success`, `on-failure`, `never` | When credential cleanup runs. | | `default_redaction` | boolean | No | -- | Whether credential values are redacted by default in audit output. | +`identity.presentation.handoff` is stricter than `child_credential_policy`: any non-`none` handoff mode requires explicit `credential_handoff` support from the active runtime/backend during capability negotiation, because the handoff boundary itself must be modeled first-class. + ### Identity Presentation Bindings Each element in the `bindings` array is an object with these fields: diff --git a/src/apply.js b/src/apply.js index d18a8cb..6188ea6 100644 --- a/src/apply.js +++ b/src/apply.js @@ -240,18 +240,25 @@ export async function applyManifestToScheduler( let effectiveResult = resolveEffectiveFeatures('openclaw-scheduler', null); let handoffVersion = '1'; + let capabilityWarnings = []; if (hasV02Features) { const runtimeCaps = querySchedulerCapabilities(schedulerRunner); effectiveResult = resolveEffectiveFeatures('openclaw-scheduler', runtimeCaps); handoffVersion = effectiveResult.handoff_version || '1'; - const capabilityErrors = validateManifestCapabilities(compiled, effectiveResult); + const { errors: capabilityErrors, warnings } = validateManifestCapabilities(compiled, effectiveResult); + capabilityWarnings = warnings; if (capabilityErrors.length > 0) { throw Object.assign( new Error(capabilityErrors.map(error => error.message).join('; ')), { code: 'unsupported_capability', capability_errors: capabilityErrors } ); } + if (capabilityWarnings.length > 0) { + for (const warning of capabilityWarnings) { + process.stderr.write(`warning: ${warning.message}\n`); + } + } } const effectiveFeatures = effectiveResult.features; @@ -455,6 +462,7 @@ export async function applyManifestToScheduler( source: effectiveResult.source, negotiated: effectiveResult.negotiated, handoff_version: effectiveResult.handoff_version || null, + ...(capabilityWarnings?.length > 0 ? { warnings: capabilityWarnings } : {}), }, handoff: { field_version: handoffVersion, diff --git a/src/capabilities.js b/src/capabilities.js index f022139..b90452f 100644 --- a/src/capabilities.js +++ b/src/capabilities.js @@ -67,21 +67,27 @@ export function resolveEffectiveFeatures(targetName, runtimeCapabilities) { /** * Check if the manifest's requirements are satisfied by the effective features. - * Returns an array of mismatch errors (empty if all satisfied). + * Returns { errors: [...], warnings: [...] } where errors are hard gates and + * warnings are soft advisories that do not block apply. */ export function validateManifestCapabilities(compiledOutput, effectiveFeatures) { const errors = []; + const warnings = []; const features = effectiveFeatures.features || effectiveFeatures; - if (!compiledOutput || !compiledOutput.jobs) return errors; + if (!compiledOutput || !compiledOutput.jobs) return { errors, warnings }; - // Apply-time gating is intentionally limited to features that must exist to - // persist or hand off the compiled durable job spec. Runtime identity - // resolution, delegation validation, and child_credential_policy remain - // execution-time concerns: persisted identity declarations may already be - // sufficient for dispatch, delegation chains are only known after a concrete - // session is resolved, and child_credential_policy is a runtime column that - // all v23+ schedulers accept regardless of whether providers are loaded. + // Hard gates: features that must exist to persist or hand off the compiled + // durable job spec. These block apply when absent. + // + // Soft warnings: runtime_identity_resolution and credential_handoff (for + // child_credential_policy) are checked here as advisories. Persisted identity + // declarations may already be sufficient for dispatch and + // child_credential_policy is a runtime column that all v23+ schedulers accept + // regardless of whether providers are loaded, but a missing runtime feature + // means execution will fail -- so we surface the gap early. Delegation + // validation remains execution-time only (chains are only known after a + // concrete session is resolved). for (const job of compiledOutput.jobs) { // Check authorization hook requirement if (job.authorization || job.authorization_ref) { @@ -130,7 +136,36 @@ export function validateManifestCapabilities(compiledOutput, effectiveFeatures) }); } } + + // Soft warning: identity with a real provider requires runtime resolution + const identityProvider = job.identity?.provider ?? null; + if (identityProvider && identityProvider !== 'none') { + if (!features.runtime_identity_resolution) { + warnings.push({ + code: 'capability_warning', + feature: 'runtime_identity_resolution', + required_by: `job "${job.name || job.id}"`, + message: `Job "${job.name || job.id}" declares identity provider "${identityProvider}" but the runtime does not support runtime_identity_resolution; credentials will not resolve at execution time`, + }); + } + } + + // Soft warning: downscope policy requires credential_handoff at runtime. + // Note: identity.presentation.handoff (above) is a hard error because + // the scheduler cannot persist the handoff contract without the feature. + // child_credential_policy is a soft warning because the column is always + // accepted by v23+ schedulers -- enforcement happens at dispatch time. + if (job.child_credential_policy === 'downscope') { + if (!features.credential_handoff) { + warnings.push({ + code: 'capability_warning', + feature: 'credential_handoff', + required_by: `job "${job.name || job.id}"`, + message: `Job "${job.name || job.id}" declares child_credential_policy="downscope" but the runtime does not support credential_handoff; child credential scoping will not be enforced`, + }); + } + } } - return errors; + return { errors, warnings }; } diff --git a/src/cli.js b/src/cli.js index ddcf47e..7bf796a 100644 --- a/src/cli.js +++ b/src/cli.js @@ -281,7 +281,7 @@ export async function runCli( const caps = querySchedulerCapabilities(runner); const effective = resolveEffectiveFeatures('openclaw-scheduler', caps); const compiled = getTarget('openclaw-scheduler').compile(manifest); - const compatibilityErrors = validateManifestCapabilities(compiled, effective); + const { errors: compatibilityErrors, warnings: compatibilityWarnings } = validateManifestCapabilities(compiled, effective); return formatOutput({ ok: true, capabilities: caps, @@ -289,6 +289,7 @@ export async function runCli( compatibility: { ok: compatibilityErrors.length === 0, errors: compatibilityErrors, + warnings: compatibilityWarnings, }, }, { mode: outputMode, pretty }); } diff --git a/src/compiler/openclaw-scheduler.js b/src/compiler/openclaw-scheduler.js index 542ef32..67c1c79 100644 --- a/src/compiler/openclaw-scheduler.js +++ b/src/compiler/openclaw-scheduler.js @@ -327,6 +327,11 @@ export function compileManifestToScheduler(manifest, { includeExplain = false } child_credential_policy: plan.child_credential_policy ?? null, + // verify fields + verify_shell: plan.verify?.shell ?? null, + verify_timeout_s: plan.verify?.timeout_seconds ?? null, + verify_on_failure: plan.verify?.on_failure ?? null, + delete_after_run: plan.delete_after_run ? 1 : 0 }; validateSchedulerStringLimits(targetErrors, taskPath, job); diff --git a/src/compiler/shared.js b/src/compiler/shared.js index 9f61567..94b207e 100644 --- a/src/compiler/shared.js +++ b/src/compiler/shared.js @@ -507,11 +507,24 @@ function resolveBudgets(task) { }; } +export function resolveVerify(workflow, task) { + const workflowVerify = workflow.verify || null; + const taskVerify = task.verify || null; + if (!taskVerify && !workflowVerify) return null; + const effective = taskVerify || workflowVerify; + return { + shell: effective.shell, + timeout_seconds: effective.timeout_seconds ?? 30, + on_failure: effective.on_failure ?? 'error', + }; +} + export function normalizedTaskPlan(workflow, task, taskIdToCompiledId) { const modelPolicy = resolveModelPolicy(workflow, task); const identity = resolveIdentity(workflow, task); const contract = resolveContract(workflow, task); const childCredentialPolicy = resolveChildCredentialPolicy(workflow, task); + const verify = resolveVerify(workflow, task); const intent = resolveIntent(task); const output = resolveOutput(task); const budgets = resolveBudgets(task); @@ -562,6 +575,7 @@ export function normalizedTaskPlan(workflow, task, taskIdToCompiledId) { authorization: resolveAuthorization(workflow, task), evidence: resolveEvidence(workflow, task), child_credential_policy: childCredentialPolicy, + verify, delete_after_run: task.delete_after_run ?? null, parent_compiled_id: task.trigger ? taskIdToCompiledId.get(task.trigger.parent) : null, }; diff --git a/src/exec.js b/src/exec.js index 572b764..1b73f4b 100644 --- a/src/exec.js +++ b/src/exec.js @@ -15,7 +15,8 @@ import { resolveAuthorizationProof, resolveContract, resolveEvidence, - resolveIdentity + resolveIdentity, + resolveVerify } from './compiler/shared.js'; import { generateExecutionId, writeAuditRecord } from './audit.js'; import { getAgentcliPaths } from './home.js'; @@ -84,6 +85,54 @@ function preflightContractChecks(contract, shell, { cwd = process.cwd() } = {}) return { violations, warnings }; } +/** + * Run the verify command after a task completes successfully. + * + * @param {object} verify - Resolved verify block (shell, timeout_seconds, on_failure). + * @param {object} options - { cwd, env, sandboxCommand } for spawn context. + * @returns {object} { passed, exit_code, stdout, stderr, timed_out, duration_ms } + */ +function runVerify(verify, { + cwd = process.cwd(), + env = process.env, + sandboxCommand = null, +} = {}) { + const timeoutMs = (verify.timeout_seconds ?? 30) * 1000; + const startMs = Date.now(); + const verifyProgram = 'sh'; + const verifyArgs = ['-c', verify.shell]; + const usesSandbox = + sandboxCommand?.sandboxed === true && + sandboxCommand?.support?.kind === 'sandbox-exec' && + typeof sandboxCommand?.profile === 'string'; + + const proc = spawnSync( + usesSandbox ? sandboxCommand.support.command : verifyProgram, + usesSandbox ? ['-p', sandboxCommand.profile, verifyProgram, ...verifyArgs] : verifyArgs, + { + cwd, + env: { ...process.env, ...env }, + encoding: 'utf8', + timeout: timeoutMs, + maxBuffer: 1 * 1024 * 1024, + } + ); + const durationMs = Date.now() - startMs; + const stdout = proc.stdout || ''; + const stderr = proc.stderr || ''; + const exitCode = proc.status; + const timedOut = Boolean(proc.error && proc.error.code === 'ETIMEDOUT') || proc.signal === 'SIGTERM'; + + return { + passed: exitCode === 0 && !timedOut, + exit_code: exitCode, + stdout, + stderr, + timed_out: timedOut, + duration_ms: durationMs, + }; +} + function resolvePrincipal(identity) { if (identity.principal) return identity.principal; const user = process.env.USER || process.env.USERNAME || 'unknown'; @@ -175,6 +224,45 @@ function summarizeHandoff(handoffResult, mode) { }; } +async function cleanupProviderArtifacts(identityProviderInstance, { + materialization = null, + session = null, + providerConfig = {}, + env = process.env, + cwd = process.cwd(), + warningPrefix = 'Credential cleanup', +} = {}, warnings = []) { + if (!identityProviderInstance?.cleanup) return; + + const effectiveSession = session || materialization?.session || null; + const cleanupRequired = + Boolean(materialization?.cleanup_required) || + Boolean(effectiveSession?.provider_assertions?.key_strategy === 'dynamic'); + + if (!cleanupRequired) return; + + const cleanupMaterialization = materialization || { + materialized: false, + env_vars: {}, + cleanup_required: true, + ...(effectiveSession ? { session: effectiveSession } : {}), + }; + + try { + const cleanupResult = await identityProviderInstance.cleanup(cleanupMaterialization, { + session: effectiveSession, + env, + cwd, + provider_config: providerConfig, + }); + for (const warning of cleanupResult?.warnings || []) { + warnings.push(`${warningPrefix} warning: ${warning}`); + } + } catch (cleanupErr) { + warnings.push(`${warningPrefix} warning: ${cleanupErr.message}`); + } +} + /** * Validate and resolve common execution state shared by both v0.1 and v0.2 paths. * @@ -250,6 +338,7 @@ function resolveCommonState(manifest, { const identity = resolveIdentity(workflow, task); 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; @@ -268,7 +357,7 @@ function resolveCommonState(manifest, { const providerConfig = provider.resolve({ env, signingKey: explicitSigningKey }); return { - expanded, workflow, task, isV2, identity, contract, + expanded, workflow, task, isV2, identity, contract, verify, auditPolicy, shell, sandboxCommand, effectiveTimeout, violations, warnings, provider, providerConfig, cwd, env, }; @@ -379,7 +468,7 @@ function executeDelegated(common, options) { function executeV1(common, { dryRun }) { const { - workflow, task, identity, contract, auditPolicy, shell, sandboxCommand, + workflow, task, identity, contract, verify, auditPolicy, shell, sandboxCommand, effectiveTimeout, warnings, provider, providerConfig, cwd, env, } = common; @@ -563,9 +652,32 @@ function executeV1(common, { dryRun }) { structured_present: structured != null, }; + // ------------------------------------------------------------------ + // Post-execution verify phase + // ------------------------------------------------------------------ + + let verifyResult = null; + let verifyFailed = false; + if (verify && exitCode === 0 && !dryRun) { + 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; + } + } + } + + const effectiveOk = exitCode === 0 && !verifyFailed; + const shouldAudit = auditPolicy === 'always' || - (auditPolicy === 'on-failure' && exitCode !== 0); + (auditPolicy === 'on-failure' && !effectiveOk); if (shouldAudit) { const record = { @@ -587,6 +699,7 @@ function executeV1(common, { dryRun }) { signer: provider.name, attestation, attestation_note, + verify: verifyResult, warnings, dry_run: false, result: auditResult, @@ -595,8 +708,18 @@ function executeV1(common, { dryRun }) { writeAuditRecord(record, { auditPath: paths.audit }); } + 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}`), + { code: 'verify_failed', verify: verifyResult } + ); + } + return { - ok: exitCode === 0, + ok: effectiveOk, execution_id: executionId, source: { workflow_id: workflow.id, task_id: task.id }, declared_identity: declaredIdentity, @@ -605,6 +728,7 @@ function executeV1(common, { dryRun }) { principal_used: principal, contract, result, + verify: verifyResult, trust: trustInfo, signer: provider.name, attestation: attestation ? { method: attestation.method, key_fingerprint: attestation.key_fingerprint } : null, @@ -629,7 +753,7 @@ async function executeV2(common, { env, }) { const { - expanded, workflow, task, identity, contract, auditPolicy, shell, sandboxCommand, + expanded, workflow, task, identity, contract, verify, auditPolicy, shell, sandboxCommand, effectiveTimeout, warnings, provider, providerConfig, cwd, } = common; @@ -751,6 +875,7 @@ async function executeV2(common, { profile: identityDeclaration, instanceId, scope: identityDeclaration.scope ?? null, + task_timeout_s: effectiveTimeout != null ? Math.max(1, Math.ceil(effectiveTimeout / 1000)) : null, }, { env, cwd } ), @@ -1033,6 +1158,22 @@ async function executeV2(common, { // ------------------------------------------------------------------ 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 = { @@ -1064,15 +1205,6 @@ async function executeV2(common, { writeAuditRecord(record, { auditPath: paths.audit }); } - // Phase 8: Cleanup (even on dry-run if materialization occurred) - if (materialization && materialization.cleanup_required && identityProviderInstance) { - try { - identityProviderInstance.cleanup(materialization, { env }); - } catch (cleanupErr) { - warnings.push(`Credential cleanup warning: ${cleanupErr.message}`); - } - } - return { ok: true, dry_run: true, @@ -1234,6 +1366,51 @@ async function executeV2(common, { ); } + // ------------------------------------------------------------------ + // 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; + } + } + } + + 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 // ------------------------------------------------------------------ @@ -1251,7 +1428,7 @@ async function executeV2(common, { const shouldAudit = auditPolicy === 'always' || - (auditPolicy === 'on-failure' && exitCode !== 0); + (auditPolicy === 'on-failure' && !effectiveOk); if (shouldAudit) { const record = { @@ -1274,6 +1451,7 @@ async function executeV2(common, { signer: provider.name, attestation, attestation_note, + verify: verifyResult, warnings, dry_run: false, result: auditResult, @@ -1282,16 +1460,14 @@ async function executeV2(common, { writeAuditRecord(record, { auditPath: paths.audit }); } - // ------------------------------------------------------------------ - // Phase 8: Cleanup - // ------------------------------------------------------------------ - - if (materialization && materialization.cleanup_required && identityProviderInstance) { - try { - identityProviderInstance.cleanup(materialization, { env }); - } catch (cleanupErr) { - warnings.push(`Credential cleanup warning: ${cleanupErr.message}`); - } + 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}`), + { code: 'verify_failed', verify: verifyResult } + ); } // ------------------------------------------------------------------ @@ -1299,7 +1475,7 @@ async function executeV2(common, { // ------------------------------------------------------------------ return { - ok: exitCode === 0, + ok: effectiveOk, execution_id: executionId, source: { workflow_id: workflow.id, task_id: task.id }, declared_identity: declaredIdentity, @@ -1308,6 +1484,7 @@ async function executeV2(common, { principal_used: principal, contract, result, + verify: verifyResult, authorization_proof: authorizationProofSummary, authorization: authorizationDecision, trust: trustInfo, diff --git a/src/identity/stripe-api-key.js b/src/identity/stripe-api-key.js index 9e005dc..c53ed7e 100644 --- a/src/identity/stripe-api-key.js +++ b/src/identity/stripe-api-key.js @@ -5,7 +5,7 @@ * commands (e.g. Vault) based on a scope-aware permission set model. * Supports two key strategies: * - precreated: Resolve existing keys (restricted or secret) by scope name. - * - dynamic: Mint restricted keys via Stripe API (not yet implemented). + * - dynamic: Mint restricted keys via the Stripe API at runtime. * * This file is self-contained with no imports from agentcli internals. * It can be copied to the scheduler's plugin directory and loaded as-is. @@ -16,6 +16,9 @@ import { execSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; +import https from 'node:https'; +import http from 'node:http'; +import { URL } from 'node:url'; // --------------------------------------------------------------------------- // Command-source cache: Map @@ -344,6 +347,275 @@ function resolveSessionPath(session, path) { return current; } +// --------------------------------------------------------------------------- +// Dynamic key minting: Stripe restricted-key API helpers +// --------------------------------------------------------------------------- + +/** + * Default Stripe API base URL. + */ +const DEFAULT_API_BASE = 'https://api.stripe.com'; + +/** + * Default buffer (in seconds) added to task timeout when computing key expiry. + */ +const DEFAULT_EXPIRY_BUFFER_S = 300; + +/** + * Default scope-to-permission mapping for Stripe restricted keys. + * + * Each scope name maps to an object of Stripe resource permissions. + * The keys follow the Stripe restricted key permission format: + * permissions[][] = "read" | "write" | "none" + * + * These mappings produce the least-privilege set for common scope names. + * Operators can override this via config.scope_permissions. + */ +const DEFAULT_SCOPE_PERMISSIONS = { + full: { + 'charges': 'write', + 'customers': 'write', + 'payment_intents': 'write', + 'subscriptions': 'write', + 'invoices': 'write', + 'refunds': 'write', + 'balance': 'read', + 'events': 'read', + }, + payments: { + 'charges': 'write', + 'payment_intents': 'write', + 'refunds': 'write', + 'customers': 'read', + }, + readonly: { + 'charges': 'read', + 'customers': 'read', + 'payment_intents': 'read', + 'subscriptions': 'read', + 'invoices': 'read', + 'balance': 'read', + 'events': 'read', + }, +}; + +/** + * Encode an object of scope permissions into x-www-form-urlencoded body params + * for the Stripe restricted key API. + * + * Input: { charges: 'write', customers: 'read' } + * Output: 'permissions[charges][write]=true&permissions[customers][read]=true' + * + * @param {object} permissions - Map of resource to permission level. + * @returns {string} URL-encoded body string. + */ +function encodePermissionsBody(permissions) { + const parts = []; + for (const [resource, level] of Object.entries(permissions)) { + parts.push(`permissions[${encodeURIComponent(resource)}][${encodeURIComponent(level)}]=true`); + } + return parts.join('&'); +} + +/** + * Resolve the permission set for a scope, checking config overrides first, + * then falling back to DEFAULT_SCOPE_PERMISSIONS. + * + * @param {string} scope - The scope name. + * @param {object} config - Provider config. + * @returns {object|null} Permission map, or null if no mapping exists. + */ +function resolvePermissionsForScope(scope, config) { + if (config.scope_permissions && config.scope_permissions[scope]) { + return config.scope_permissions[scope]; + } + return DEFAULT_SCOPE_PERMISSIONS[scope] || null; +} + +/** + * Make an HTTPS (or HTTP for testing) request and return the parsed JSON response. + * + * @param {object} opts + * @param {string} opts.method - HTTP method. + * @param {string} opts.url - Full URL string. + * @param {object} opts.headers - Request headers. + * @param {string} [opts.body] - Request body. + * @param {number} [opts.timeout] - Request timeout in ms (default 30000). + * @returns {Promise<{ statusCode: number, body: object }>} + */ +function stripeRequest(opts) { + return new Promise((resolve, reject) => { + const parsed = new URL(opts.url); + const transport = parsed.protocol === 'http:' ? http : https; + + const reqOpts = { + method: opts.method, + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'http:' ? 80 : 443), + path: parsed.pathname + parsed.search, + headers: { + ...opts.headers, + }, + timeout: opts.timeout || 30000, + }; + + if (opts.body) { + reqOpts.headers['Content-Length'] = Buffer.byteLength(opts.body, 'utf8'); + } + + const req = transport.request(reqOpts, (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + let body; + try { + body = JSON.parse(raw); + } catch (_parseErr) { + body = { raw_body: raw }; + } + resolve({ statusCode: res.statusCode, body }); + }); + }); + + req.on('error', (err) => reject(err)); + req.on('timeout', () => { + req.destroy(); + reject(new Error('Stripe API request timed out')); + }); + + if (opts.body) { + req.write(opts.body); + } + req.end(); + }); +} + +/** + * Create a restricted API key via the Stripe API. + * + * POST /v1/api_keys + * Authorization: Bearer + * Content-Type: application/x-www-form-urlencoded + * + * Expected response (success): + * { "id": "rk_...", "object": "api_key", "secret": "rk_test_...", ... } + * + * @param {string} masterKey - The secret key with permission to create restricted keys. + * @param {object} permissions - Resource-to-level permission map. + * @param {string} apiBase - Stripe API base URL. + * @returns {Promise<{ ok: boolean, key_id?: string, key_secret?: string, error?: string, transient?: boolean }>} + */ +async function createRestrictedKey(masterKey, permissions, apiBase) { + const body = encodePermissionsBody(permissions); + const url = `${apiBase}/v1/api_keys`; + + try { + const res = await stripeRequest({ + method: 'POST', + url, + headers: { + 'Authorization': `Bearer ${masterKey}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + }); + + if (res.statusCode >= 200 && res.statusCode < 300) { + const keyId = res.body.id; + const keySecret = res.body.secret || res.body.key; + if (!keyId || !keySecret) { + return { + ok: false, + transient: false, + error: `Stripe API returned success but missing id or secret in response: ${JSON.stringify(res.body)}`, + }; + } + 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}`, + }; + } catch (err) { + return { + ok: false, + transient: true, + error: `Stripe API request failed: ${err.message}`, + }; + } +} + +/** + * Delete a restricted API key via the Stripe API. + * + * DELETE /v1/api_keys/{key_id} + * Authorization: Bearer + * + * @param {string} masterKey - The secret key with permission to manage restricted keys. + * @param {string} keyId - The restricted key ID to delete. + * @param {string} apiBase - Stripe API base URL. + * @returns {Promise<{ ok: boolean, error?: string }>} + */ +async function deleteRestrictedKey(masterKey, keyId, apiBase) { + const url = `${apiBase}/v1/api_keys/${encodeURIComponent(keyId)}`; + + try { + const res = await stripeRequest({ + method: 'DELETE', + url, + headers: { + 'Authorization': `Bearer ${masterKey}`, + }, + }); + + if (res.statusCode >= 200 && res.statusCode < 300) { + 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}` }; + } +} + +/** + * Resolve the master key from the configured source (env, file, or command). + * + * @param {object} masterKeySource - { env?, file?, command? } + * @param {object} env - Environment object. + * @param {string} cwd - Working directory. + * @returns {{ ok: boolean, value?: string, error?: string, transient?: boolean }} + */ +function resolveMasterKey(masterKeySource, env, cwd) { + if (!masterKeySource || typeof masterKeySource !== 'object') { + return { ok: false, transient: false, error: 'master_key_source is missing or not an object' }; + } + if (typeof masterKeySource.env === 'string' && masterKeySource.env.length > 0) { + return resolveEnvSource(masterKeySource.env, env); + } + if (typeof masterKeySource.file === 'string' && masterKeySource.file.length > 0) { + return resolveFileSource(masterKeySource.file); + } + if (typeof masterKeySource.command === 'string' && masterKeySource.command.length > 0) { + return resolveCommandSource(masterKeySource.command, 60000, { cwd, env }); + } + return { + ok: false, + transient: false, + error: 'master_key_source has no valid source (env, file, or command)', + }; +} + // --------------------------------------------------------------------------- // Provider implementation // --------------------------------------------------------------------------- @@ -465,6 +737,38 @@ const stripeApiKeyProvider = { errors.push('master_key_source must declare at least one source: env, file, or command'); } } + + // api_base: optional, must be a valid URL if present + if (config.api_base !== undefined && config.api_base !== null) { + if (typeof config.api_base !== 'string' || config.api_base.length === 0) { + errors.push('provider_config.api_base must be a non-empty string URL'); + } else { + try { + const parsed = new URL(config.api_base); + 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) { + errors.push( + 'provider_config.api_base using http: is only allowed for localhost or when provider_config.allow_insecure_http is true' + ); + } + } 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}`); + } + } + } + + // default_expiry_buffer_s: optional, must be a positive number if present + if (config.default_expiry_buffer_s !== undefined && config.default_expiry_buffer_s !== null) { + if (typeof config.default_expiry_buffer_s !== 'number' || config.default_expiry_buffer_s <= 0) { + errors.push('provider_config.default_expiry_buffer_s must be a positive number'); + } + } } // cache_ttl_s: optional, must be a positive number if present @@ -488,7 +792,8 @@ const stripeApiKeyProvider = { * For precreated strategy: resolves the key from the permission set entry * matching request.scope (env var, file, or shell command). * - * For dynamic strategy: not yet implemented; returns a permanent error. + * For dynamic strategy: resolves master key, mints a restricted key via + * POST /v1/api_keys, and returns a session with stripe_key_id and expiry. * * @param {object} request - Session request: { profile, instanceId, scope }. * @param {object} [ctx] - Resolution context: { env, cwd }. @@ -503,11 +808,7 @@ const stripeApiKeyProvider = { const trustLevel = (profile.trust && profile.trust.level) || 'supervised'; if (config.key_strategy === 'dynamic') { - return { - ok: false, - transient: false, - error: 'Dynamic key strategy not yet implemented', - }; + return this._resolveDynamicSession(request, config, env, cwd, trustLevel); } // Precreated strategy @@ -618,6 +919,122 @@ const stripeApiKeyProvider = { return { ok: true, session }; }, + /** + * Internal: resolve a dynamic session by minting a restricted key via Stripe API. + * + * @param {object} request - Session request. + * @param {object} config - Provider config. + * @param {object} env - Environment object. + * @param {string} cwd - Working directory. + * @param {string} trustLevel - Effective trust level. + * @returns {Promise<{ ok: boolean, session?: object, transient?: boolean, error?: string }>} + */ + async _resolveDynamicSession(request, config, env, cwd, trustLevel) { + const scope = request.scope || 'full'; + 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) + ? config.default_expiry_buffer_s + : DEFAULT_EXPIRY_BUFFER_S; + + // Resolve the master key + const masterKeyResult = resolveMasterKey(config.master_key_source, env, cwd); + if (!masterKeyResult.ok) { + return { + ok: false, + transient: masterKeyResult.transient, + error: `Failed to resolve master key: ${masterKeyResult.error}`, + }; + } + + // Validate master key format + const masterKeyCheck = validateKeyFormat(masterKeyResult.value, accountMode); + if (!masterKeyCheck.valid) { + return { + ok: false, + transient: false, + error: `Master key format invalid: ${masterKeyCheck.error}`, + }; + } + + // Resolve permissions for the requested scope + const permissions = resolvePermissionsForScope(scope, config); + if (!permissions || Object.keys(permissions).length === 0) { + return { + ok: false, + transient: false, + error: `No permission mapping found for scope "${scope}". Define scope_permissions["${scope}"] in provider_config or use a built-in scope (full, payments, readonly).`, + }; + } + + // Create the restricted key via Stripe API + const createResult = await createRestrictedKey(masterKeyResult.value, permissions, apiBase); + if (!createResult.ok) { + return { + ok: false, + transient: createResult.transient, + error: createResult.error, + }; + } + + // Compute expiry: task timeout (if known) + buffer, or just buffer from now + const taskTimeoutS = (request.task_timeout_s && typeof request.task_timeout_s === 'number') + ? request.task_timeout_s + : 0; + const expiresAt = new Date(Date.now() + ((taskTimeoutS + expiryBufferS) * 1000)).toISOString(); + + const session = { + provider: 'stripe-api-key', + subject: { + kind: 'service', + principal: `stripe:${accountMode}`, + }, + instance: request.instanceId ? { id: request.instanceId, source: 'operator' } : null, + trust: { + declared_level: trustLevel, + effective_level: trustLevel, + }, + credentials: { + api_key: { + kind: 'bearer', + value: createResult.key_secret, + scope, + }, + }, + provider_assertions: { + key_strategy: 'dynamic', + account_mode: accountMode, + scope, + stripe_key_id: createResult.key_id, + api_base: apiBase, + }, + delegation_chain: [ + { + kind: 'service', + principal: `stripe:${accountMode}`, + grant: `scope:${scope}`, + validated: true, + }, + ], + delegation_validation: { + valid: true, + depth: 1, + acyclic: true, + escalation_detected: false, + }, + refresh: { + supported: false, + expires_at: expiresAt, + }, + handoff: { + mode: config.scope_hierarchy ? 'downscope' : 'none', + prepared: false, + }, + }; + + return { ok: true, session }; + }, + /** * Materialize session credentials into environment variables for * subprocess injection. @@ -651,26 +1068,74 @@ const stripeApiKeyProvider = { } } - return { + const result = { materialized: true, env_vars: envVars, cleanup_required: config.key_strategy === 'dynamic', }; + + // 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; + } + + return result; }, /** * Clean up materialized credentials. * * For precreated strategy: no-op (keys are long-lived). - * For dynamic strategy: placeholder for future key revocation. + * For dynamic strategy: revokes the minted restricted key via Stripe API. + * Revocation is best-effort: failures are reported as warnings but + * do not cause the cleanup to fail overall. * - * @param {object} _materialization - The materialization result. - * @param {object} _ctx - Resolution context. - * @returns {{ cleaned: boolean, warnings?: string[] }} + * @param {object} materialization - The materialization result (includes session reference). + * @param {object} ctx - Resolution context: { session, env, cwd }. + * @returns {Promise<{ cleaned: boolean, warnings?: string[] }>|{ cleaned: boolean, warnings?: string[] }} */ - cleanup(_materialization, _ctx) { - // Precreated keys need no cleanup -- they are long-lived and not minted per-job - return { cleaned: true }; + cleanup(materialization, ctx) { + const session = (ctx && ctx.session) || (materialization && materialization.session) || null; + if (!session) { + return { 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 }; + } + + const keyId = assertions.stripe_key_id; + if (!keyId) { + return { 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 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); + if (!masterKeyResult.ok) { + return { + cleaned: true, + warnings: [`Could not resolve master key for cleanup: ${masterKeyResult.error}`], + }; + } + + // Async deletion, best-effort + return deleteRestrictedKey(masterKeyResult.value, keyId, apiBase).then((delResult) => { + if (!delResult.ok) { + return { cleaned: true, warnings: [delResult.error] }; + } + return { cleaned: true }; + }).catch((err) => { + return { cleaned: true, warnings: [`Key revocation failed: ${err.message}`] }; + }); }, /** @@ -703,10 +1168,6 @@ const stripeApiKeyProvider = { const config = (parentProfile.auth && parentProfile.auth.provider_config) || {}; - if (config.key_strategy === 'dynamic') { - return { prepared: false, error: 'Dynamic key strategy not yet implemented for handoff' }; - } - // Determine parent scope from the current session const parentScope = (session.credentials && session.credentials.api_key && session.credentials.api_key.scope) || null; @@ -724,7 +1185,11 @@ const stripeApiKeyProvider = { }; } - // Look up the target scope's permission set + if (config.key_strategy === 'dynamic') { + return this._prepareDynamicHandoff(session, targetScope, parentScope, config, env, cwd); + } + + // Look up the target scope's permission set (precreated strategy) const permSets = config.permission_sets || {}; const targetPermSet = permSets[targetScope]; if (!targetPermSet) { @@ -817,6 +1282,103 @@ const stripeApiKeyProvider = { return { prepared: true, session: childSession }; }, + /** + * Internal: prepare a dynamic handoff by minting a new restricted key + * with narrower permissions for the target scope. + * + * @param {object} session - Parent session. + * @param {string} targetScope - Target scope for the child. + * @param {string} parentScope - Parent's current scope. + * @param {object} config - Provider config from parent profile. + * @param {object} env - Environment object. + * @param {string} cwd - Working directory. + * @returns {Promise<{ prepared: boolean, session?: object, error?: string }>} + */ + async _prepareDynamicHandoff(session, targetScope, parentScope, config, env, cwd) { + 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) + ? config.default_expiry_buffer_s + : DEFAULT_EXPIRY_BUFFER_S; + + // Resolve the master key for minting the child key + const masterKeyResult = resolveMasterKey(config.master_key_source, env, cwd); + if (!masterKeyResult.ok) { + return { prepared: false, error: `Failed to resolve master key for handoff: ${masterKeyResult.error}` }; + } + + // Resolve narrower permissions for the target scope + const permissions = resolvePermissionsForScope(targetScope, config); + if (!permissions || Object.keys(permissions).length === 0) { + return { + prepared: false, + error: `No permission mapping for handoff scope "${targetScope}". Define scope_permissions["${targetScope}"] in provider_config.`, + }; + } + + // Mint a new restricted key with the narrower permissions + const createResult = await createRestrictedKey(masterKeyResult.value, permissions, apiBase); + if (!createResult.ok) { + return { prepared: false, error: createResult.error }; + } + + const expiresAt = new Date(Date.now() + (expiryBufferS * 1000)).toISOString(); + const trustLevel = session.trust ? session.trust.effective_level : 'supervised'; + + const childSession = { + provider: 'stripe-api-key', + subject: { + kind: 'service', + principal: `stripe:${accountMode}`, + }, + instance: session.instance, + trust: { + declared_level: trustLevel, + effective_level: trustLevel, + }, + credentials: { + api_key: { + kind: 'bearer', + value: createResult.key_secret, + scope: targetScope, + }, + }, + provider_assertions: { + key_strategy: 'dynamic', + account_mode: accountMode, + scope: targetScope, + parent_scope: parentScope, + stripe_key_id: createResult.key_id, + api_base: apiBase, + }, + delegation_chain: [ + ...(session.delegation_chain || []), + { + kind: 'service', + principal: `stripe:${accountMode}`, + grant: `downscope:${parentScope}->${targetScope}`, + validated: true, + }, + ], + delegation_validation: { + valid: true, + depth: (session.delegation_chain || []).length + 1, + acyclic: true, + escalation_detected: false, + }, + refresh: { + supported: false, + expires_at: expiresAt, + }, + handoff: { + mode: 'downscope', + prepared: true, + }, + }; + + return { prepared: true, session: childSession }; + }, + /** * Validate a delegation chain for scope escalation and depth. * @@ -913,3 +1475,16 @@ const stripeApiKeyProvider = { export default stripeApiKeyProvider; export { stripeApiKeyProvider }; + +// Exported for unit testing of internal helpers +export { + encodePermissionsBody, + resolvePermissionsForScope, + stripeRequest, + createRestrictedKey, + deleteRestrictedKey, + resolveMasterKey, + DEFAULT_API_BASE, + DEFAULT_EXPIRY_BUFFER_S, + DEFAULT_SCOPE_PERMISSIONS, +}; diff --git a/src/runtime/openclaw-scheduler.js b/src/runtime/openclaw-scheduler.js index df45fa3..5311fb9 100644 --- a/src/runtime/openclaw-scheduler.js +++ b/src/runtime/openclaw-scheduler.js @@ -112,7 +112,7 @@ export const schedulerAdapter = { const runtimeCaps = querySchedulerCapabilities(runner); const effectiveResult = resolveEffectiveFeatures('openclaw-scheduler', runtimeCaps); const handoffVersion = effectiveResult.handoff_version || '1'; - const capabilityErrors = validateManifestCapabilities({ jobs: [jobSpec] }, effectiveResult); + const { errors: capabilityErrors } = validateManifestCapabilities({ jobs: [jobSpec] }, effectiveResult); if (capabilityErrors.length > 0) { throw Object.assign( new Error(capabilityErrors.map(error => error.message).join('; ')), diff --git a/src/scheduler-fields.js b/src/scheduler-fields.js index 7cc2032..e129452 100644 --- a/src/scheduler-fields.js +++ b/src/scheduler-fields.js @@ -34,6 +34,9 @@ export const SCHEDULER_FIELDS_V02 = [ 'contract_sandbox', 'contract_allowed_paths', 'contract_network', 'contract_max_cost_usd', 'contract_audit', 'child_credential_policy', + 'verify_shell', + 'verify_timeout_s', + 'verify_on_failure', ]; export const SCHEDULER_FIELD_VERSIONS = { diff --git a/src/schema.js b/src/schema.js index 35f0181..41ea847 100644 --- a/src/schema.js +++ b/src/schema.js @@ -163,6 +163,17 @@ const shellField = { } }; +const verifyField = { + type: 'object', + nullable: true, + required: ['shell'], + fields: { + shell: { type: 'string', note: 'Shell command to run for post-completion verification' }, + timeout_seconds: { type: 'integer', min: 1, nullable: true, note: 'Max time for verify command (default: 30)' }, + on_failure: { type: 'string', enum: ['error', 'warn'], nullable: true, note: 'Behavior when verify fails (default: error)' } + } +}; + const onFailureField = { type: 'object', nullable: true, @@ -714,6 +725,7 @@ MANIFEST_SCHEMA.workflow = { authorization: authorizationRefField, evidence: evidenceRefField, child_credential_policy: childCredentialPolicyField, + verify: verifyField, tasks: { type: 'array', minItems: 1, items: { type: 'object' } } } }; @@ -768,6 +780,7 @@ MANIFEST_SCHEMA.task = { authorization: authorizationRefField, evidence: evidenceRefField, child_credential_policy: childCredentialPolicyField, + verify: verifyField, on_failure: onFailureField, delete_after_run: nullableBoolean } @@ -800,5 +813,8 @@ Object.assign(MANIFEST_SCHEMA.schedulerJob.fields, { contract_required_trust_level: nullableString, contract_trust_enforcement: nullableString, child_credential_policy: childCredentialPolicyField, - authorization_proof_verification: { type: 'object', nullable: true } + authorization_proof_verification: { type: 'object', nullable: true }, + verify_shell: nullableString, + verify_timeout_s: { type: 'integer', nullable: true, min: 1 }, + verify_on_failure: nullableString, }); diff --git a/src/validate.js b/src/validate.js index 351aa11..425700c 100644 --- a/src/validate.js +++ b/src/validate.js @@ -16,7 +16,8 @@ const KNOWN_MANIFEST_KEYS = new Set([ const KNOWN_WORKFLOW_KEYS = new Set([ 'id', 'name', 'model_policy', 'identity', 'contract', 'tasks', - 'authorization_proof', 'authorization', 'evidence', 'child_credential_policy' + 'authorization_proof', 'authorization', 'evidence', 'child_credential_policy', + 'verify' ]); const KNOWN_TASK_KEYS = new Set([ @@ -24,7 +25,8 @@ const KNOWN_TASK_KEYS = new Set([ 'model_policy', 'intent', 'output', 'budgets', 'schedule', 'trigger', 'delivery', 'reliability', 'runtime', 'approval', 'context', 'session', 'identity', 'contract', 'on_failure', 'delete_after_run', - 'authorization_proof', 'authorization', 'evidence', 'child_credential_policy' + 'authorization_proof', 'authorization', 'evidence', 'child_credential_policy', + 'verify' ]); const KNOWN_ON_FAILURE_KEYS = new Set([ @@ -610,6 +612,24 @@ function validateOptionalBlocks(errors, warnings, path, value) { checkBoolean(errors, `${path}.delete_after_run`, value.delete_after_run); } +function validateVerify(errors, path, value) { + if (!isObject(value)) { + addError(errors, path, 'must be an object'); + return; + } + if (value.shell == null || typeof value.shell !== 'string' || value.shell.trim() === '') { + addError(errors, `${path}.shell`, 'is required and must be a non-empty string'); + } else if (hasUnsupportedControlChars(value.shell)) { + addError(errors, `${path}.shell`, 'contains unsupported control characters'); + } + if (value.timeout_seconds != null) { + if (!Number.isInteger(value.timeout_seconds) || value.timeout_seconds < 1) { + addError(errors, `${path}.timeout_seconds`, 'must be an integer >= 1'); + } + } + checkEnum(errors, `${path}.on_failure`, value.on_failure, ['error', 'warn']); +} + function validateOnFailure(errors, warnings, path, task) { if (task.on_failure == null) return; if (!isObject(task.on_failure)) { @@ -846,6 +866,9 @@ export function validateManifest(manifest) { validateEvidenceRef(errors, `${workflowPath}.evidence`, workflow.evidence); } validateChildCredentialPolicy(errors, `${workflowPath}.child_credential_policy`, workflow.child_credential_policy); + if (checkOptionalObject(errors, `${workflowPath}.verify`, workflow.verify)) { + validateVerify(errors, `${workflowPath}.verify`, workflow.verify); + } if (workflow.id) { if (workflowIds.has(workflow.id)) addError(errors, `${workflowPath}.id`, 'must be unique'); workflowIds.add(workflow.id); @@ -912,6 +935,9 @@ export function validateManifest(manifest) { validateOptionalBlocks(errors, warnings, taskPath, task); validateChildCredentialPolicy(errors, `${taskPath}.child_credential_policy`, task.child_credential_policy); + if (checkOptionalObject(errors, `${taskPath}.verify`, task.verify)) { + validateVerify(errors, `${taskPath}.verify`, task.verify); + } if (task.target?.session_target === 'shell' && (task.intent?.mode === 'plan' || task.intent?.read_only)) { warnings.push({ path: `${taskPath}.intent`, diff --git a/test/agentcli.test.js b/test/agentcli.test.js index 28fa713..f3289a6 100644 --- a/test/agentcli.test.js +++ b/test/agentcli.test.js @@ -4,6 +4,7 @@ import { spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { delimiter, join } from 'node:path'; import { tmpdir } from 'node:os'; +import { createServer } from 'node:http'; import { PassThrough } from 'node:stream'; import { DatabaseSync } from 'node:sqlite'; import { @@ -26,7 +27,7 @@ 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 } from '../src/compiler/shared.js'; +import { 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'; @@ -7231,7 +7232,7 @@ test('validateManifestCapabilities detects authorization_hook mismatch', () => { ] }; const features = { authorization_hook: false, trust_evaluation: true, evidence_generation: true }; - const errors = validateManifestCapabilities(compiled, { features }); + const { errors } = validateManifestCapabilities(compiled, { features }); assert.strictEqual(errors.length, 1); assert.strictEqual(errors[0].feature, 'authorization_hook'); assert.ok(errors[0].message.includes('Job One')); @@ -7244,7 +7245,7 @@ test('validateManifestCapabilities detects trust_evaluation mismatch', () => { ] }; const features = { authorization_hook: true, trust_evaluation: false, evidence_generation: true }; - const errors = validateManifestCapabilities(compiled, { features }); + const { errors } = validateManifestCapabilities(compiled, { features }); assert.strictEqual(errors.length, 1); assert.strictEqual(errors[0].feature, 'trust_evaluation'); assert.ok(errors[0].message.includes('Trust Job')); @@ -7257,7 +7258,7 @@ test('validateManifestCapabilities detects evidence_generation mismatch', () => ] }; const features = { authorization_hook: true, trust_evaluation: true, evidence_generation: false }; - const errors = validateManifestCapabilities(compiled, { features }); + const { errors } = validateManifestCapabilities(compiled, { features }); assert.strictEqual(errors.length, 1); assert.strictEqual(errors[0].feature, 'evidence_generation'); assert.ok(errors[0].message.includes('Evidence Job')); @@ -7283,7 +7284,7 @@ test('validateManifestCapabilities detects credential_handoff mismatch', () => { evidence_generation: true, credential_handoff: false, }; - const errors = validateManifestCapabilities(compiled, { features }); + const { errors } = validateManifestCapabilities(compiled, { features }); assert.strictEqual(errors.length, 1); assert.strictEqual(errors[0].feature, 'credential_handoff'); assert.ok(errors[0].message.includes('Handoff Job')); @@ -7302,7 +7303,7 @@ test('validateManifestCapabilities returns no errors when all features satisfied ] }; const features = { authorization_hook: true, trust_evaluation: true, evidence_generation: true }; - const errors = validateManifestCapabilities(compiled, { features }); + const { errors } = validateManifestCapabilities(compiled, { features }); assert.strictEqual(errors.length, 0); }); @@ -7313,15 +7314,146 @@ test('validateManifestCapabilities returns no errors for jobs without v0.2 featu ] }; const features = { authorization_hook: false, trust_evaluation: false, evidence_generation: false }; - const errors = validateManifestCapabilities(compiled, { features }); + const { errors } = validateManifestCapabilities(compiled, { features }); assert.strictEqual(errors.length, 0); }); test('validateManifestCapabilities handles null/empty compiled output', () => { const features = { authorization_hook: false }; - assert.strictEqual(validateManifestCapabilities(null, { features }).length, 0); - assert.strictEqual(validateManifestCapabilities({}, { features }).length, 0); - assert.strictEqual(validateManifestCapabilities({ jobs: [] }, { features }).length, 0); + assert.strictEqual(validateManifestCapabilities(null, { features }).errors.length, 0); + assert.strictEqual(validateManifestCapabilities({}, { features }).errors.length, 0); + assert.strictEqual(validateManifestCapabilities({ jobs: [] }, { features }).errors.length, 0); +}); + +test('validateManifestCapabilities warns when identity provider requires runtime_identity_resolution', () => { + const compiled = { + jobs: [ + { + id: 'j1', + name: 'Stripe Job', + identity: { + provider: 'stripe-api-key', + subject: { kind: 'service' }, + }, + }, + ] + }; + const features = { + authorization_hook: true, + trust_evaluation: true, + evidence_generation: true, + runtime_identity_resolution: false, + }; + const { errors, warnings } = validateManifestCapabilities(compiled, { features }); + assert.strictEqual(errors.length, 0); + assert.strictEqual(warnings.length, 1); + assert.strictEqual(warnings[0].code, 'capability_warning'); + assert.strictEqual(warnings[0].feature, 'runtime_identity_resolution'); + assert.ok(warnings[0].message.includes('Stripe Job')); + assert.ok(warnings[0].message.includes('stripe-api-key')); +}); + +test('validateManifestCapabilities does not warn for identity provider "none"', () => { + const compiled = { + jobs: [ + { + id: 'j1', + name: 'None Provider Job', + identity: { + provider: 'none', + subject: { kind: 'service' }, + }, + }, + ] + }; + const features = { + runtime_identity_resolution: false, + }; + const { errors, warnings } = validateManifestCapabilities(compiled, { features }); + assert.strictEqual(errors.length, 0); + assert.strictEqual(warnings.length, 0); +}); + +test('validateManifestCapabilities does not warn when runtime_identity_resolution is true', () => { + const compiled = { + jobs: [ + { + id: 'j1', + name: 'Resolved Job', + identity: { + provider: 'gcp-workload-identity', + subject: { kind: 'service' }, + }, + }, + ] + }; + const features = { + runtime_identity_resolution: true, + }; + const { errors, warnings } = validateManifestCapabilities(compiled, { features }); + assert.strictEqual(errors.length, 0); + assert.strictEqual(warnings.length, 0); +}); + +test('validateManifestCapabilities warns when child_credential_policy is downscope without credential_handoff', () => { + const compiled = { + jobs: [ + { + id: 'j1', + name: 'Downscope Job', + child_credential_policy: 'downscope', + }, + ] + }; + const features = { + authorization_hook: true, + trust_evaluation: true, + evidence_generation: true, + credential_handoff: false, + }; + const { errors, warnings } = validateManifestCapabilities(compiled, { features }); + assert.strictEqual(errors.length, 0); + assert.strictEqual(warnings.length, 1); + assert.strictEqual(warnings[0].code, 'capability_warning'); + assert.strictEqual(warnings[0].feature, 'credential_handoff'); + assert.ok(warnings[0].message.includes('Downscope Job')); + assert.ok(warnings[0].message.includes('downscope')); +}); + +test('validateManifestCapabilities does not warn for child_credential_policy "inherit"', () => { + const compiled = { + jobs: [ + { + id: 'j1', + name: 'Inherit Job', + child_credential_policy: 'inherit', + }, + ] + }; + const features = { + credential_handoff: false, + }; + const { errors, warnings } = validateManifestCapabilities(compiled, { features }); + assert.strictEqual(errors.length, 0); + assert.strictEqual(warnings.length, 0); +}); + +test('validateManifestCapabilities does not warn for downscope when credential_handoff is true', () => { + const compiled = { + jobs: [ + { + id: 'j1', + name: 'Supported Downscope Job', + child_credential_policy: 'downscope', + }, + ] + }; + const features = { + credential_handoff: true, + }; + const { errors, warnings } = validateManifestCapabilities(compiled, { features }); + assert.strictEqual(errors.length, 0); + assert.strictEqual(warnings.length, 0); }); test('applyManifestToScheduler rejects unsupported trust and evidence capabilities before writing', async () => { @@ -7445,6 +7577,78 @@ test('applyManifestToScheduler includes capabilities metadata in result', async assert.strictEqual(result.capabilities.handoff_version, '2'); }); +test('applyManifestToScheduler preserves capability warnings in structured result', async () => { + const manifest = { + version: '0.2', + workflows: [{ + id: 'warning-wf', + name: 'Warning Workflow', + tasks: [{ + id: 'child-task', + name: 'Downscope Child', + prompt: 'exercise capability warning path', + target: { session_target: 'isolated' }, + schedule: { cron: '0 * * * *' }, + delivery: { mode: 'none' }, + child_credential_policy: 'downscope', + }] + }] + }; + const runner = { + invocation: { label: 'fake-scheduler' }, + queryCapabilities() { + return { + scheduler_version: '0.2.0', + schema_version: 22, + handoff_version: '2', + features: { + approvals: 'runtime', + runtime_execution: true, + identity_declaration: true, + runtime_identity_resolution: true, + trust_evaluation: true, + authorization_proof_verification: true, + authorization_hook: true, + evidence_generation: true, + delegation_validation: false, + credential_handoff: false, + audit_export: true + } + }; + }, + listJobs() { + return []; + }, + addJob(spec) { + return { ok: true, job: spec }; + }, + updateJob(id, spec) { + return { ok: true, job: spec }; + } + }; + + const stderrWrites = []; + const originalStderrWrite = process.stderr.write; + process.stderr.write = (chunk, ...args) => { + stderrWrites.push(String(chunk)); + const cb = args.find(arg => typeof arg === 'function'); + if (cb) cb(); + return true; + }; + + try { + const result = await applyManifestToScheduler(manifest, { runner }); + assert.strictEqual(result.ok, true); + assert.ok(Array.isArray(result.capabilities?.warnings)); + assert.strictEqual(result.capabilities.warnings.length, 1); + assert.strictEqual(result.capabilities.warnings[0].feature, 'credential_handoff'); + assert.ok(result.capabilities.warnings[0].message.includes('child_credential_policy="downscope"')); + assert.ok(stderrWrites.some(line => line.includes('child_credential_policy="downscope"'))); + } finally { + process.stderr.write = originalStderrWrite; + } +}); + test('applyManifestToScheduler skips runtime capability queries for pure v0.1 manifests', async () => { let capabilityCalls = 0; const runner = { @@ -7509,7 +7713,7 @@ test('SCHEDULER_FIELD_VERSIONS v1 matches original 40-field list', () => { assert.ok(!v1.includes('evidence')); }); -test('SCHEDULER_FIELD_VERSIONS v2 includes v1 plus 22 v0.2 fields', () => { +test('SCHEDULER_FIELD_VERSIONS v2 includes v1 plus 25 v0.2 fields', () => { const v2 = SCHEDULER_FIELD_VERSIONS['2']; assert.ok(Array.isArray(v2)); assert.strictEqual(v2.length, SCHEDULER_FIELDS_V1.length + SCHEDULER_FIELDS_V02.length); @@ -9006,7 +9210,16 @@ test('child_credential_policy: field is in SCHEDULER_FIELDS_V02', () => { // stripe-api-key identity provider // --------------------------------------------------------------------------- -import stripeApiKeyProvider from '../src/identity/stripe-api-key.js'; +import stripeApiKeyProvider, { + encodePermissionsBody, + resolvePermissionsForScope, + createRestrictedKey, + deleteRestrictedKey, + resolveMasterKey, + DEFAULT_API_BASE, + DEFAULT_EXPIRY_BUFFER_S, + DEFAULT_SCOPE_PERMISSIONS, +} from '../src/identity/stripe-api-key.js'; const validPrecreatedProfile = { auth: { @@ -9251,7 +9464,7 @@ test('stripe-api-key resolveSession: rejects key mode mismatch (live key with te assert.ok(/mode mismatch/.test(result.error)); }); -test('stripe-api-key resolveSession: dynamic strategy returns not-implemented error', () => { +test('stripe-api-key resolveSession: dynamic strategy without master key env returns error', async () => { const dynamicProfile = { auth: { provider_config: { @@ -9263,9 +9476,9 @@ test('stripe-api-key resolveSession: dynamic strategy returns not-implemented er trust: { level: 'supervised' }, }; const request = { profile: dynamicProfile, instanceId: 'test-5' }; - const result = stripeApiKeyProvider.resolveSession(request, {}); + const result = await stripeApiKeyProvider.resolveSession(request, { env: {}, cwd: '/tmp' }); assert.strictEqual(result.ok, false); - assert.ok(/not yet implemented/.test(result.error)); + assert.ok(/master key/.test(result.error) || /STRIPE_MASTER_KEY/.test(result.error)); }); test('stripe-api-key resolveSession: key_command cache distinguishes cwd (same snippet)', () => { @@ -9699,3 +9912,1576 @@ test('v0.2 exec stripe-api-key handoff with downscope produces prepared session' assert.strictEqual(result.handoff?.prepared, true); assert.strictEqual(result.handoff?.mode, 'downscope'); }); + +// --------------------------------------------------------------------------- +// stripe-api-key: dynamic key strategy tests +// --------------------------------------------------------------------------- + +/** + * Create a mock Stripe API HTTP server for testing dynamic key minting. + * Returns { server, port, close(), requests[] }. + */ +function createMockStripeServer(handler) { + const requests = []; + const server = createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + requests.push({ method: req.method, url: req.url, headers: req.headers, body }); + handler(req, res, body); + }); + }); + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const port = server.address().port; + resolve({ + server, + port, + baseUrl: `http://127.0.0.1:${port}`, + requests, + close: () => new Promise((r) => server.close(r)), + }); + }); + }); +} + +// -- encodePermissionsBody tests -- + +test('stripe-api-key encodePermissionsBody: encodes resource permissions correctly', () => { + const result = encodePermissionsBody({ charges: 'write', customers: 'read' }); + assert.ok(result.includes('permissions[charges][write]=true')); + assert.ok(result.includes('permissions[customers][read]=true')); + assert.strictEqual(result.split('&').length, 2); +}); + +test('stripe-api-key encodePermissionsBody: empty permissions produces empty string', () => { + const result = encodePermissionsBody({}); + assert.strictEqual(result, ''); +}); + +// -- resolvePermissionsForScope tests -- + +test('stripe-api-key resolvePermissionsForScope: returns default for built-in scopes', () => { + const config = {}; + const fullPerms = resolvePermissionsForScope('full', config); + assert.ok(fullPerms); + assert.strictEqual(fullPerms.charges, 'write'); + assert.strictEqual(fullPerms.balance, 'read'); + + const readonlyPerms = resolvePermissionsForScope('readonly', config); + assert.ok(readonlyPerms); + assert.strictEqual(readonlyPerms.charges, 'read'); + + const paymentsPerms = resolvePermissionsForScope('payments', config); + assert.ok(paymentsPerms); + assert.strictEqual(paymentsPerms.payment_intents, 'write'); +}); + +test('stripe-api-key resolvePermissionsForScope: config override takes precedence', () => { + const config = { + scope_permissions: { + full: { charges: 'read' }, + }, + }; + const perms = resolvePermissionsForScope('full', config); + assert.strictEqual(perms.charges, 'read'); + assert.strictEqual(perms.balance, undefined); +}); + +test('stripe-api-key resolvePermissionsForScope: returns null for unknown scope without config', () => { + const result = resolvePermissionsForScope('custom-scope', {}); + assert.strictEqual(result, null); +}); + +// -- resolveMasterKey tests -- + +test('stripe-api-key resolveMasterKey: resolves from env', () => { + const result = resolveMasterKey({ env: 'MASTER_KEY_VAR' }, { MASTER_KEY_VAR: 'sk_test_master123' }, '/tmp'); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.value, 'sk_test_master123'); +}); + +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)); +}); + +test('stripe-api-key resolveMasterKey: returns error when no source specified', () => { + const result = resolveMasterKey({}, {}, '/tmp'); + assert.strictEqual(result.ok, false); + assert.ok(/no valid source/.test(result.error)); +}); + +// -- DEFAULT constants -- + +test('stripe-api-key DEFAULT_API_BASE is Stripe production URL', () => { + assert.strictEqual(DEFAULT_API_BASE, 'https://api.stripe.com'); +}); + +test('stripe-api-key DEFAULT_EXPIRY_BUFFER_S is 300', () => { + assert.strictEqual(DEFAULT_EXPIRY_BUFFER_S, 300); +}); + +test('stripe-api-key DEFAULT_SCOPE_PERMISSIONS has full, payments, readonly', () => { + assert.ok(DEFAULT_SCOPE_PERMISSIONS.full); + assert.ok(DEFAULT_SCOPE_PERMISSIONS.payments); + assert.ok(DEFAULT_SCOPE_PERMISSIONS.readonly); +}); + +// -- createRestrictedKey with mock server -- + +test('stripe-api-key createRestrictedKey: success path creates key', async () => { + const mock = await createMockStripeServer((req, res, _body) => { + if (req.method === 'POST' && req.url === '/v1/api_keys') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + id: 'rk_test_key_id_123', + object: 'api_key', + secret: 'rk_test_secret_key_value_456', + })); + } else { + res.writeHead(404); + res.end('{}'); + } + }); + + try { + const result = await createRestrictedKey( + 'sk_test_master_key_abcdef', + { charges: 'write' }, + mock.baseUrl + ); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.key_id, 'rk_test_key_id_123'); + assert.strictEqual(result.key_secret, 'rk_test_secret_key_value_456'); + + // Verify the request was sent correctly + assert.strictEqual(mock.requests.length, 1); + assert.strictEqual(mock.requests[0].method, 'POST'); + assert.strictEqual(mock.requests[0].url, '/v1/api_keys'); + assert.ok(mock.requests[0].headers['authorization'].includes('Bearer sk_test_master_key_abcdef')); + assert.ok(mock.requests[0].body.includes('permissions[charges][write]=true')); + } finally { + await mock.close(); + } +}); + +test('stripe-api-key createRestrictedKey: API error returns structured error', async () => { + const mock = await createMockStripeServer((req, res, _body) => { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { message: 'Invalid API Key provided', type: 'authentication_error' }, + })); + }); + + try { + 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)); + } finally { + await mock.close(); + } +}); + +test('stripe-api-key createRestrictedKey: 429 rate limit is transient', async () => { + const mock = await createMockStripeServer((req, res, _body) => { + res.writeHead(429, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { message: 'Rate limit exceeded', type: 'rate_limit_error' }, + })); + }); + + try { + const result = await createRestrictedKey('sk_test_key_xyz', { charges: 'read' }, mock.baseUrl); + assert.strictEqual(result.ok, false); + assert.strictEqual(result.transient, true); + } finally { + await mock.close(); + } +}); + +test('stripe-api-key createRestrictedKey: 500 server error is transient', async () => { + const mock = await createMockStripeServer((req, res, _body) => { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { message: 'Internal server error' }, + })); + }); + + try { + const result = await createRestrictedKey('sk_test_key_xyz', { charges: 'read' }, mock.baseUrl); + assert.strictEqual(result.ok, false); + assert.strictEqual(result.transient, true); + } finally { + await mock.close(); + } +}); + +test('stripe-api-key createRestrictedKey: missing id/secret in response returns error', async () => { + const mock = await createMockStripeServer((req, res, _body) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ object: 'api_key' })); + }); + + 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)); + } finally { + await mock.close(); + } +}); + +// -- deleteRestrictedKey with mock server -- + +test('stripe-api-key deleteRestrictedKey: success path deletes key', async () => { + const mock = await createMockStripeServer((req, res, _body) => { + if (req.method === 'DELETE' && req.url === '/v1/api_keys/rk_test_key_id_123') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ id: 'rk_test_key_id_123', deleted: true })); + } else { + res.writeHead(404); + res.end('{}'); + } + }); + + try { + const result = await deleteRestrictedKey('sk_test_master_key_abcdef', 'rk_test_key_id_123', mock.baseUrl); + assert.strictEqual(result.ok, true); + assert.strictEqual(mock.requests.length, 1); + assert.strictEqual(mock.requests[0].method, 'DELETE'); + assert.ok(mock.requests[0].url.includes('rk_test_key_id_123')); + } finally { + await mock.close(); + } +}); + +test('stripe-api-key deleteRestrictedKey: API error returns structured error', async () => { + const mock = await createMockStripeServer((req, res, _body) => { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { message: 'No such API key', type: 'invalid_request_error' }, + })); + }); + + 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)); + } finally { + await mock.close(); + } +}); + +// -- resolveSession dynamic: end-to-end with mock server -- + +test('stripe-api-key resolveSession dynamic: full success path with mock server', async () => { + const mock = await createMockStripeServer((req, res, _body) => { + if (req.method === 'POST' && req.url === '/v1/api_keys') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + id: 'rk_test_dynamic_id_789', + object: 'api_key', + secret: 'rk_test_dynamic_secret_value_abc', + })); + } + }); + + try { + const dynamicProfile = { + auth: { + provider_config: { + key_strategy: 'dynamic', + account_mode: 'test', + api_base: mock.baseUrl, + master_key_source: { env: 'STRIPE_DYNAMIC_MASTER' }, + default_expiry_buffer_s: 600, + }, + }, + trust: { level: 'supervised' }, + }; + const request = { + profile: dynamicProfile, + instanceId: 'dyn-1', + scope: 'readonly', + task_timeout_s: 120, + }; + const ctx = { + env: { STRIPE_DYNAMIC_MASTER: 'sk_test_master_key_for_dynamic_test' }, + cwd: '/tmp', + }; + const result = await stripeApiKeyProvider.resolveSession(request, ctx); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.session.provider, 'stripe-api-key'); + assert.strictEqual(result.session.subject.principal, 'stripe:test'); + assert.strictEqual(result.session.credentials.api_key.value, 'rk_test_dynamic_secret_value_abc'); + assert.strictEqual(result.session.credentials.api_key.scope, 'readonly'); + assert.strictEqual(result.session.credentials.api_key.kind, 'bearer'); + assert.strictEqual(result.session.provider_assertions.key_strategy, 'dynamic'); + assert.strictEqual(result.session.provider_assertions.stripe_key_id, 'rk_test_dynamic_id_789'); + assert.strictEqual(result.session.provider_assertions.api_base, mock.baseUrl); + assert.strictEqual(result.session.instance.id, 'dyn-1'); + assert.ok(result.session.refresh.expires_at); + // Verify expiry is approximately (120 + 600) seconds from now + const expiresMs = new Date(result.session.refresh.expires_at).getTime(); + const expectedMs = Date.now() + ((120 + 600) * 1000); + assert.ok(Math.abs(expiresMs - expectedMs) < 5000, 'expires_at should be ~720s from now'); + } finally { + await mock.close(); + } +}); + +test('stripe-api-key resolveSession dynamic: master key format mismatch returns error', async () => { + const dynamicProfile = { + auth: { + provider_config: { + key_strategy: 'dynamic', + account_mode: 'live', + master_key_source: { env: 'STRIPE_MASTER' }, + }, + }, + trust: { level: 'supervised' }, + }; + const request = { profile: dynamicProfile, scope: 'full' }; + const ctx = { + env: { STRIPE_MASTER: 'sk_test_wrong_mode_key_abcdef' }, + cwd: '/tmp', + }; + const result = await stripeApiKeyProvider.resolveSession(request, ctx); + assert.strictEqual(result.ok, false); + assert.ok(/mode mismatch/.test(result.error) || /Master key format invalid/.test(result.error)); +}); + +test('stripe-api-key resolveSession dynamic: unknown scope without mapping returns error', async () => { + const dynamicProfile = { + auth: { + provider_config: { + key_strategy: 'dynamic', + account_mode: 'test', + master_key_source: { env: 'STRIPE_MASTER' }, + }, + }, + trust: { level: 'supervised' }, + }; + const request = { profile: dynamicProfile, scope: 'custom-unmapped' }; + const ctx = { + env: { STRIPE_MASTER: 'sk_test_master_key_abcdef_xyz12' }, + cwd: '/tmp', + }; + const result = await stripeApiKeyProvider.resolveSession(request, ctx); + assert.strictEqual(result.ok, false); + assert.ok(/No permission mapping/.test(result.error)); +}); + +test('stripe-api-key resolveSession dynamic: API failure propagates error', async () => { + const mock = await createMockStripeServer((req, res, _body) => { + res.writeHead(403, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { message: 'Insufficient permissions to create restricted key' }, + })); + }); + + try { + const dynamicProfile = { + auth: { + provider_config: { + key_strategy: 'dynamic', + account_mode: 'test', + api_base: mock.baseUrl, + master_key_source: { env: 'STRIPE_MASTER' }, + }, + }, + trust: { level: 'supervised' }, + }; + const request = { profile: dynamicProfile, scope: 'full' }; + const ctx = { + env: { STRIPE_MASTER: 'sk_test_master_key_with_no_perms' }, + cwd: '/tmp', + }; + const result = await stripeApiKeyProvider.resolveSession(request, ctx); + assert.strictEqual(result.ok, false); + assert.ok(/Insufficient permissions/.test(result.error)); + } finally { + await mock.close(); + } +}); + +test('stripe-api-key resolveSession dynamic: default scope is "full" when none provided', async () => { + const mock = await createMockStripeServer((req, res, body) => { + if (req.method === 'POST' && req.url === '/v1/api_keys') { + // Verify that 'full' scope permissions were sent + assert.ok(body.includes('permissions[charges][write]=true'), 'should have full scope write permissions'); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + id: 'rk_test_default_scope_id', + object: 'api_key', + secret: 'rk_test_default_scope_secret_val', + })); + } + }); + + try { + const dynamicProfile = { + auth: { + provider_config: { + key_strategy: 'dynamic', + account_mode: 'test', + api_base: mock.baseUrl, + master_key_source: { env: 'STRIPE_MASTER' }, + }, + }, + trust: { level: 'supervised' }, + }; + const request = { profile: dynamicProfile }; + const ctx = { + env: { STRIPE_MASTER: 'sk_test_master_key_for_default_sc' }, + cwd: '/tmp', + }; + const result = await stripeApiKeyProvider.resolveSession(request, ctx); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.session.credentials.api_key.scope, 'full'); + } finally { + await mock.close(); + } +}); + +test('v0.2 exec stripe-api-key dynamic cleanup revokes minted key after execution', async () => { + const mock = await createMockStripeServer((req, res, _body) => { + if (req.method === 'POST' && req.url === '/v1/api_keys') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + id: 'rk_test_exec_cleanup_id', + object: 'api_key', + secret: 'rk_test_exec_cleanup_secret_value_12345', + })); + return; + } + + if (req.method === 'DELETE' && req.url === '/v1/api_keys/rk_test_exec_cleanup_id') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + id: 'rk_test_exec_cleanup_id', + deleted: true, + })); + return; + } + + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Unexpected request' } })); + }); + + try { + const manifest = { + version: '0.2', + identity_profiles: [{ + id: 'stripe-dynamic', + provider: 'stripe-api-key', + subject: { kind: 'service', principal: 'stripe:test' }, + auth: { + provider_config: { + key_strategy: 'dynamic', + account_mode: 'test', + api_base: mock.baseUrl, + master_key_source: { env: 'STRIPE_MASTER_KEY' }, + }, + }, + trust: { level: 'supervised' }, + }], + workflows: [{ + id: 'w', + name: 'W', + tasks: [{ + id: 't', + name: 'T', + shell: { + program: 'sh', + args: ['-c', 'printf ok'], + }, + target: { session_target: 'shell' }, + schedule: { cron: '0 * * * *' }, + contract: { audit: 'none' }, + identity: { ref: 'stripe-dynamic', scope: 'readonly' }, + }], + }], + }; + + const result = await executeTask(manifest, { + taskId: 't', + env: { + ...process.env, + STRIPE_MASTER_KEY: 'sk_test_master_key_cleanup_value_12345', + }, + }); + + assert.strictEqual(result.ok, true); + assert.strictEqual(mock.requests.filter(request => request.method === 'POST').length, 1); + assert.strictEqual(mock.requests.filter(request => request.method === 'DELETE').length, 1); + assert.ok(mock.requests.some(request => request.url === '/v1/api_keys/rk_test_exec_cleanup_id')); + } finally { + await mock.close(); + } +}); + +test('v0.2 exec stripe-api-key dynamic handoff cleanup revokes prepared child key during dry-run', async () => { + let createCount = 0; + const mock = await createMockStripeServer((req, res, _body) => { + if (req.method === 'POST' && req.url === '/v1/api_keys') { + createCount += 1; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + id: `rk_test_handoff_cleanup_${createCount}`, + object: 'api_key', + secret: `rk_test_handoff_cleanup_secret_value_${createCount}`, + })); + return; + } + + if (req.method === 'DELETE' && req.url.startsWith('/v1/api_keys/rk_test_handoff_cleanup_')) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + id: req.url.split('/').pop(), + deleted: true, + })); + return; + } + + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Unexpected request' } })); + }); + + try { + const manifest = { + version: '0.2', + identity_profiles: [{ + id: 'stripe-dynamic-handoff', + provider: 'stripe-api-key', + subject: { kind: 'service', principal: 'stripe:test' }, + auth: { + provider_config: { + key_strategy: 'dynamic', + account_mode: 'test', + api_base: mock.baseUrl, + master_key_source: { env: 'STRIPE_MASTER_KEY' }, + scope_hierarchy: { + full: ['readonly'], + readonly: [], + }, + }, + }, + presentation: { handoff: 'downscope' }, + trust: { level: 'supervised' }, + }], + workflows: [{ + id: 'w', + name: 'W', + tasks: [{ + id: 't', + name: 'T', + shell: { + program: 'sh', + args: ['-c', 'printf ok'], + }, + target: { session_target: 'shell' }, + schedule: { cron: '0 * * * *' }, + contract: { audit: 'none' }, + identity: { ref: 'stripe-dynamic-handoff', scope: 'readonly' }, + }], + }], + }; + + 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); + } finally { + await mock.close(); + } +}); + +// -- cleanup dynamic: tests with mock server -- + +test('stripe-api-key cleanup: dynamic session revokes key via API', async () => { + const mock = await createMockStripeServer((req, res, _body) => { + if (req.method === 'DELETE' && req.url === '/v1/api_keys/rk_test_cleanup_id') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ id: 'rk_test_cleanup_id', deleted: true })); + } + }); + + try { + const session = { + provider_assertions: { + key_strategy: 'dynamic', + stripe_key_id: 'rk_test_cleanup_id', + api_base: mock.baseUrl, + }, + }; + const ctx = { + session, + env: { STRIPE_MASTER: 'sk_test_cleanup_master_key_1234' }, + cwd: '/tmp', + provider_config: { + master_key_source: { env: 'STRIPE_MASTER' }, + }, + }; + const result = await stripeApiKeyProvider.cleanup({}, ctx); + assert.strictEqual(result.cleaned, true); + assert.strictEqual(result.warnings, undefined); + assert.strictEqual(mock.requests.length, 1); + assert.strictEqual(mock.requests[0].method, 'DELETE'); + } finally { + await mock.close(); + } +}); + +test('stripe-api-key cleanup: dynamic session with API failure returns warning', async () => { + const mock = await createMockStripeServer((req, res, _body) => { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Internal error' } })); + }); + + try { + const session = { + provider_assertions: { + key_strategy: 'dynamic', + stripe_key_id: 'rk_test_fail_cleanup_id', + api_base: mock.baseUrl, + }, + }; + const ctx = { + session, + env: { STRIPE_MASTER: 'sk_test_cleanup_master_key_fail' }, + cwd: '/tmp', + provider_config: { + master_key_source: { env: 'STRIPE_MASTER' }, + }, + }; + const result = await stripeApiKeyProvider.cleanup({}, ctx); + 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')); + } finally { + await mock.close(); + } +}); + +test('stripe-api-key cleanup: precreated session is no-op', () => { + const session = { + provider_assertions: { + key_strategy: 'precreated', + }, + }; + const result = stripeApiKeyProvider.cleanup({}, { session }); + assert.strictEqual(result.cleaned, true); + assert.strictEqual(result.warnings, undefined); +}); + +test('stripe-api-key cleanup: missing session is no-op', () => { + const result = stripeApiKeyProvider.cleanup({}, {}); + assert.strictEqual(result.cleaned, true); +}); + +test('stripe-api-key cleanup: dynamic session without stripe_key_id warns', () => { + const session = { + provider_assertions: { + key_strategy: 'dynamic', + }, + }; + const result = stripeApiKeyProvider.cleanup({}, { session }); + assert.strictEqual(result.cleaned, true); + assert.ok(Array.isArray(result.warnings)); + assert.ok(result.warnings[0].includes('no stripe_key_id')); +}); + +test('stripe-api-key cleanup: dynamic session without master key source warns', async () => { + const session = { + provider_assertions: { + key_strategy: 'dynamic', + stripe_key_id: 'rk_test_no_master', + api_base: 'http://127.0.0.1:1', + }, + }; + const result = stripeApiKeyProvider.cleanup({}, { + session, + env: {}, + cwd: '/tmp', + provider_config: { master_key_source: { env: 'NONEXISTENT_VAR' } }, + }); + // The result might be sync or async depending on the path + const resolved = (result instanceof Promise) ? await result : result; + assert.strictEqual(resolved.cleaned, true); + assert.ok(Array.isArray(resolved.warnings)); +}); + +// -- prepareHandoff dynamic: tests with mock server -- + +test('stripe-api-key prepareHandoff dynamic: mints narrower key for child scope', async () => { + const mock = await createMockStripeServer((req, res, body) => { + if (req.method === 'POST' && req.url === '/v1/api_keys') { + // Verify readonly permissions were sent (not full) + assert.ok(body.includes('permissions[charges][read]=true')); + assert.ok(!body.includes('[write]=true')); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + id: 'rk_test_child_handoff_id', + object: 'api_key', + secret: 'rk_test_child_handoff_secret_val', + })); + } + }); + + try { + const parentSession = { + provider: 'stripe-api-key', + instance: { id: 'dyn-parent', source: 'operator' }, + trust: { declared_level: 'supervised', effective_level: 'supervised' }, + credentials: { + api_key: { kind: 'bearer', value: 'rk_test_parent_dyn_key_value12', scope: 'full' }, + }, + delegation_chain: [ + { kind: 'service', principal: 'stripe:test', grant: 'scope:full', validated: true }, + ], + }; + const parentProfile = { + auth: { + provider_config: { + key_strategy: 'dynamic', + account_mode: 'test', + api_base: mock.baseUrl, + master_key_source: { env: 'STRIPE_MASTER' }, + scope_hierarchy: { + full: ['readonly'], + readonly: [], + }, + }, + }, + }; + const handoff = { + target_scope: 'readonly', + parent_profile: parentProfile, + }; + const ctx = { + env: { STRIPE_MASTER: 'sk_test_handoff_master_key_1234' }, + cwd: '/tmp', + }; + const result = await stripeApiKeyProvider.prepareHandoff(parentSession, handoff, ctx); + assert.strictEqual(result.prepared, true); + assert.strictEqual(result.session.credentials.api_key.scope, 'readonly'); + assert.strictEqual(result.session.credentials.api_key.value, 'rk_test_child_handoff_secret_val'); + assert.strictEqual(result.session.provider_assertions.key_strategy, 'dynamic'); + assert.strictEqual(result.session.provider_assertions.stripe_key_id, 'rk_test_child_handoff_id'); + assert.strictEqual(result.session.provider_assertions.parent_scope, 'full'); + assert.ok(result.session.refresh.expires_at); + assert.strictEqual(result.session.handoff.mode, 'downscope'); + assert.strictEqual(result.session.handoff.prepared, true); + assert.strictEqual(result.session.delegation_chain.length, 2); + assert.ok(result.session.delegation_chain[1].grant.includes('downscope:full->readonly')); + } finally { + await mock.close(); + } +}); + +test('stripe-api-key prepareHandoff dynamic: unreachable scope still fails', async () => { + const parentSession = { + credentials: { + api_key: { kind: 'bearer', value: 'rk_test_readonly_only_key_123', scope: 'readonly' }, + }, + delegation_chain: [], + }; + const parentProfile = { + auth: { + provider_config: { + key_strategy: 'dynamic', + account_mode: 'test', + master_key_source: { env: 'STRIPE_MASTER' }, + scope_hierarchy: { + full: ['readonly'], + readonly: [], + }, + }, + }, + }; + const handoff = { target_scope: 'full', parent_profile: parentProfile }; + const ctx = { env: { STRIPE_MASTER: 'sk_test_handoff_unreachable_key' }, cwd: '/tmp' }; + const result = await stripeApiKeyProvider.prepareHandoff(parentSession, handoff, ctx); + assert.strictEqual(result.prepared, false); + assert.ok(/not reachable/.test(result.error)); +}); + +test('stripe-api-key prepareHandoff dynamic: API failure during minting returns error', async () => { + const mock = await createMockStripeServer((req, res, _body) => { + res.writeHead(503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Service unavailable' } })); + }); + + try { + const parentSession = { + credentials: { + api_key: { kind: 'bearer', value: 'rk_test_parent_api_fail_key12', scope: 'full' }, + }, + delegation_chain: [], + }; + const parentProfile = { + auth: { + provider_config: { + key_strategy: 'dynamic', + account_mode: 'test', + api_base: mock.baseUrl, + master_key_source: { env: 'STRIPE_MASTER' }, + scope_hierarchy: { full: ['readonly'], readonly: [] }, + }, + }, + }; + const handoff = { target_scope: 'readonly', parent_profile: parentProfile }; + 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)); + } finally { + await mock.close(); + } +}); + +// -- validateProfile: dynamic strategy additional validation -- + +test('stripe-api-key validateProfile: dynamic with valid api_base passes', () => { + const profile = { + auth: { + provider_config: { + key_strategy: 'dynamic', + account_mode: 'test', + master_key_source: { env: 'STRIPE_MASTER_KEY' }, + api_base: 'https://api.stripe.com', + }, + }, + }; + const result = stripeApiKeyProvider.validateProfile(profile, {}); + assert.strictEqual(result.valid, true); +}); + +test('stripe-api-key validateProfile: dynamic with http api_base passes (for testing)', () => { + const profile = { + auth: { + provider_config: { + key_strategy: 'dynamic', + account_mode: 'test', + master_key_source: { env: 'STRIPE_MASTER_KEY' }, + api_base: 'http://localhost:12111', + }, + }, + }; + const result = stripeApiKeyProvider.validateProfile(profile, {}); + assert.strictEqual(result.valid, true); +}); + +test('stripe-api-key validateProfile: dynamic with invalid api_base fails', () => { + const profile = { + auth: { + provider_config: { + key_strategy: 'dynamic', + account_mode: 'test', + master_key_source: { env: 'STRIPE_MASTER_KEY' }, + api_base: 'not-a-url', + }, + }, + }; + const result = stripeApiKeyProvider.validateProfile(profile, {}); + assert.strictEqual(result.valid, false); + assert.ok(result.errors.some(e => /api_base/.test(e))); +}); + +test('stripe-api-key validateProfile: dynamic with ftp api_base fails', () => { + const profile = { + auth: { + provider_config: { + key_strategy: 'dynamic', + account_mode: 'test', + master_key_source: { env: 'STRIPE_MASTER_KEY' }, + api_base: 'ftp://stripe.com/v1', + }, + }, + }; + const result = stripeApiKeyProvider.validateProfile(profile, {}); + assert.strictEqual(result.valid, false); + assert.ok(result.errors.some(e => /protocol/.test(e))); +}); + +test('stripe-api-key validateProfile: dynamic with valid default_expiry_buffer_s passes', () => { + const profile = { + auth: { + provider_config: { + key_strategy: 'dynamic', + account_mode: 'test', + master_key_source: { env: 'STRIPE_MASTER_KEY' }, + default_expiry_buffer_s: 600, + }, + }, + }; + const result = stripeApiKeyProvider.validateProfile(profile, {}); + assert.strictEqual(result.valid, true); +}); + +test('stripe-api-key validateProfile: dynamic with zero default_expiry_buffer_s fails', () => { + const profile = { + auth: { + provider_config: { + key_strategy: 'dynamic', + account_mode: 'test', + master_key_source: { env: 'STRIPE_MASTER_KEY' }, + default_expiry_buffer_s: 0, + }, + }, + }; + const result = stripeApiKeyProvider.validateProfile(profile, {}); + assert.strictEqual(result.valid, false); + assert.ok(result.errors.some(e => /default_expiry_buffer_s/.test(e))); +}); + +test('stripe-api-key validateProfile: dynamic with negative default_expiry_buffer_s fails', () => { + const profile = { + auth: { + provider_config: { + key_strategy: 'dynamic', + account_mode: 'test', + master_key_source: { env: 'STRIPE_MASTER_KEY' }, + default_expiry_buffer_s: -100, + }, + }, + }; + const result = stripeApiKeyProvider.validateProfile(profile, {}); + assert.strictEqual(result.valid, false); + assert.ok(result.errors.some(e => /default_expiry_buffer_s/.test(e))); +}); + +test('stripe-api-key validateProfile: dynamic with non-number default_expiry_buffer_s fails', () => { + const profile = { + auth: { + provider_config: { + key_strategy: 'dynamic', + account_mode: 'test', + master_key_source: { env: 'STRIPE_MASTER_KEY' }, + default_expiry_buffer_s: 'five', + }, + }, + }; + const result = stripeApiKeyProvider.validateProfile(profile, {}); + assert.strictEqual(result.valid, false); + assert.ok(result.errors.some(e => /default_expiry_buffer_s/.test(e))); +}); + +// -- materialize: dynamic session sets cleanup_required -- + +test('stripe-api-key materialize: dynamic session sets cleanup_required true', () => { + const session = { + provider: 'stripe-api-key', + credentials: { + api_key: { + kind: 'bearer', + value: 'rk_test_dynamic_mat_key_value', + scope: 'readonly', + }, + }, + provider_assertions: { + key_strategy: 'dynamic', + account_mode: 'test', + scope: 'readonly', + stripe_key_id: 'rk_test_mat_key_id', + }, + }; + const result = stripeApiKeyProvider.materialize(session, {}, {}); + assert.strictEqual(result.materialized, true); + assert.strictEqual(result.cleanup_required, true); + assert.strictEqual(result.env_vars['STRIPE_API_KEY'], 'rk_test_dynamic_mat_key_value'); +}); + +// -- describeSession: dynamic session masks key and preserves stripe_key_id -- + +test('stripe-api-key describeSession: dynamic session masks key but keeps metadata', () => { + const session = { + provider: 'stripe-api-key', + credentials: { + api_key: { + kind: 'bearer', + value: 'rk_test_dynamic_desc_key_value_xyz', + scope: 'readonly', + }, + }, + provider_assertions: { + key_strategy: 'dynamic', + account_mode: 'test', + scope: 'readonly', + stripe_key_id: 'rk_test_desc_key_id', + api_base: 'https://api.stripe.com', + }, + }; + 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.provider_assertions.key_strategy, 'dynamic'); +}); + +// --------------------------------------------------------------------------- +// task.verify -- validation tests +// --------------------------------------------------------------------------- + +test('validate: valid verify block passes validation', () => { + const manifest = { + version: '0.2', + workflows: [{ + id: 'w', name: 'W', + tasks: [{ + id: 't', name: 'T', + prompt: 'do stuff', + target: { session_target: 'isolated' }, + schedule: { cron: '0 * * * *' }, + verify: { shell: 'test -f /tmp/output.txt', timeout_seconds: 15, on_failure: 'warn' } + }] + }] + }; + const result = validateManifest(manifest); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.errors.length, 0); +}); + +test('validate: verify with only shell passes (defaults for timeout and on_failure)', () => { + const manifest = { + version: '0.2', + workflows: [{ + id: 'w', name: 'W', + tasks: [{ + id: 't', name: 'T', + prompt: 'do stuff', + target: { session_target: 'isolated' }, + schedule: { cron: '0 * * * *' }, + verify: { shell: 'echo ok' } + }] + }] + }; + const result = validateManifest(manifest); + assert.strictEqual(result.ok, true); +}); + +test('validate: verify missing shell fails validation', () => { + const manifest = { + version: '0.2', + workflows: [{ + id: 'w', name: 'W', + tasks: [{ + id: 't', name: 'T', + prompt: 'do stuff', + target: { session_target: 'isolated' }, + schedule: { cron: '0 * * * *' }, + verify: { timeout_seconds: 10 } + }] + }] + }; + const result = validateManifest(manifest); + assert.strictEqual(result.ok, false); + assert.ok(result.errors.some(e => e.path.includes('verify.shell'))); +}); + +test('validate: verify empty shell fails validation', () => { + const manifest = { + version: '0.2', + workflows: [{ + id: 'w', name: 'W', + tasks: [{ + id: 't', name: 'T', + prompt: 'do stuff', + target: { session_target: 'isolated' }, + schedule: { cron: '0 * * * *' }, + verify: { shell: ' ' } + }] + }] + }; + const result = validateManifest(manifest); + assert.strictEqual(result.ok, false); + assert.ok(result.errors.some(e => e.path.includes('verify.shell'))); +}); + +test('validate: verify invalid on_failure fails validation', () => { + const manifest = { + version: '0.2', + workflows: [{ + id: 'w', name: 'W', + tasks: [{ + id: 't', name: 'T', + prompt: 'do stuff', + target: { session_target: 'isolated' }, + schedule: { cron: '0 * * * *' }, + verify: { shell: 'echo ok', on_failure: 'retry' } + }] + }] + }; + const result = validateManifest(manifest); + assert.strictEqual(result.ok, false); + assert.ok(result.errors.some(e => e.path.includes('verify.on_failure') && e.message.includes('error, warn'))); +}); + +test('validate: verify invalid timeout_seconds fails validation', () => { + const manifest = { + version: '0.2', + workflows: [{ + id: 'w', name: 'W', + tasks: [{ + id: 't', name: 'T', + prompt: 'do stuff', + target: { session_target: 'isolated' }, + schedule: { cron: '0 * * * *' }, + verify: { shell: 'echo ok', timeout_seconds: 0 } + }] + }] + }; + const result = validateManifest(manifest); + assert.strictEqual(result.ok, false); + assert.ok(result.errors.some(e => e.path.includes('verify.timeout_seconds'))); +}); + +test('validate: workflow-level verify passes validation', () => { + const manifest = { + version: '0.2', + workflows: [{ + id: 'w', name: 'W', + verify: { shell: 'test -d /tmp/workspace' }, + tasks: [{ + id: 't', name: 'T', + prompt: 'do stuff', + target: { session_target: 'isolated' }, + schedule: { cron: '0 * * * *' } + }] + }] + }; + const result = validateManifest(manifest); + assert.strictEqual(result.ok, true); +}); + +// --------------------------------------------------------------------------- +// task.verify -- compiler/shared resolveVerify tests +// --------------------------------------------------------------------------- + +test('resolveVerify: returns null when neither workflow nor task has verify', () => { + const result = resolveVerify({}, {}); + assert.strictEqual(result, null); +}); + +test('resolveVerify: task verify overrides workflow verify', () => { + const workflow = { verify: { shell: 'wf-check', timeout_seconds: 10, on_failure: 'warn' } }; + const task = { verify: { shell: 'task-check' } }; + const result = resolveVerify(workflow, task); + assert.strictEqual(result.shell, 'task-check'); + assert.strictEqual(result.timeout_seconds, 30); // default since task didn't set it + assert.strictEqual(result.on_failure, 'error'); // default since task didn't set it +}); + +test('resolveVerify: falls back to workflow verify when task has none', () => { + const workflow = { verify: { shell: 'wf-check', timeout_seconds: 60, on_failure: 'warn' } }; + const task = {}; + const result = resolveVerify(workflow, task); + assert.strictEqual(result.shell, 'wf-check'); + assert.strictEqual(result.timeout_seconds, 60); + assert.strictEqual(result.on_failure, 'warn'); +}); + +test('resolveVerify: applies defaults for timeout_seconds and on_failure', () => { + const result = resolveVerify({}, { verify: { shell: 'echo ok' } }); + assert.strictEqual(result.shell, 'echo ok'); + assert.strictEqual(result.timeout_seconds, 30); + assert.strictEqual(result.on_failure, 'error'); +}); + +// --------------------------------------------------------------------------- +// task.verify -- compilation: verify fields flow to scheduler spec +// --------------------------------------------------------------------------- + +test('compilation: verify fields appear in compiled scheduler job', () => { + const manifest = { + version: '0.2', + workflows: [{ + id: 'w', name: 'W', + tasks: [{ + id: 't', name: 'T', + prompt: 'do stuff', + target: { session_target: 'isolated' }, + schedule: { cron: '0 * * * *' }, + verify: { shell: 'test -f /tmp/result.json', timeout_seconds: 45, on_failure: 'warn' } + }] + }] + }; + const compiled = compileManifestToScheduler(manifest); + const job = compiled.jobs[0]; + assert.strictEqual(job.verify_shell, 'test -f /tmp/result.json'); + assert.strictEqual(job.verify_timeout_s, 45); + assert.strictEqual(job.verify_on_failure, 'warn'); +}); + +test('compilation: verify fields are null when not declared', () => { + const manifest = { + version: '0.2', + workflows: [{ + id: 'w', name: 'W', + tasks: [{ + id: 't', name: 'T', + prompt: 'do stuff', + target: { session_target: 'isolated' }, + schedule: { cron: '0 * * * *' } + }] + }] + }; + const compiled = compileManifestToScheduler(manifest); + const job = compiled.jobs[0]; + assert.strictEqual(job.verify_shell, null); + assert.strictEqual(job.verify_timeout_s, null); + assert.strictEqual(job.verify_on_failure, null); +}); + +test('compilation: workflow-level verify flows through to scheduler job', () => { + const manifest = { + version: '0.2', + workflows: [{ + id: 'w', name: 'W', + verify: { shell: 'wf-check', timeout_seconds: 20 }, + tasks: [{ + id: 't', name: 'T', + prompt: 'do stuff', + target: { session_target: 'isolated' }, + schedule: { cron: '0 * * * *' } + }] + }] + }; + const compiled = compileManifestToScheduler(manifest); + const job = compiled.jobs[0]; + assert.strictEqual(job.verify_shell, 'wf-check'); + assert.strictEqual(job.verify_timeout_s, 20); + assert.strictEqual(job.verify_on_failure, 'error'); +}); + +// --------------------------------------------------------------------------- +// task.verify -- scheduler fields include verify +// --------------------------------------------------------------------------- + +test('SCHEDULER_FIELDS_V02 includes verify fields', () => { + assert.ok(SCHEDULER_FIELDS_V02.includes('verify_shell'), 'verify_shell missing from V02'); + assert.ok(SCHEDULER_FIELDS_V02.includes('verify_timeout_s'), 'verify_timeout_s missing from V02'); + assert.ok(SCHEDULER_FIELDS_V02.includes('verify_on_failure'), 'verify_on_failure missing from V02'); +}); + +// --------------------------------------------------------------------------- +// task.verify -- execution tests +// --------------------------------------------------------------------------- + +test('exec verify: succeeds when verify command exits 0', () => { + const manifest = { + version: '0.1', + workflows: [{ + id: 'w', name: 'W', + tasks: [{ + id: 't', name: 'T', + shell: { program: 'echo', args: ['hello'] }, + target: { session_target: 'shell' }, + schedule: { cron: '0 * * * *' }, + contract: { audit: 'none' }, + verify: { shell: 'true' } + }] + }] + }; + const result = executeTask(manifest, { taskId: 't' }); + assert.strictEqual(result.ok, true); + assert.ok(result.verify); + assert.strictEqual(result.verify.passed, true); + assert.strictEqual(result.verify.exit_code, 0); +}); + +test('exec verify: on_failure=error throws verify_failed', () => { + const manifest = { + version: '0.1', + workflows: [{ + id: 'w', name: 'W', + tasks: [{ + id: 't', name: 'T', + shell: { program: 'echo', args: ['hello'] }, + target: { session_target: 'shell' }, + schedule: { cron: '0 * * * *' }, + contract: { audit: 'none' }, + verify: { shell: 'exit 1', on_failure: 'error' } + }] + }] + }; + assert.throws( + () => executeTask(manifest, { taskId: 't' }), + (err) => { + assert.strictEqual(err.code, 'verify_failed'); + assert.ok(err.verify); + assert.strictEqual(err.verify.passed, false); + assert.strictEqual(err.verify.exit_code, 1); + return true; + } + ); +}); + +test('exec verify: on_failure=warn adds warning but returns ok', () => { + const manifest = { + version: '0.1', + workflows: [{ + id: 'w', name: 'W', + tasks: [{ + id: 't', name: 'T', + shell: { program: 'echo', args: ['hello'] }, + target: { session_target: 'shell' }, + schedule: { cron: '0 * * * *' }, + contract: { audit: 'none' }, + verify: { shell: 'exit 1', on_failure: 'warn' } + }] + }] + }; + const result = executeTask(manifest, { taskId: 't' }); + assert.strictEqual(result.ok, true); + assert.ok(result.warnings.some(w => w.includes('Verify command failed'))); +}); + +test('exec verify: not run when shell command fails', () => { + const manifest = { + version: '0.1', + workflows: [{ + id: 'w', name: 'W', + tasks: [{ + id: 't', name: 'T', + shell: { program: 'sh', args: ['-c', 'exit 2'] }, + target: { session_target: 'shell' }, + schedule: { cron: '0 * * * *' }, + contract: { audit: 'none' }, + verify: { shell: 'echo should-not-run', on_failure: 'error' } + }] + }] + }; + const result = executeTask(manifest, { taskId: 't' }); + assert.strictEqual(result.ok, false); + assert.strictEqual(result.verify, null); +}); + +test('exec verify: null when no verify block declared', () => { + const manifest = { + version: '0.1', + workflows: [{ + id: 'w', name: 'W', + tasks: [{ + id: 't', name: 'T', + shell: { program: 'echo', args: ['hello'] }, + target: { session_target: 'shell' }, + schedule: { cron: '0 * * * *' }, + contract: { audit: 'none' } + }] + }] + }; + const result = executeTask(manifest, { taskId: 't' }); + assert.strictEqual(result.ok, true); + assert.strictEqual(result.verify, null); +}); + +test('exec verify: v0.1 uses task cwd and shell env', () => { + const tmpRoot = mkdtempSync(join(tmpdir(), 'agentcli-verify-v1-')); + const workDir = join(tmpRoot, 'workspace'); + mkdirSync(workDir); + + const manifest = { + version: '0.1', + workflows: [{ + id: 'w', name: 'W', + tasks: [{ + id: 't', name: 'T', + shell: { + program: 'sh', + args: ['-c', 'printf ok > marker.txt'], + cwd: workDir, + env: { + VERIFY_TOKEN: 'from-shell-env', + }, + }, + target: { session_target: 'shell' }, + schedule: { cron: '0 * * * *' }, + contract: { audit: 'none' }, + verify: { shell: 'test "$VERIFY_TOKEN" = "from-shell-env" && test -f marker.txt' } + }] + }] + }; + + const result = executeTask(manifest, { taskId: 't' }); + assert.strictEqual(result.ok, true); + assert.ok(result.verify); + assert.strictEqual(result.verify.passed, true); +}); + +test('exec verify: respects strict sandbox enforcement on supported darwin runners', () => { + const support = resolveSandboxSupport(); + if (process.platform !== 'darwin' || !support) { + return; + } + + const workdir = mkdtempSync(join(tmpdir(), 'agentcli-verify-sandbox-')); + const outsideRoot = process.env.HOME && !process.env.HOME.startsWith(tmpdir()) + ? process.env.HOME + : process.cwd(); + const deniedPath = join(outsideRoot, `agentcli-verify-denied-${Date.now()}.txt`); + + try { + rmSync(deniedPath, { force: true }); + const manifest = { + version: '0.1', + workflows: [{ + id: 'w', name: 'W', + tasks: [{ + id: 't', name: 'T', + shell: { + program: 'sh', + args: ['-lc', 'printf ok > allowed.txt'], + cwd: workdir, + env: { TARGET: deniedPath }, + }, + target: { session_target: 'shell' }, + schedule: { cron: '0 * * * *' }, + contract: { sandbox: 'strict', network: 'none', audit: 'none' }, + verify: { shell: 'printf blocked > "$TARGET"' }, + }] + }] + }; + + assert.throws( + () => executeTask(manifest, { taskId: 't' }), + (err) => { + assert.strictEqual(err.code, 'verify_failed'); + return true; + } + ); + assert.ok(!existsSync(deniedPath)); + } finally { + rmSync(workdir, { recursive: true, force: true }); + rmSync(deniedPath, { force: true }); + } +}); + +test('exec verify: v0.2 path verify succeeds', async () => { + const manifest = { + version: '0.2', + identity_profiles: [{ + id: 'none-profile', + provider: 'none', + subject: { kind: 'service', principal: 'agent://test/verify' } + }], + workflows: [{ + id: 'w', name: 'W', + identity: { ref: 'none-profile' }, + tasks: [{ + id: 't', name: 'T', + shell: { program: 'echo', args: ['v2-hello'] }, + target: { session_target: 'shell' }, + schedule: { cron: '0 * * * *' }, + contract: { audit: 'none' }, + verify: { shell: 'true', timeout_seconds: 5 } + }] + }] + }; + const result = await executeTask(manifest, { taskId: 't' }); + assert.strictEqual(result.ok, true); + assert.ok(result.verify); + assert.strictEqual(result.verify.passed, true); +}); + +test('exec verify: v0.2 uses task cwd and materialized env', async () => { + const tmpRoot = mkdtempSync(join(tmpdir(), 'agentcli-verify-v2-')); + const workDir = join(tmpRoot, 'workspace'); + mkdirSync(workDir); + + const manifest = { + version: '0.2', + identity_profiles: [{ + id: 'verify-env-profile', + provider: 'env-bearer', + subject: { kind: 'service', principal: 'agent://test/verify-env' }, + auth: { + mode: 'service', + provider_config: { + token_env: 'SOURCE_VERIFY_TOKEN', + }, + }, + presentation: { + bindings: [{ + source: 'credentials.access_token.value', + target: { + kind: 'env', + name: 'VERIFY_TOKEN', + }, + }], + }, + trust: { level: 'supervised' }, + }], + workflows: [{ + id: 'w', name: 'W', + tasks: [{ + id: 't', name: 'T', + shell: { + program: 'sh', + args: ['-c', 'printf ok > marker.txt'], + cwd: workDir, + }, + target: { session_target: 'shell' }, + schedule: { cron: '0 * * * *' }, + contract: { audit: 'none' }, + identity: { ref: 'verify-env-profile' }, + verify: { shell: 'test "$VERIFY_TOKEN" = "token-from-identity" && test -f marker.txt' } + }] + }] + }; + + const result = await executeTask(manifest, { + taskId: 't', + env: { + ...process.env, + SOURCE_VERIFY_TOKEN: 'token-from-identity', + }, + }); + + assert.strictEqual(result.ok, true); + assert.ok(result.verify); + assert.strictEqual(result.verify.passed, true); +}); + +test('exec verify: v0.2 path verify failure with on_failure=error throws', async () => { + const manifest = { + version: '0.2', + identity_profiles: [{ + id: 'none-profile', + provider: 'none', + subject: { kind: 'service', principal: 'agent://test/verify' } + }], + workflows: [{ + id: 'w', name: 'W', + identity: { ref: 'none-profile' }, + tasks: [{ + id: 't', name: 'T', + shell: { program: 'echo', args: ['v2-hello'] }, + target: { session_target: 'shell' }, + schedule: { cron: '0 * * * *' }, + contract: { audit: 'none' }, + verify: { shell: 'exit 1', on_failure: 'error' } + }] + }] + }; + await assert.rejects( + () => executeTask(manifest, { taskId: 't' }), + (err) => { + assert.strictEqual(err.code, 'verify_failed'); + return true; + } + ); +});