From fb9e5aa541908db88019c8c73e8908819f391e3c Mon Sep 17 00:00:00 2001 From: amittell Date: Mon, 30 Mar 2026 12:22:24 -0400 Subject: [PATCH 1/7] Add task.verify, dynamic Stripe keys, capability warnings, and remaining v0.2 follow-ups task.verify (agentcli#2): - New verify field on tasks: shell command runs after completion to confirm outcome - verify.shell (required), verify.timeout_seconds (default 30), verify.on_failure (error|warn) - Full pipeline: validation, schema, compiler, exec (v1+v2), scheduler fields - 19 new tests Dynamic Stripe key minting: - Implement key_strategy: "dynamic" in stripe-api-key provider - Mint restricted keys via POST /v1/api_keys with master key auth - Cleanup via DELETE /v1/api_keys/{id} (best-effort) - Dynamic prepareHandoff mints narrower keys for child scope - 45 new tests with mock HTTP server Capability warnings: - validateManifestCapabilities returns { errors, warnings } instead of flat array - Soft warnings for runtime_identity_resolution and credential_handoff (downscope) - Updated all callers (apply.js, cli.js, runtime adapter) - 6 new tests DRY dedup: already resolved (schedulerCreateSpec shared via apply.js import) --- src/apply.js | 7 +- src/capabilities.js | 51 +- src/cli.js | 3 +- src/compiler/openclaw-scheduler.js | 5 + src/compiler/shared.js | 14 + src/exec.js | 113 ++- src/identity/stripe-api-key.js | 590 +++++++++++- src/runtime/openclaw-scheduler.js | 2 +- src/scheduler-fields.js | 3 + src/schema.js | 18 +- src/validate.js | 30 +- test/agentcli.test.js | 1442 +++++++++++++++++++++++++++- 12 files changed, 2221 insertions(+), 57 deletions(-) diff --git a/src/apply.js b/src/apply.js index d18a8cb..dca2918 100644 --- a/src/apply.js +++ b/src/apply.js @@ -245,13 +245,18 @@ export async function applyManifestToScheduler( effectiveResult = resolveEffectiveFeatures('openclaw-scheduler', runtimeCaps); handoffVersion = effectiveResult.handoff_version || '1'; - const capabilityErrors = validateManifestCapabilities(compiled, effectiveResult); + const { errors: capabilityErrors, warnings: capabilityWarnings } = validateManifestCapabilities(compiled, effectiveResult); 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; diff --git a/src/capabilities.js b/src/capabilities.js index f022139..6ddd4dd 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,32 @@ 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 + 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..e8e4b17 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,39 @@ 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 } for spawn context. + * @returns {object} { passed, exit_code, stdout, stderr, timed_out, duration_ms } + */ +function runVerify(verify, { cwd = process.cwd(), env = process.env } = {}) { + const timeoutMs = (verify.timeout_seconds ?? 30) * 1000; + const startMs = Date.now(); + const proc = spawnSync('sh', ['-c', verify.shell], { + 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'; @@ -250,6 +284,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 +303,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 +414,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 +598,28 @@ 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, env }); + 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 +641,7 @@ function executeV1(common, { dryRun }) { signer: provider.name, attestation, attestation_note, + verify: verifyResult, warnings, dry_run: false, result: auditResult, @@ -595,8 +650,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 +670,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 +695,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; @@ -1234,6 +1300,25 @@ async function executeV2(common, { ); } + // ------------------------------------------------------------------ + // Post-execution verify phase + // ------------------------------------------------------------------ + + let verifyResult = null; + let verifyFailed = false; + if (verify && exitCode === 0) { + verifyResult = runVerify(verify, { cwd, env }); + 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; + // ------------------------------------------------------------------ // Phase 7: Enhanced Audit Record // ------------------------------------------------------------------ @@ -1251,7 +1336,7 @@ async function executeV2(common, { const shouldAudit = auditPolicy === 'always' || - (auditPolicy === 'on-failure' && exitCode !== 0); + (auditPolicy === 'on-failure' && !effectiveOk); if (shouldAudit) { const record = { @@ -1274,6 +1359,7 @@ async function executeV2(common, { signer: provider.name, attestation, attestation_note, + verify: verifyResult, warnings, dry_run: false, result: auditResult, @@ -1294,12 +1380,22 @@ async function executeV2(common, { } } + if (verifyFailed) { + const verifyStdout = verifyResult.stdout || ''; + const verifyStderr = verifyResult.stderr || ''; + const detail = verifyStderr || verifyStdout || '(no output)'; + throw Object.assign( + new Error(`Verify command failed (exit ${verifyResult.exit_code}): ${detail}`), + { code: 'verify_failed', verify: verifyResult } + ); + } + // ------------------------------------------------------------------ // Return result // ------------------------------------------------------------------ return { - ok: exitCode === 0, + ok: effectiveOk, execution_id: executionId, source: { workflow_id: workflow.id, task_id: task.id }, declared_identity: declaredIdentity, @@ -1308,6 +1404,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..7431d44 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,272 @@ 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 (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 +734,29 @@ 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:' && parsed.protocol !== 'http:') { + errors.push('provider_config.api_base must use https: or http: 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 @@ -503,11 +795,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 +906,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. @@ -662,15 +1066,55 @@ const stripeApiKeyProvider = { * 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 +1147,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 +1164,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 +1261,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 +1454,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..2fda1d1 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 }, + 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..827dc63 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 () => { @@ -7509,7 +7641,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 +9138,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 +9392,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 +9404,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 +9840,1274 @@ 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(); + } +}); + +// -- 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.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 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; + } + ); +}); From 8df7a18902957f4ad0591afe370e6b9d72daef7c Mon Sep 17 00:00:00 2001 From: amittell Date: Mon, 30 Mar 2026 14:15:23 -0400 Subject: [PATCH 2/7] Document scheduler/child trust boundary and execution principal separation Add architecture docs explaining the credential flow control surfaces, child credential policy enforcement model, and separation between agentcli (control plane) and scheduler (execution runtime) roles. --- docs/architecture.md | 40 +++++++++++++++++++++++++++++++++++++ docs/execution-identity.md | 41 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/docs/architecture.md b/docs/architecture.md index 2f3a935..b5696dd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -85,6 +85,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..edb4f97 100644 --- a/docs/execution-identity.md +++ b/docs/execution-identity.md @@ -2100,6 +2100,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 From 28ef19ecbcc3c01f3161511f615235968a4b5a5a Mon Sep 17 00:00:00 2001 From: amittell Date: Mon, 30 Mar 2026 14:39:14 -0400 Subject: [PATCH 3/7] Address PR review: cleanup metadata, http guard, schema min, master key guard - Embed session in materialize result so cleanup() can access stripe_key_id without callers needing to thread session through ctx - Restrict http:// api_base to localhost unless allow_insecure_http is set, preventing accidental credential leak over plain HTTP - Add min: 1 to verify_timeout_s in published schema - Guard resolveMasterKey against missing/non-object master_key_source --- src/identity/stripe-api-key.js | 26 +++++++++++++++++++++++--- src/schema.js | 2 +- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/identity/stripe-api-key.js b/src/identity/stripe-api-key.js index 7431d44..7af2206 100644 --- a/src/identity/stripe-api-key.js +++ b/src/identity/stripe-api-key.js @@ -597,6 +597,9 @@ async function deleteRestrictedKey(masterKey, keyId, apiBase) { * @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); } @@ -742,8 +745,17 @@ const stripeApiKeyProvider = { } else { try { const parsed = new URL(config.api_base); - if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { - errors.push('provider_config.api_base must use https: or http: protocol'); + 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}`); @@ -1055,11 +1067,19 @@ 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; }, /** diff --git a/src/schema.js b/src/schema.js index 2fda1d1..41ea847 100644 --- a/src/schema.js +++ b/src/schema.js @@ -815,6 +815,6 @@ Object.assign(MANIFEST_SCHEMA.schedulerJob.fields, { child_credential_policy: childCredentialPolicyField, authorization_proof_verification: { type: 'object', nullable: true }, verify_shell: nullableString, - verify_timeout_s: { type: 'integer', nullable: true }, + verify_timeout_s: { type: 'integer', nullable: true, min: 1 }, verify_on_failure: nullableString, }); From e0e1e46bdbca7e62597cc93eebc567f6479249b5 Mon Sep 17 00:00:00 2001 From: amittell Date: Mon, 30 Mar 2026 15:17:06 -0400 Subject: [PATCH 4/7] Fix verify context and dynamic Stripe cleanup --- docs/field-reference.md | 14 +++ src/exec.js | 103 ++++++++++++---- test/agentcli.test.js | 254 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 348 insertions(+), 23 deletions(-) diff --git a/docs/field-reference.md b/docs/field-reference.md index 46b2219..584c101 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,18 @@ 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. | +--- + +## 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. + +| 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 | diff --git a/src/exec.js b/src/exec.js index e8e4b17..6a92fa5 100644 --- a/src/exec.js +++ b/src/exec.js @@ -209,6 +209,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. * @@ -605,7 +644,10 @@ function executeV1(common, { dryRun }) { let verifyResult = null; let verifyFailed = false; if (verify && exitCode === 0 && !dryRun) { - verifyResult = runVerify(verify, { cwd, env }); + verifyResult = runVerify(verify, { + cwd: shell.cwd || cwd, + env: spawnEnv, + }); if (!verifyResult.passed) { if (verify.on_failure === 'warn') { warnings.push(`Verify command failed (exit ${verifyResult.exit_code}): ${verifyResult.stderr || verifyResult.stdout || '(no output)'}`); @@ -817,6 +859,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 } ), @@ -1099,6 +1142,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 = { @@ -1130,15 +1189,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, @@ -1307,7 +1357,10 @@ async function executeV2(common, { let verifyResult = null; let verifyFailed = false; if (verify && exitCode === 0) { - verifyResult = runVerify(verify, { cwd, env }); + verifyResult = runVerify(verify, { + cwd: shell.cwd || cwd, + env: spawnEnv, + }); if (!verifyResult.passed) { if (verify.on_failure === 'warn') { warnings.push(`Verify command failed (exit ${verifyResult.exit_code}): ${verifyResult.stderr || verifyResult.stdout || '(no output)'}`); @@ -1319,6 +1372,22 @@ async function executeV2(common, { 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 // ------------------------------------------------------------------ @@ -1368,18 +1437,6 @@ 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 || ''; diff --git a/test/agentcli.test.js b/test/agentcli.test.js index 827dc63..df4248b 100644 --- a/test/agentcli.test.js +++ b/test/agentcli.test.js @@ -10275,6 +10275,168 @@ test('stripe-api-key resolveSession dynamic: default scope is "full" when none p } }); +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 () => { @@ -11055,6 +11217,39 @@ test('exec verify: null when no verify block declared', () => { 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: v0.2 path verify succeeds', async () => { const manifest = { version: '0.2', @@ -11082,6 +11277,65 @@ test('exec verify: v0.2 path verify succeeds', async () => { 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', From e2fd4cff68ebb6a7a87776c98edc37f0b1fed1c9 Mon Sep 17 00:00:00 2001 From: amittell Date: Mon, 30 Mar 2026 16:17:52 -0400 Subject: [PATCH 5/7] Address PR review: evidence/verify ordering docs, JSON warnings, JSDoc, capability cross-link - Document evidence vs verify ordering in exec.js (evidence attests command outcome, verify is a separate operator-local deliverable check) - Include capability warnings in apply JSON output (capabilities.warnings) so automation parsing stdout gets structured warning data - Fix stale JSDoc claiming dynamic strategy is not implemented - Add cross-link comment explaining hard error (presentation.handoff) vs soft warning (child_credential_policy downscope) asymmetry --- src/apply.js | 5 ++++- src/capabilities.js | 6 +++++- src/exec.js | 6 ++++++ src/identity/stripe-api-key.js | 3 ++- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/apply.js b/src/apply.js index dca2918..6188ea6 100644 --- a/src/apply.js +++ b/src/apply.js @@ -240,12 +240,14 @@ 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 { errors: capabilityErrors, warnings: capabilityWarnings } = 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('; ')), @@ -460,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 6ddd4dd..b90452f 100644 --- a/src/capabilities.js +++ b/src/capabilities.js @@ -150,7 +150,11 @@ export function validateManifestCapabilities(compiledOutput, effectiveFeatures) } } - // Soft warning: downscope policy requires credential_handoff at runtime + // 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({ diff --git a/src/exec.js b/src/exec.js index 6a92fa5..7bd88d1 100644 --- a/src/exec.js +++ b/src/exec.js @@ -1352,6 +1352,12 @@ 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; diff --git a/src/identity/stripe-api-key.js b/src/identity/stripe-api-key.js index 7af2206..c53ed7e 100644 --- a/src/identity/stripe-api-key.js +++ b/src/identity/stripe-api-key.js @@ -792,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 }. From e8a5808a75d70b49df1b074b9e8fd318443bb21b Mon Sep 17 00:00:00 2001 From: amittell Date: Mon, 30 Mar 2026 17:30:36 -0400 Subject: [PATCH 6/7] Sandbox verify commands under strict contracts --- src/exec.js | 25 ++++++++++++++++++---- test/agentcli.test.js | 48 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/src/exec.js b/src/exec.js index 7bd88d1..1b73f4b 100644 --- a/src/exec.js +++ b/src/exec.js @@ -89,19 +89,34 @@ function preflightContractChecks(contract, shell, { cwd = process.cwd() } = {}) * 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 } for spawn context. + * @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 } = {}) { +function runVerify(verify, { + cwd = process.cwd(), + env = process.env, + sandboxCommand = null, +} = {}) { const timeoutMs = (verify.timeout_seconds ?? 30) * 1000; const startMs = Date.now(); - const proc = spawnSync('sh', ['-c', verify.shell], { + 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 || ''; @@ -647,6 +662,7 @@ function executeV1(common, { dryRun }) { verifyResult = runVerify(verify, { cwd: shell.cwd || cwd, env: spawnEnv, + sandboxCommand, }); if (!verifyResult.passed) { if (verify.on_failure === 'warn') { @@ -1366,6 +1382,7 @@ async function executeV2(common, { verifyResult = runVerify(verify, { cwd: shell.cwd || cwd, env: spawnEnv, + sandboxCommand, }); if (!verifyResult.passed) { if (verify.on_failure === 'warn') { diff --git a/test/agentcli.test.js b/test/agentcli.test.js index df4248b..2ca3950 100644 --- a/test/agentcli.test.js +++ b/test/agentcli.test.js @@ -11250,6 +11250,54 @@ test('exec verify: v0.1 uses task cwd and shell env', () => { 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', From 160f8564d7e8409be3aec93c03185c06d0613260 Mon Sep 17 00:00:00 2001 From: amittell Date: Mon, 30 Mar 2026 21:46:04 -0400 Subject: [PATCH 7/7] Clarify verify and capability warning semantics --- docs/architecture.md | 1 + docs/execution-identity.md | 8 +++++ docs/field-reference.md | 6 ++++ test/agentcli.test.js | 72 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+) diff --git a/docs/architecture.md b/docs/architecture.md index b5696dd..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. diff --git a/docs/execution-identity.md b/docs/execution-identity.md index edb4f97..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 diff --git a/docs/field-reference.md b/docs/field-reference.md index 584c101..ba4dd10 100644 --- a/docs/field-reference.md +++ b/docs/field-reference.md @@ -274,12 +274,16 @@ 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. | @@ -346,6 +350,8 @@ Runs a shell command after the main task succeeds. Workflow-level `verify` acts | `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/test/agentcli.test.js b/test/agentcli.test.js index 2ca3950..f3289a6 100644 --- a/test/agentcli.test.js +++ b/test/agentcli.test.js @@ -7577,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 = {