Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 162 additions & 39 deletions src/core/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, DeviceFlowCutoverMode>();
for (const tool of brokeredTools) {
Expand All @@ -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<string, string> = {};
const ownerAuthEnv: Record<string, string> = {};
const brokerVended = new Map<string, { tool: BrokeredLayerTool; env: Record<string, string> }>();
const ownerEnvCredentialIds: string[] = [];
const keychainInjected: MaterializedEnvCred[] = [];
const credsStart = Date.now();
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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,
});
Expand All @@ -1230,14 +1224,24 @@ 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 {
box,
scratchBox,
ownerAuthBox,
ownerAuthCommand,
scopedCommand,
provision,
provisionScratch,
provisionOwnerAuth,
Expand All @@ -1263,7 +1267,6 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator {
ownerAuthAvailable,
ownerAuthEnv,
ownerEnvCredentialIds,
brokerVended,
brokeredTools,
quarantinedServices,
brokerCutoverServices,
Expand Down Expand Up @@ -1671,6 +1674,7 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator {
provisionScratch,
...(provisionOwnerAuth ? { provisionOwnerAuth } : {}),
...(ownerAuthCommand ? { ownerAuthCommand } : {}),
...(scopedCommand ? { scopedCommand } : {}),
ensureSkillTree,
...(reachAvailable
? {
Expand All @@ -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,
Expand All @@ -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<ReturnType<typeof deps.sandbox.run>> | 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,
Expand Down Expand Up @@ -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)
? {
Expand Down
42 changes: 17 additions & 25 deletions src/core/orchestrator/sandboxes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -43,8 +42,7 @@ export interface TurnSandboxContext {
ownerAuthAvailable: boolean;
ownerAuthEnv: Record<string, string>;
ownerEnvCredentialIds: string[];
brokerVended: Map<string, { tool: BrokeredLayerTool; env: Record<string, string> }>;
brokeredTools: readonly BrokeredLayerTool[];
brokeredTools: readonly import("../../deployment/load-layer.ts").BrokeredLayerTool[];
quarantinedServices: string[];
brokerCutoverServices: string[];
cutoverModeOf: (service: string) => DeviceFlowCutoverMode;
Expand Down Expand Up @@ -76,7 +74,6 @@ export function createTurnSandboxes(ctx: TurnSandboxContext) {
ownerAuthAvailable,
ownerAuthEnv,
ownerEnvCredentialIds,
brokerVended,
brokeredTools,
quarantinedServices,
brokerCutoverServices,
Expand All @@ -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, string>): 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) {
Expand All @@ -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: {
Expand Down Expand Up @@ -583,6 +574,7 @@ export function createTurnSandboxes(ctx: TurnSandboxContext) {
scratchBox,
ownerAuthBox,
ownerAuthCommand,
scopedCommand,
provision,
provisionScratch,
provisionOwnerAuth,
Expand Down
7 changes: 6 additions & 1 deletion src/harness/claude-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }),
};
}
Expand Down
Loading