diff --git a/src/core/orchestrator.ts b/src/core/orchestrator.ts index cadd8abae..6b7acf43e 100644 --- a/src/core/orchestrator.ts +++ b/src/core/orchestrator.ts @@ -71,7 +71,9 @@ import { renderPendingOnboardingPrompt, } from "../onboarding/onboarding.ts"; import { createToolContext, NeedsApproval, CommandDenied } from "../tools/primitives.ts"; -import type { BrokeredLayerTool } from "../deployment/load-layer.ts"; +import { evaluateCommandWithLayer } from "../policy/command-policy.ts"; +import { createSecretValueMasker } from "../security/secret-masking.ts"; +import { shq } from "../util/shell.ts"; import type { FileArtifact } from "../files/file-artifact-store.ts"; import { filterHistoryForAudience, principalEntitledToScope } from "../resolution/context-filter.ts"; import { @@ -922,6 +924,15 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { commandUses.set(key, n - 1); return true; }; + const authorizeCommand = (command: string, approvalKey?: string): boolean => { + let key = approvalKey ?? command; + if (approvalKey !== undefined && commandUses.has(approvalKey)) key = approvalKey; + else if (commandUses.has(command)) key = command; + const n = commandUses.get(key) ?? 0; + if (n <= 0) return false; + commandUses.set(key, n - 1); + return true; + }; const brokeredTools = deps.brokeredTools ?? []; const cutoverModes = new Map(); for (const tool of brokeredTools) { @@ -943,9 +954,15 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { input.origin.kind === "automation" && input.origin.useOwnerKeychain === true; let ownerAuthAvailable = isolateOwnerKeychain; + if ( + deps.sharedOwnerAuthIsolation === true && + conversation.kind !== "dm" && + brokeredTools.some((tool) => cutoverModeOf(tool.service) !== "legacy" && deps.layerBrokerFor?.(tool)) + ) { + ownerAuthAvailable = true; + } const connectorEnv: Record = {}; const ownerAuthEnv: Record = {}; - const brokerVended = new Map }>(); const ownerEnvCredentialIds: string[] = []; const keychainInjected: MaterializedEnvCred[] = []; const credsStart = Date.now(); @@ -1159,17 +1176,9 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { if (!strictReadOnly && actor.type === "internal") { for (const tool of brokeredTools) { const mode = cutoverModeOf(tool.service); + if (mode !== "legacy") continue; const broker = deps.layerBrokerFor?.(tool); if (!broker) { - if (conversation.kind !== "dm" && mode !== "legacy") { - deps.credentialUsage?.record({ - slug: tool.service, - host: "sts.amazonaws.com", - status: mode === "ephemeral_only" ? "ephemeral_failed_closed" : "legacy_fallback", - scopeLabel: scopeId, - principalId: actor.id, - }); - } continue; } const aws = await broker @@ -1184,19 +1193,7 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { AWS_DEFAULT_REGION: aws.region, } : null; - const isolateShared = - deps.sharedOwnerAuthIsolation === true && conversation.kind !== "dm" && mode !== "legacy"; - if (awsEnv && isolateShared) { - brokerVended.set(tool.service, { tool, env: awsEnv }); - ownerAuthAvailable = true; - deps.credentialUsage?.record({ - slug: tool.service, - host: "sts.amazonaws.com", - status: "ephemeral_vended", - scopeLabel: scopeId, - principalId: actor.id, - }); - } else if (awsEnv && (conversation.kind === "dm" || mode === "legacy")) { + if (awsEnv) { Object.assign(connectorEnv, awsEnv); deps.credentialUsage?.record({ slug: tool.service, @@ -1206,13 +1203,10 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { principalId: actor.id, }); } else { - let status = "legacy_unavailable"; - if (mode === "ephemeral_only") status = "ephemeral_failed_closed"; - else if (mode === "prefer_ephemeral") status = "legacy_fallback"; deps.credentialUsage?.record({ slug: tool.service, host: "sts.amazonaws.com", - status, + status: "legacy_unavailable", scopeLabel: scopeId, principalId: actor.id, }); @@ -1230,7 +1224,16 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { swallow("gap-work emit", e); } }; - const commandPolicy = resolution.commandPolicy; + const ephemeralOnlyDenyRules = brokeredTools + .filter((candidate) => cutoverModeOf(candidate.service) === "ephemeral_only") + .map((tool) => ({ + pattern: `(^|[\\s;&|()])${tool.binary.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}($|[\\s;&|()])`, + decision: "deny" as const, + reason: `credential-bearing service ${tool.service} must be run with credential_exec`, + })); + const commandPolicy = ephemeralOnlyDenyRules.length + ? { ...resolution.commandPolicy, rules: [...ephemeralOnlyDenyRules, ...resolution.commandPolicy.rules] } + : resolution.commandPolicy; const layerCommandRules = [...(deps.deploymentLayer?.commandRules ?? [])]; const reachAvailable = !!deps.reachExec && !!deps.directory && conversation.kind === "dm"; const { @@ -1238,6 +1241,7 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { scratchBox, ownerAuthBox, ownerAuthCommand, + scopedCommand, provision, provisionScratch, provisionOwnerAuth, @@ -1263,7 +1267,6 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { ownerAuthAvailable, ownerAuthEnv, ownerEnvCredentialIds, - brokerVended, brokeredTools, quarantinedServices, brokerCutoverServices, @@ -1671,6 +1674,7 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { provisionScratch, ...(provisionOwnerAuth ? { provisionOwnerAuth } : {}), ...(ownerAuthCommand ? { ownerAuthCommand } : {}), + ...(scopedCommand ? { scopedCommand } : {}), ensureSkillTree, ...(reachAvailable ? { @@ -1684,15 +1688,7 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { layers: resolution.layers, commandPolicy: () => commandPolicy, layerCommandRules: () => layerCommandRules, - authorizeCommand: (command: string, approvalKey?: string) => { - let key = approvalKey ?? command; - if (approvalKey !== undefined && commandUses.has(approvalKey)) key = approvalKey; - else if (commandUses.has(command)) key = command; - const n = commandUses.get(key) ?? 0; - if (n <= 0) return false; - commandUses.set(key, n - 1); - return true; - }, + authorizeCommand, grantedHandles: resolution.grantedHandles, sharedMaterializeDir: turnSharedDir, workspace: deps.workspace, @@ -1701,6 +1697,132 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { files: deps.files, auditLog: deps.auditLog, createdBy: actor.id, + ...(() => { + const available = + strictReadOnly || actor.type !== "internal" + ? [] + : brokeredTools.filter( + (tool) => cutoverModeOf(tool.service) !== "legacy" && deps.layerBrokerFor?.(tool), + ); + if (!available.length) return {}; + return { + credentialExecServices: available.map(({ service, binary }) => ({ service, binary })), + credentialExec: async ( + service: string, + args: string[], + opts?: { timeoutSeconds?: number; signal?: AbortSignal }, + ) => { + const tool = available.find((candidate) => candidate.service === service); + if (!tool || cutoverModeOf(service) === "legacy") { + throw new Error(`credential_exec service is unavailable: ${service}`); + } + const broker = deps.layerBrokerFor?.(tool); + if (!broker) throw new Error(`credential_exec broker is unavailable: ${service}`); + const composed = [shq(tool.binary), ...args.map(shq)].join(" "); + const gate = evaluateCommandWithLayer( + composed, + resolution.commandPolicy, + deps.deploymentLayer?.commandRules ?? [], + ); + if (gate.decision === "deny") throw new CommandDenied(composed, gate.reason ?? "denied by policy"); + if (gate.decision === "require_approval" && !authorizeCommand(composed, gate.approvalKey)) { + throw new NeedsApproval( + composed, + gate.reason ?? "requires approval", + "approval", + gate.matched, + gate.approvalKey, + ); + } + let aws; + try { + aws = await broker.credsForActor(actor.id); + } catch { + deps.credentialUsage?.record({ + slug: service, + host: "sts.amazonaws.com", + status: cutoverModeOf(service) === "ephemeral_only" ? "ephemeral_failed_closed" : "legacy_fallback", + scopeLabel: scopeId, + principalId: actor.id, + }); + throw new Error(`credential_exec could not vend credentials for ${service}`); + } + const awsEnv = { + AWS_ACCESS_KEY_ID: aws.accessKeyId, + AWS_SECRET_ACCESS_KEY: aws.secretAccessKey, + AWS_SESSION_TOKEN: aws.sessionToken, + AWS_REGION: aws.region, + AWS_DEFAULT_REGION: aws.region, + }; + const mask = createSecretValueMasker(awsEnv); + let handle; + let result: Awaited> | undefined; + let runError: unknown; + let cleanupError: unknown; + try { + handle = await deps.sandbox.provision( + resolution.layers.filter((layer) => layer.mode === "ro" && layer.mountPath === "global"), + { + env: awsEnv, + egress: resolution.egress, + ...(egressTokenForTurn ? { egressToken: egressTokenForTurn } : {}), + scratch: { key: `credential-exec:${session.id}:${randomUUID()}` }, + routeScopeId: memoryScopeId, + }, + ); + deps.credentialUsage?.record({ + slug: service, + host: "sts.amazonaws.com", + status: "ephemeral_vended", + scopeLabel: scopeId, + principalId: actor.id, + }); + deps.auditLog.record({ + at: Date.now(), + principalId: actor.id, + action: "credential.materialize", + resource: `${service} (ephemeral broker)`, + scopeLabel: scopeId, + }); + const requestedMs = opts?.timeoutSeconds == null ? deps.execTimeoutMs : opts.timeoutSeconds * 1000; + const timeoutMs = + requestedMs != null && deps.execTimeoutCeilingMs != null + ? Math.min(requestedMs, deps.execTimeoutCeilingMs) + : requestedMs; + result = await deps.sandbox.run( + handle, + composed, + timeoutMs !== undefined || opts?.signal + ? { + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + ...(opts?.signal ? { signal: opts.signal } : {}), + } + : undefined, + ); + } catch (error) { + runError = error; + } finally { + if (handle) { + let lastError: unknown; + for (let attempt = 1; attempt <= 3; attempt++) { + try { + await deps.sandbox.teardown(handle, { destroy: true }); + lastError = undefined; + break; + } catch (error) { + lastError = error; + if (attempt < 3) await sleep(50 * attempt); + } + } + cleanupError = lastError; + } + } + if (cleanupError) throw new Error(`credential_exec cleanup failed for ${service}`); + if (runError || !result) throw new Error(`credential_exec failed while running ${service}`); + return { ...result, stdout: mask(result.stdout), stderr: mask(result.stderr) }; + }, + }; + })(), ...(deps.publicWebUrl ? { publicWebUrl: deps.publicWebUrl } : {}), publishContext: { conversationKind: conversation.kind, @@ -2150,6 +2272,7 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { systemCacheBoundary: stableSystemBytes, history: continuation?.history ?? history, tools, + ...(tools.credentialExecServices ? { credentialExecServices: tools.credentialExecServices } : {}), ...(securityPolicy.inboundScreening === "external" && (deps.securityScreener || deps.harness.models.screenSecurity) ? { diff --git a/src/core/orchestrator/sandboxes.ts b/src/core/orchestrator/sandboxes.ts index 67591391d..6592de451 100644 --- a/src/core/orchestrator/sandboxes.ts +++ b/src/core/orchestrator/sandboxes.ts @@ -14,7 +14,6 @@ import { type ResidentAuthConnector, } from "../../credentials/resident-auth.ts"; import { shq } from "../../util/shell.ts"; -import type { BrokeredLayerTool } from "../../deployment/load-layer.ts"; import { createSkillMaterializer, safeSkillDirName } from "../../skills/materialize.ts"; import type { SkillResolution } from "../../skills/skill-store.ts"; import { TURN_FILES_DIR } from "../attachments.ts"; @@ -43,8 +42,7 @@ export interface TurnSandboxContext { ownerAuthAvailable: boolean; ownerAuthEnv: Record; ownerEnvCredentialIds: string[]; - brokerVended: Map }>; - brokeredTools: readonly BrokeredLayerTool[]; + brokeredTools: readonly import("../../deployment/load-layer.ts").BrokeredLayerTool[]; quarantinedServices: string[]; brokerCutoverServices: string[]; cutoverModeOf: (service: string) => DeviceFlowCutoverMode; @@ -76,7 +74,6 @@ export function createTurnSandboxes(ctx: TurnSandboxContext) { ownerAuthAvailable, ownerAuthEnv, ownerEnvCredentialIds, - brokerVended, brokeredTools, quarantinedServices, brokerCutoverServices, @@ -91,6 +88,20 @@ export function createTurnSandboxes(ctx: TurnSandboxContext) { } = ctx; let ownerAuthCommand: ((command: string) => string) | undefined; + const brokerEnvKeys = [ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_REGION", + "AWS_DEFAULT_REGION", + ]; + const unsetBrokerEnv = (env: Record): string => { + const keys = brokerEnvKeys.filter((key) => !(key in env)); + return keys.length ? `unset ${keys.join(" ")}; ` : ""; + }; + const scopedCommand = brokerCutoverServices.length + ? (command: string): string => `${unsetBrokerEnv(connectorEnv)}${command}` + : undefined; if (ownerAuthAvailable) { ownerAuthCommand = (command) => { for (const credentialId of ownerEnvCredentialIds) { @@ -102,30 +113,10 @@ export function createTurnSandboxes(ctx: TurnSandboxContext) { scopeLabel: scopeId, }); } - const invoked = [...brokerVended.values()].filter(({ tool }) => - new RegExp(`(^|[\\s;&|()])${tool.binary}(?=$|[\\s;&|()])`).test(command), - ); - for (const { tool } of invoked) { - deps.auditLog.record({ - at: Date.now(), - principalId: actor.id, - action: "credential.materialize", - resource: `${tool.service} (ephemeral broker)`, - scopeLabel: scopeId, - }); - } const exports = Object.entries(ownerAuthEnv) .map(([key, value]) => `${key}=${shq(value)}`) .join(" "); - const wrappers = invoked - .map(({ tool, env }) => { - const brokerExports = Object.entries(env) - .map(([key, value]) => `${key}=${shq(value)}`) - .join(" "); - return `${tool.binary}() { ${brokerExports} command ${tool.binary} "$@"; }; `; - }) - .join(""); - return `${exports ? `export ${exports}; ` : ""}${wrappers}${command}`; + return `unset AGENT_API_TOKEN AGENT_OAUTH_CONSENT_TOKEN AGENT_CREDENTIAL_TOKEN AGENT_OUTBOX; ${unsetBrokerEnv(ownerAuthEnv)}${exports ? `export ${exports}; ` : ""}${command}`; }; } const box: { @@ -583,6 +574,7 @@ export function createTurnSandboxes(ctx: TurnSandboxContext) { scratchBox, ownerAuthBox, ownerAuthCommand, + scopedCommand, provision, provisionScratch, provisionOwnerAuth, diff --git a/src/harness/claude-harness.ts b/src/harness/claude-harness.ts index 0d4bf6517..e07f15db3 100644 --- a/src/harness/claude-harness.ts +++ b/src/harness/claude-harness.ts @@ -199,7 +199,12 @@ function toolOptions(opts: ClaudeHarnessOptions, turn?: HarnessTurnInput): PiToo backgroundJobTtlMs: opts.backgroundJobTtlMs, backgroundJobTtlMaxMs: opts.backgroundJobTtlMaxMs, ...(turn - ? { readOnly: turn.readOnly, surfaceTools: turn.surfaceTools, surfaceName: turn.surfaceName } + ? { + readOnly: turn.readOnly, + surfaceTools: turn.surfaceTools, + surfaceName: turn.surfaceName, + credentialExecServices: turn.credentialExecServices, + } : { surfaceTools: true, surfaceName: "slack" }), }; } diff --git a/src/harness/codex-harness.ts b/src/harness/codex-harness.ts index e40eaf8f1..cfcb42f2f 100644 --- a/src/harness/codex-harness.ts +++ b/src/harness/codex-harness.ts @@ -232,7 +232,12 @@ function toolOptions(opts: CodexHarnessOptions, turn?: HarnessTurnInput): PiTool backgroundJobTtlMs: opts.backgroundJobTtlMs, backgroundJobTtlMaxMs: opts.backgroundJobTtlMaxMs, ...(turn - ? { readOnly: turn.readOnly, surfaceTools: turn.surfaceTools, surfaceName: turn.surfaceName } + ? { + readOnly: turn.readOnly, + surfaceTools: turn.surfaceTools, + surfaceName: turn.surfaceName, + credentialExecServices: turn.credentialExecServices, + } : { surfaceTools: true, surfaceName: "slack" }), }; } diff --git a/src/harness/harness.ts b/src/harness/harness.ts index 809909c73..970642a81 100644 --- a/src/harness/harness.ts +++ b/src/harness/harness.ts @@ -66,6 +66,7 @@ export interface HarnessTurnInput { systemCacheBoundary?: number; history: SessionEntry[]; tools: ToolContext; + credentialExecServices?: readonly { service: string; binary: string }[]; screenExternalContent?(input: { content: string; tool: string; diff --git a/src/harness/mock-harness.ts b/src/harness/mock-harness.ts index d9bbc39f8..73718230a 100644 --- a/src/harness/mock-harness.ts +++ b/src/harness/mock-harness.ts @@ -19,6 +19,7 @@ const READ_ONLY_BLOCKED_PREFIXES = [ "!run ", "!scratch ", "!owner ", + "!credential ", "!reach ", "!paused-approval ", "!collect-approval ", @@ -262,6 +263,22 @@ export function createMockHarness(): Harness { scopeLabel: turn.scopeLabel, }); reply = "thought about it"; + } else if (command0.startsWith("!credential ")) { + const rest = command0.slice("!credential ".length); + const split = rest.indexOf(" "); + const service = split === -1 ? rest : rest.slice(0, split); + const args = split === -1 ? [] : (JSON.parse(rest.slice(split + 1)) as string[]); + if (!turn.tools.credentialExec) throw new Error("credential_exec unavailable"); + await turn.emit({ + type: "tool_call", + payload: { tool: "credential_exec", service, args }, + scopeLabel: turn.scopeLabel, + }); + const result = await turn.tools.credentialExec(service, args); + await turn.emit({ type: "tool_result", payload: result, scopeLabel: turn.scopeLabel }); + turn.onProgress?.({ toolCalls: 1 }); + usedTool = true; + reply = result.stdout.trim() || result.stderr.trim() || `(exit ${result.code})`; } else if (command0.startsWith("!run ") || command0.startsWith("!scratch ") || command0.startsWith("!owner ")) { let tag = "!run "; if (command0.startsWith("!scratch ")) tag = "!scratch "; diff --git a/src/harness/opencode-harness.ts b/src/harness/opencode-harness.ts index df75a8e8d..db90b963a 100644 --- a/src/harness/opencode-harness.ts +++ b/src/harness/opencode-harness.ts @@ -109,7 +109,12 @@ function toolOptions(opts: OpenCodeHarnessOptions, turn?: HarnessTurnInput): PiT backgroundJobTtlMs: opts.backgroundJobTtlMs, backgroundJobTtlMaxMs: opts.backgroundJobTtlMaxMs, ...(turn - ? { readOnly: turn.readOnly, surfaceTools: turn.surfaceTools, surfaceName: turn.surfaceName } + ? { + readOnly: turn.readOnly, + surfaceTools: turn.surfaceTools, + surfaceName: turn.surfaceName, + credentialExecServices: turn.credentialExecServices, + } : { surfaceTools: true, surfaceName: "slack" }), }; } diff --git a/src/harness/pi-harness.ts b/src/harness/pi-harness.ts index 0c8a96b34..5355a8f32 100644 --- a/src/harness/pi-harness.ts +++ b/src/harness/pi-harness.ts @@ -1255,6 +1255,7 @@ export function createPiHarness(opts?: PiHarnessOptions): Harness { surfaceTools?: boolean, surfaceName?: string, turnScope?: ScopeId, + credentialExecServices?: readonly { service: string; binary: string }[], tapeRows?: TapeRecord[], tapeMode?: "shadow" | "serve", tapeFold?: unknown[], @@ -1314,6 +1315,7 @@ export function createPiHarness(opts?: PiHarnessOptions): Harness { ownerAuthExec, reachExec, controlTools, + ...(credentialExecServices?.length ? { credentialExecServices } : {}), ...(surfaceTools ? { surfaceTools: true } : {}), ...(surfaceName ? { surfaceName } : {}), ...(readOnly ? { readOnly: true } : {}), @@ -1470,6 +1472,7 @@ export function createPiHarness(opts?: PiHarnessOptions): Harness { turn.surfaceTools, turn.surfaceName, turn.scopeLabel, + turn.credentialExecServices, turn.tapeRows, turn.tapeMode, turn.tapeFold, diff --git a/src/harness/pi-tools.ts b/src/harness/pi-tools.ts index 778772611..9a189f958 100644 --- a/src/harness/pi-tools.ts +++ b/src/harness/pi-tools.ts @@ -230,6 +230,7 @@ function fmtCronRunLine(entry: CronFireLogEntry): string { } export interface PiToolsOptions { + credentialExecServices?: readonly { service: string; binary: string }[]; scratchExec?: boolean; ownerAuthExec?: boolean; reachExec?: boolean; @@ -279,6 +280,7 @@ export function createPiTools(ref: ToolContextRef, opts?: PiToolsOptions): ToolD const ownerAuthExec = !!opts?.ownerAuthExec; const reachExec = !!opts?.reachExec; const controlTools = !!opts?.controlTools; + const credentialExecServices = opts?.credentialExecServices ?? ref.current?.credentialExecServices ?? []; const surfaceTools = !!opts?.surfaceTools; const execTimeoutSec = Math.round((opts?.execTimeoutMs ?? CONFIG_DEFAULTS.execTimeoutDefaultSec * 1000) / 1000); const execCeilingSec = Math.round((opts?.execTimeoutCeilingMs ?? CONFIG_DEFAULTS.execTimeoutMaxSec * 1000) / 1000); @@ -2394,8 +2396,78 @@ export function createPiTools(ref: ToolContextRef, opts?: PiToolsOptions): ToolD }, }); + const credentialExec = defineTool({ + name: "credential_exec", + label: "Credential exec", + description: + "Run a configured credential-bearing CLI in a one-shot isolated box. Shell operators and pipelines are not supported; args are passed literally. Available services: " + + credentialExecServices.map(({ service, binary }) => `${service} (${binary})`).join(", "), + parameters: Type.Object({ + service: Type.String({ enum: credentialExecServices.map(({ service }) => service) }), + args: Type.Array(Type.String()), + timeout_seconds: Type.Optional(Type.Integer({ minimum: 1, maximum: execCeilingSec })), + }), + async execute(callId, params: { service: string; args: string[]; timeout_seconds?: number }) { + const tc = ref.current; + await recordCall(callId, { tool: "credential_exec", service: params.service, args: params.args }); + if (!tc?.credentialExec) { + return recordResult( + callId, + { tool: "credential_exec", unavailable: true }, + text("[error] credential_exec is unavailable on this turn"), + true, + ); + } + try { + const result = await tc.credentialExec(params.service, params.args, { + ...(params.timeout_seconds !== undefined ? { timeoutSeconds: params.timeout_seconds } : {}), + ...(ref.abortSignal ? { signal: ref.abortSignal } : {}), + }); + const parts = [result.stdout, result.stderr ? `[stderr]\n${result.stderr}` : ""].filter(Boolean).join("\n"); + return recordResult( + callId, + { tool: "credential_exec", service: params.service, ...result }, + text(`${parts}\n[exit ${result.code}${result.timedOut ? " timed-out" : ""}]`), + result.code !== 0, + ); + } catch (error) { + if (error instanceof NeedsApproval) { + ref.pendingApprovals?.push({ + command: error.command, + reason: error.approvalReason, + kind: error.kind, + matched: error.matched, + ...(error.approvalKey ? { approvalKey: error.approvalKey } : {}), + }); + ref.pausedOnApproval = true; + return recordResult( + callId, + { tool: "credential_exec", blocked: "needs_approval", reason: error.approvalReason }, + { ...text(`[blocked: needs human approval] ${error.approvalReason}`), terminate: true }, + true, + ); + } + if (error instanceof CommandDenied) { + return recordResult( + callId, + { tool: "credential_exec", denied: true, reason: error.message }, + text(`[denied by policy] ${error.message}`), + true, + ); + } + return recordResult( + callId, + { tool: "credential_exec", service: params.service, failed: true }, + text(`[error] ${errMessage(error)}`), + true, + ); + } + }, + }); + const tools = [ execute, + ...(credentialExecServices.length ? [credentialExec] : []), read, write, publish, diff --git a/src/tools/primitives.ts b/src/tools/primitives.ts index e764278e5..43b6bc93b 100644 --- a/src/tools/primitives.ts +++ b/src/tools/primitives.ts @@ -144,6 +144,12 @@ interface ReachedProvenance { } export interface ToolContext extends SurfaceToolDeps { + credentialExecServices?: readonly { service: string; binary: string }[]; + credentialExec?( + service: string, + args: string[], + opts?: { timeoutSeconds?: number; signal?: AbortSignal }, + ): Promise; execute( command: string, opts?: { @@ -336,10 +342,13 @@ export const CONTROL_UNAVAILABLE: ControlUnavailable = { export interface ToolContextDeps { sandbox: Sandbox; + credentialExecServices?: readonly { service: string; binary: string }[]; + credentialExec?: ToolContext["credentialExec"]; provision: () => Promise; provisionScratch?: () => Promise; provisionOwnerAuth?: () => Promise; ownerAuthCommand?: (command: string) => string; + scopedCommand?: (command: string) => string; ensureSkillTree?: (skillDir: string) => Promise; reach?: { resolveChannel(query: string): Promise; @@ -443,6 +452,8 @@ export function createToolContext(deps: ToolContextDeps): ToolContext { } return { + ...(deps.credentialExecServices ? { credentialExecServices: deps.credentialExecServices } : {}), + ...(deps.credentialExec ? { credentialExec: deps.credentialExec } : {}), async execute( command: string, execOpts?: { @@ -519,7 +530,9 @@ export function createToolContext(deps: ToolContextDeps): ToolContext { }); } return timed("exec", async () => { - const sandboxCommand = ownerAuth && deps.ownerAuthCommand ? deps.ownerAuthCommand(command) : command; + const sandboxCommand = ownerAuth + ? (deps.ownerAuthCommand?.(command) ?? command) + : (deps.scopedCommand?.(command) ?? command); const r = await deps.sandbox.run(handle, sandboxCommand, opts); return reached ? { ...r, reached } : r; }); @@ -803,7 +816,12 @@ export function createToolContext(deps: ToolContextDeps): ToolContext { for (const skillDir of skillTreeDirsInCommand(command)) await deps.ensureSkillTree(skillDir); } return once( - () => deps.backgroundBroker!.start(handle, command, opts?.ttlSeconds ? opts.ttlSeconds * 1000 : undefined), + () => + deps.backgroundBroker!.start( + handle, + deps.scopedCommand?.(command) ?? command, + opts?.ttlSeconds ? opts.ttlSeconds * 1000 : undefined, + ), () => true, ); }, diff --git a/test/device-flow-persist.test.ts b/test/device-flow-persist.test.ts index 06d619676..f7bf2bc8c 100644 --- a/test/device-flow-persist.test.ts +++ b/test/device-flow-persist.test.ts @@ -44,13 +44,15 @@ function sprites() { } const rw = (scope: string) => [{ scopeId: scope, mountPath: "", mode: "rw" as const }]; -function acmecliBrokeredLayer(): string { +function acmecliBrokeredLayer(binary?: string, approvals?: Array<{ pattern: string; reason?: string }>): string { const dir = mkdtempSync(join(tmpdir(), "dfp-layer-")); mkdirSync(join(dir, "tools/acmecli"), { recursive: true }); writeFileSync( join(dir, "tools/acmecli/tool.json"), JSON.stringify({ id: "acmecli", + ...(binary ? { install: { binary } } : {}), + ...(approvals ? { approvals } : {}), auth: { check: "acmecli me", reauth: "acmecli login --use-device-code", @@ -73,6 +75,187 @@ test("deviceFlowCredOwner: the person on their own personal box, the scope on a assert.equal(deviceFlowCredOwner(scopeId("personal", "U2"), "U1"), scopeId("personal", "U2")); }); +test("personal ephemeral-only credentials run only through credential_exec and are redacted", async () => { + let assumes = 0; + const sentinels = { + access: "AKIA_CREDENTIAL_EXEC_SENTINEL", + secret: "credential_exec_secret_sentinel", + token: "credential_exec_session_sentinel", + }; + const built = buildApp( + testConfig({ + dataDir: mkdtempSync(join(tmpdir(), "dfp-credential-exec-")), + signingSecret: "device-flow-test-secret", + deploymentLayerDir: acmecliBrokeredLayer("env"), + }), + { + credentialBrokers: { + acmecli: createAwsRoleBroker({ + roleArn: "arn:aws:iam::123456789012:role/acmecli-broker", + region: "us-west-2", + sessionActions: ["execute-api:Invoke"], + assumeRole: async () => { + assumes++; + return { + Credentials: { + AccessKeyId: sentinels.access, + SecretAccessKey: sentinels.secret, + SessionToken: sentinels.token, + Expiration: new Date(Date.now() + 3_600_000), + }, + }; + }, + }), + }, + }, + ); + const personal = scopeId("personal", actor.externalId); + const conversation = { + kind: "dm" as const, + threadRef: "dm:credential-exec", + audience: [actor], + }; + await built.deviceFlowCutover.set(personal, "acmecli", "ephemeral_only", "security@example.com"); + const ambient = await built.app.turn({ + surface: "slack", + actor, + conversation, + text: "!run printf '%s' \"${AWS_ACCESS_KEY_ID-unset}\"", + }); + assert.equal(ambient.reply, "unset"); + assert.equal(assumes, 0); + const direct = await built.app.turn({ surface: "slack", actor, conversation, text: "!run env" }); + assert.match(`${direct.reason ?? ""} ${direct.reply ?? ""}`, /credential_exec/); + const brokered = await built.app.turn({ + surface: "slack", + actor, + conversation, + text: "!credential acmecli []", + }); + assert.equal(assumes, 1); + assert.match(brokered.reply ?? "", //); + assert.match(brokered.reply ?? "", //); + assert.match(brokered.reply ?? "", //); + for (const value of Object.values(sentinels)) assert.doesNotMatch(brokered.reply ?? "", new RegExp(value)); + const durable = JSON.stringify(await built.sessions.getEntries(brokered.sessionId!)); + for (const value of Object.values(sentinels)) assert.doesNotMatch(durable, new RegExp(value)); + assert.equal( + ff.names().some((name) => name.includes("credential-exec")), + false, + ); +}); + +test("credential_exec honors deployment approval rules before vending credentials", async () => { + let assumes = 0; + const built = buildApp( + testConfig({ + dataDir: mkdtempSync(join(tmpdir(), "dfp-credexec-approval-")), + signingSecret: "device-flow-test-secret", + deploymentLayerDir: acmecliBrokeredLayer("env", [ + { pattern: "\\benv\\b\\s+tool\\b", reason: "mutating subcommand" }, + ]), + }), + { + credentialBrokers: { + acmecli: createAwsRoleBroker({ + roleArn: "arn:aws:iam::123456789012:role/acmecli-broker", + region: "us-west-2", + sessionActions: ["execute-api:Invoke"], + assumeRole: async () => { + assumes++; + return { + Credentials: { + AccessKeyId: "AKIA_APPROVAL_GATE", + SecretAccessKey: "approval_gate_secret_value", + SessionToken: "approval_gate_session_token", + Expiration: new Date(Date.now() + 3_600_000), + }, + }; + }, + }), + }, + }, + ); + const personal = scopeId("personal", actor.externalId); + const conversation = { kind: "dm" as const, threadRef: "dm:credexec-approval", audience: [actor] }; + await built.deviceFlowCutover.set(personal, "acmecli", "ephemeral_only", "security@example.com"); + + const gated = await built.app.turn({ + surface: "slack", + actor, + conversation, + text: '!credential acmecli ["tool","delete"]', + }); + assert.equal(gated.status, "pending_approval"); + assert.equal(assumes, 0, "no AssumeRole call happens for a blocked command"); + const pending = gated.pendingApprovals![0]!; + assert.match(pending.reason, /mutating subcommand/); + + const approved = await built.app.turn({ + surface: "slack", + actor, + conversation, + text: '!credential acmecli ["tool","delete"]', + approval: { requestId: pending.requestId, approved: true }, + }); + assert.equal(approved.status, "ok", approved.reason); + assert.equal(assumes, 1, "approval unblocks exactly one vended invocation"); + + const unrelated = await built.app.turn({ + surface: "slack", + actor, + conversation: { ...conversation, threadRef: "dm:credexec-approval-3" }, + text: '!credential acmecli ["me"]', + }); + assert.equal(unrelated.status, "ok", "subcommands without approval rules run without a grant"); + assert.equal(assumes, 1, "the broker's per-actor credential cache is reused within its TTL"); +}); + +test("a scope allow rule cannot override the ephemeral_only direct-execution deny", async () => { + const built = buildApp( + testConfig({ + dataDir: mkdtempSync(join(tmpdir(), "dfp-credexec-scope-allow-")), + signingSecret: "device-flow-test-secret", + deploymentLayerDir: acmecliBrokeredLayer("env"), + }), + { + credentialBrokers: { + acmecli: createAwsRoleBroker({ + roleArn: "arn:aws:iam::123456789012:role/acmecli-broker", + region: "us-west-2", + sessionActions: ["execute-api:Invoke"], + assumeRole: async () => ({ + Credentials: { + AccessKeyId: "AKIA_SCOPE_ALLOW", + SecretAccessKey: "scope_allow_secret_value", + SessionToken: "scope_allow_session_token", + Expiration: new Date(Date.now() + 3_600_000), + }, + }), + }), + }, + }, + ); + const personal = scopeId("personal", actor.externalId); + built.config.setCommandPolicy(personal, { + mode: "denylist", + rules: [{ pattern: "\\benv\\b", decision: "allow" }], + }); + const conversation = { kind: "dm" as const, threadRef: "dm:credexec-scope-allow", audience: [actor] }; + await built.deviceFlowCutover.set(personal, "acmecli", "ephemeral_only", "security@example.com"); + + const direct = await built.app.turn({ surface: "slack", actor, conversation, text: "!run env" }); + assert.match(`${direct.reason ?? ""} ${direct.reply ?? ""}`, /credential_exec/); + + const sanctioned = await built.app.turn({ + surface: "slack", + actor, + conversation: { ...conversation, threadRef: "dm:credexec-scope-allow-2" }, + text: "!credential acmecli []", + }); + assert.equal(sanctioned.status, "ok", sanctioned.reason); +}); + test("capture saves changed login bundles per service and fingerprint-skips unchanged ones", async () => { const sb = sprites(); const k = kc(); @@ -483,8 +666,8 @@ test("shared ACMECLI cutover isolates brokered STS without shrinking the existin }); assert.equal( brokeredAcmecli.reply, - "AKIA_BOB_GENERAL|AKIA_BOB", - "only ACMECLI gets brokered identity; adjacent AWS work keeps Bob's general authority", + "AKIA_BOB_GENERAL|AKIA_BOB_GENERAL", + "prefer-ephemeral direct execution retains the owner's legacy fallback without broker vending", ); const unpoisoned = await built.app.turn({ @@ -500,8 +683,9 @@ test("shared ACMECLI cutover isolates brokered STS without shrinking the existin assert.ok( ownerAudit.some((event) => event.action === "keychain.materialize" && event.resource.includes("owner-auth box")), ); - assert.ok( - ownerAudit.some((event) => event.action === "credential.materialize" && event.resource.includes("acmecli")), + assert.equal( + ownerAudit.some((event) => event.action === "credential.materialize"), + false, ); const scoped = await built.app.turn({ @@ -545,8 +729,8 @@ test("shared ACMECLI cutover isolates brokered STS without shrinking the existin assert.equal(aliceAcmecli.status, "ok", aliceAcmecli.reason); assert.equal( aliceAcmecli.reply, - "AKIA_ALICE|unset|absent", - "ambient shared-room ACMECLI keeps the acting user's identity without Bob's keychain", + "|unset|absent", + "direct execution has no brokered identity and no access to Bob's keychain", ); assert.equal( ff.names().some((n) => n.includes("scratch")), @@ -584,8 +768,10 @@ test("shared ACMECLI cutover isolates brokered STS without shrinking the existin "ephemeral-only removes already-materialized legacy files without deleting the stored record", ); const acmecliUsage = await built.credentialUsage.list({ slug: "acmecli" }); - assert.ok(acmecliUsage.some((row) => row.status === "ephemeral_vended" && row.principalId === "BOB")); - assert.ok(acmecliUsage.some((row) => row.status === "ephemeral_vended" && row.principalId === "ALICE")); + assert.equal( + acmecliUsage.some((row) => row.status === "ephemeral_vended"), + false, + ); const legacyUsage = await built.credentialUsage.list({ slug: "keychain:acmecli" }); assert.ok( legacyUsage.some((row) => row.status === "legacy_retained"), @@ -742,6 +928,15 @@ test("prefer-isolated keeps legacy ACMECLI when STS vending fails; isolated-only text: "!run cat ~/.acmecli/session.json", }); assert.equal(fallback.reply, "legacy_ok"); + await assert.rejects( + built.app.turn({ + surface: "slack", + actor, + conversation: { ...conversation, threadRef: "ch:C-acmecli-fallback:prefer-broker" }, + text: "!credential acmecli []", + }), + /could not vend credentials/, + ); await built.deviceFlowCutover.set(room, "acmecli", "ephemeral_only", "security@example.com"); const closed = await built.app.turn({ @@ -764,6 +959,15 @@ test("prefer-isolated keeps legacy ACMECLI when STS vending fails; isolated-only "absent", "isolated-only never restores an owner's ambient ACMECLI after broker failure", ); + await assert.rejects( + built.app.turn({ + surface: "slack", + actor, + conversation: { ...conversation, threadRef: "ch:C-acmecli-fallback:only-broker" }, + text: "!credential acmecli []", + }), + /could not vend credentials/, + ); const usage = await built.credentialUsage.list({ slug: "acmecli" }); assert.ok(usage.some((row) => row.status === "legacy_fallback")); assert.ok(usage.some((row) => row.status === "ephemeral_failed_closed")); diff --git a/test/pi-tools.test.ts b/test/pi-tools.test.ts index 5e99d0f80..661ebe131 100644 --- a/test/pi-tools.test.ts +++ b/test/pi-tools.test.ts @@ -1178,6 +1178,72 @@ test("execute forwards the agent's timeout_seconds into tc.execute; omitting it assert.equal(sink.lastExecOpts, undefined); }); +test("credential_exec is turn-scoped, typed, and forwards only service plus literal argv", async () => { + const calls: unknown[] = []; + const tc: ToolContext = { + ...fakeToolContext(), + credentialExecServices: [{ service: "acme", binary: "acmecli" }], + async credentialExec(service, args, opts) { + calls.push({ service, args, opts }); + return { stdout: "authenticated", stderr: "", code: 0, timedOut: false }; + }, + }; + const absent = createPiTools({ current: fakeToolContext() }); + assert.equal( + absent.some((tool) => tool.name === "credential_exec"), + false, + ); + const tools = createPiTools( + { current: tc }, + { credentialExecServices: tc.credentialExecServices, execTimeoutCeilingMs: 10_000 }, + ); + const tool = tools.find((candidate) => candidate.name === "credential_exec")!; + assert.match(tool.description, /acme \(acmecli\)/); + assert.match(tool.description, /Shell operators and pipelines are not supported/); + const args = ["; env", "$(env)", "a|b", "> out", "two words"]; + const result = await call(tool, { service: "acme", args, timeout_seconds: 7 }); + assert.deepEqual(calls, [{ service: "acme", args, opts: { timeoutSeconds: 7 } }]); + assert.match((result as { content: Array<{ text: string }> }).content[0]!.text, /authenticated/); +}); + +test("credential_exec surfaces NeedsApproval and CommandDenied like execute", async () => { + const gated: ToolContext = { + ...fakeToolContext(), + credentialExecServices: [{ service: "acme", binary: "acmecli" }], + async credentialExec() { + throw new NeedsApproval("'acmecli' 'tool'", "mutating subcommand", "approval", "tool", "\\bacmecli\\s+tool\\b"); + }, + }; + const ref = { current: gated, pendingApprovals: [] as NonNullable }; + const tool = createPiTools(ref, { credentialExecServices: gated.credentialExecServices }).find( + (candidate) => candidate.name === "credential_exec", + )!; + const blocked = (await call(tool, { service: "acme", args: ["tool"] })) as { + content: Array<{ text: string }>; + terminate?: boolean; + }; + assert.match(blocked.content[0]!.text, /needs human approval/); + assert.equal(blocked.terminate, true); + assert.equal(ref.pendingApprovals.length, 1); + assert.equal(ref.pendingApprovals[0]!.approvalKey, "\\bacmecli\\s+tool\\b"); + assert.equal((ref as { pausedOnApproval?: boolean }).pausedOnApproval, true); + + const denied: ToolContext = { + ...fakeToolContext(), + credentialExecServices: [{ service: "acme", binary: "acmecli" }], + async credentialExec() { + throw new CommandDenied("'acmecli'", "must be run with credential_exec"); + }, + }; + const deniedTool = createPiTools({ current: denied }, { credentialExecServices: denied.credentialExecServices }).find( + (candidate) => candidate.name === "credential_exec", + )!; + const deniedResult = (await call(deniedTool, { service: "acme", args: [] })) as { + content: Array<{ text: string }>; + }; + assert.match(deniedResult.content[0]!.text, /denied by policy/); +}); + test('execute scope:"owner" routes only when the owner-auth surface is enabled', async () => { const sink: { lastExecOpts?: Parameters[1] } = {}; const execute = createPiTools({ current: fakeToolContext(sink) }, { ownerAuthExec: true })[0]!;