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
5 changes: 5 additions & 0 deletions services/cloud-agent-next/docs/control-plane.md
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,11 @@ path `/workspace/app`. A snapshot holds one repository at one path, so any other
the wrapper sends the original hello on the same socket. A legacy welcome enables periodic
heartbeats without an acknowledgement deadline; old wrappers receive neither the optional field
nor acknowledgement frames. Duplicate hello on a bound socket is ignored.
- A wrapper that redacts named secrets advertises optional `redactsNamedSecrets: true` in `hello`. A
`session.prepare` frame may carry frame-only `secretEnvKeys`, the names of `env` entries whose
values the wrapper redacts in setup output and auto-commit diagnostics even when the name does not
look secret. The field is never stored in a route row, and a frame without it stays valid for a
version-3 wrapper.

- Connect, send `hello` (`wrapperId`, `allocationId`, protocol version), wait for `welcome` or
`shutdown`. On `shutdown`, permanently close the connection and exit with code 0, even when the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { beforeAll, describe, expect, it } from 'vitest';
import { CurrentSessionMetadataSchema } from '../../persistence/session-metadata.js';
import {
controlPlanePrepareInputSchema,
controlPlaneRegistrationRouteSpecSchema,
controlPlaneRouteSpecSchema,
} from '../../shared/control-plane-protocol.js';
import { encryptWithPublicKey } from '../../utils/encryption.js';
Expand Down Expand Up @@ -355,6 +356,18 @@ describe('controlPlaneRouteSpecSchema MCP boundaries', () => {
expect(parsed.success).toBe(false);
});

it('rejects frame-only secretEnvKeys at the registration boundary', () => {
const routeSpec = {
sessionId: 'workspace_12345678-1234-1234-1234-123456789abc',
kiloSessionId: 'ses_12345678901234567890123456',
directory: '/tmp/worktree',
attemptId: 'attempt-1',
secretEnvKeys: ['DATABASE_URL'],
};
expect(controlPlaneRouteSpecSchema.safeParse(routeSpec).success).toBe(true);
expect(controlPlaneRegistrationRouteSpecSchema.safeParse(routeSpec).success).toBe(false);
});

it('rejects a UTF-8 payload over the 80 KiB byte limit', () => {
// Eleven headers of 3000 three-byte characters each: every value is under
// the 8192-character cap, but the serialized UTF-8 bytes exceed 80 KiB.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ import {
controlPlaneEventsNotificationSchema,
controlPlaneOutcomeSchema,
controlPlanePromptPayloadSchema,
controlPlaneRouteSpecSchema,
controlPlaneRegistrationRouteSpecSchema,
controlPlaneRouteUpdateSchema,
controlPlaneRouteViewSchema,
controlPlaneSetupEventSchema,
Expand Down Expand Up @@ -240,7 +240,7 @@ function persistedRouteView(
export const controlPlaneSessionRegistrationSchema = z
.object({
sandboxId: z.string().min(1).max(256),
spec: controlPlaneRouteSpecSchema,
spec: controlPlaneRegistrationRouteSpecSchema,
credentials: controlPlaneCredentialSourceSchema,
/** Worker-selected provider pin (H1/H2); the DO applies it, never re-decides it. */
sandboxSelection: controlPlaneSandboxSelectionSchema.optional(),
Expand Down
47 changes: 47 additions & 0 deletions services/cloud-agent-next/src/session-service.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { generateKeyPairSync } from 'node:crypto';
import { relative } from 'node:path';
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import type * as GitTokenServiceClientModule from './services/git-token-service-client.js';
import { validateWrapperDispatchTicket } from './auth.js';
import { deriveKiloSandboxTargets } from './kilo/kilo-targets.js';
import jwt from 'jsonwebtoken';
import { ExecutionError } from './execution/errors.js';
import { encryptWithPublicKey } from './utils/encryption.js';
import {
createPendingSessionMessage,
recordPendingFlushFailure,
Expand Down Expand Up @@ -3617,6 +3619,51 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => {
expect(materialized.PROFILE_SECRET).toBeUndefined();
});

it('advertises decrypted secret env key names on the materialized config', async () => {
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
const metadata = createMetadata({
profile: {
envVars: { PUBLIC_VALUE: 'visible' },
encryptedSecrets: { DATABASE_URL: encryptWithPublicKey('db-from-profile', publicKey) },
},
});

const result = await buildPromptWrapperRequests(metadata, env => {
env.AGENT_ENV_VARS_PRIVATE_KEY = privateKey;
});

expect(result.readyRequest.materialized.env.DATABASE_URL).toBe('db-from-profile');
expect(result.readyRequest.materialized.env.PUBLIC_VALUE).toBe('visible');
expect(result.readyRequest.materialized.secretEnvKeys).toEqual(['DATABASE_URL']);
});

it('does not advertise a decrypted secret the platform overwrote', async () => {
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
const metadata = createMetadata({
profile: {
encryptedSecrets: {
DATABASE_URL: encryptWithPublicKey('db-from-profile', publicKey),
KILOCODE_TOKEN: encryptWithPublicKey('secret-kilo-token', publicKey),
},
},
});

const result = await buildPromptWrapperRequests(metadata, env => {
env.AGENT_ENV_VARS_PRIVATE_KEY = privateKey;
});

expect(result.readyRequest.materialized.env.KILOCODE_TOKEN).not.toBe('secret-kilo-token');
expect(result.readyRequest.materialized.secretEnvKeys).toEqual(['DATABASE_URL']);
});

it('materializes fixed Bitbucket CLI environment for ordinary Bitbucket sessions', async () => {
const result = await buildPromptWrapperRequests(createBitbucketMetadata(false));
const materialized = result.readyRequest.materialized.env;
Expand Down
88 changes: 53 additions & 35 deletions services/cloud-agent-next/src/session-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1098,10 +1098,13 @@ export class SessionService {
bitbucketWorkspaceUuid: context.bitbucketWorkspaceUuid,
bitbucketRepositoryUuid: context.bitbucketRepositoryUuid,
profile: effectiveProfile,
});
}).env;
}

private getSaferEnvVars(opts: GetSaferEnvVarsOptions): Record<string, string> {
private getSaferEnvVars(opts: GetSaferEnvVarsOptions): {
env: Record<string, string>;
secretEnvKeys: string[];
} {
const {
sessionHome,
sessionId,
Expand Down Expand Up @@ -1159,6 +1162,8 @@ export class SessionService {

// Bitbucket Code Reviewer sessions use only trusted worker-owned environment values.
let baseEnvVars = isBitbucketCodeReview ? {} : { ...userEnvVars };
let decryptedSecretKeys: string[] = [];
let decryptedSecretValues: Record<string, string> = {};

if (!isBitbucketCodeReview && encryptedSecrets && Object.keys(encryptedSecrets).length > 0) {
const privateKey = env.AGENT_ENV_VARS_PRIVATE_KEY;
Expand All @@ -1167,7 +1172,12 @@ export class SessionService {
'Encrypted secrets provided but AGENT_ENV_VARS_PRIVATE_KEY is not configured on the worker'
);
}
baseEnvVars = mergeEnvVarsWithSecrets(baseEnvVars, encryptedSecrets, privateKey);
const mergedSecrets = mergeEnvVarsWithSecrets(baseEnvVars, encryptedSecrets, privateKey);
baseEnvVars = mergedSecrets;
decryptedSecretKeys = Object.keys(encryptedSecrets);
decryptedSecretValues = Object.fromEntries(
decryptedSecretKeys.map(key => [key, mergedSecrets[key]])
);
logger
.withTags({ secretCount: Object.keys(encryptedSecrets).length })
.info('Decrypted and merged encrypted secrets');
Expand Down Expand Up @@ -1494,7 +1504,12 @@ export class SessionService {
envVars.KILO_SESSION_INGEST_URL = env.KILO_SESSION_INGEST_URL;
}

return envVars;
return {
env: envVars,
// A later grant step may have overwritten a decrypted secret; only what is
// still that secret's value is safe to advertise for redaction.
secretEnvKeys: decryptedSecretKeys.filter(key => envVars[key] === decryptedSecretValues[key]),
};
}

/**
Expand Down Expand Up @@ -2001,37 +2016,39 @@ export class SessionService {
platform,
});

const materializedEnv = this.getSaferEnvVars({
sessionHome,
sessionId,
workspacePath,
env,
kiloCapability,
kiloBackendBaseUrl,
kiloProviderBaseUrl,
kiloSessionIngestBaseUrl,
kilocodeModel: agent.model,
originalOrgId: orgId,
githubToken: resolvedTokens.githubToken,
githubRepo: github?.repo,
githubPullRequestNumber: github?.pullRequestNumber,
githubAppType: resolvedTokens.githubAppType,
createdOnPlatform: metadata.identity.createdOnPlatform,
callbackTarget: metadata.callback?.target,
appendSystemPrompt: metadata.agent?.appendSystemPrompt,
gitUrl:
resolvedTokens.gitlabCapabilityGitUrl ??
resolvedTokens.bitbucketCapabilityGitUrl ??
git?.url,
gitToken: resolvedTokens.gitToken,
gitlabInstanceUrl: resolvedTokens.gitlabInstanceUrl,
glabIsOAuth2: resolvedTokens.glabIsOAuth2,
platform,
bitbucketTokenManaged: resolvedTokens.bitbucketTokenManaged,
bitbucketWorkspaceUuid: git?.type === 'bitbucket' ? git.workspaceUuid : undefined,
bitbucketRepositoryUuid: git?.type === 'bitbucket' ? git.repositoryUuid : undefined,
profile,
});
const { env: materializedEnv, secretEnvKeys: materializedSecretEnvKeys } = this.getSaferEnvVars(
{
sessionHome,
sessionId,
workspacePath,
env,
kiloCapability,
kiloBackendBaseUrl,
kiloProviderBaseUrl,
kiloSessionIngestBaseUrl,
kilocodeModel: agent.model,
originalOrgId: orgId,
githubToken: resolvedTokens.githubToken,
githubRepo: github?.repo,
githubPullRequestNumber: github?.pullRequestNumber,
githubAppType: resolvedTokens.githubAppType,
createdOnPlatform: metadata.identity.createdOnPlatform,
callbackTarget: metadata.callback?.target,
appendSystemPrompt: metadata.agent?.appendSystemPrompt,
gitUrl:
resolvedTokens.gitlabCapabilityGitUrl ??
resolvedTokens.bitbucketCapabilityGitUrl ??
git?.url,
gitToken: resolvedTokens.gitToken,
gitlabInstanceUrl: resolvedTokens.gitlabInstanceUrl,
glabIsOAuth2: resolvedTokens.glabIsOAuth2,
platform,
bitbucketTokenManaged: resolvedTokens.bitbucketTokenManaged,
bitbucketWorkspaceUuid: git?.type === 'bitbucket' ? git.workspaceUuid : undefined,
bitbucketRepositoryUuid: git?.type === 'bitbucket' ? git.repositoryUuid : undefined,
profile,
}
);

const ready = {
workspacePath,
Expand Down Expand Up @@ -2094,6 +2111,7 @@ export class SessionService {
...(runtimeCredentialProxy ? { runtimeCredentialProxy } : {}),
materialized: {
env: materializedEnv,
...(materializedSecretEnvKeys.length ? { secretEnvKeys: materializedSecretEnvKeys } : {}),
...(profile.setupCommands?.length ? { setupCommands: profile.setupCommands } : {}),
...(profile.runtimeSkills?.length ? { runtimeSkills: profile.runtimeSkills } : {}),
},
Expand Down
36 changes: 36 additions & 0 deletions services/cloud-agent-next/src/shared/control-plane-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,9 +256,33 @@ export const controlPlaneRouteSpecSchema = z
* never stored in a route spec.
*/
capture: z.literal(true).optional(),
/**
* Only the Sandbox DO adds this to the `session.prepare` frame, like `mcp`:
* the names of the profile secrets it decrypted into `env`, so the wrapper's
* output redactor remembers their values. Names only; it is never stored in a
* route spec, and the values stay in `env`.
*/
secretEnvKeys: z.array(z.string().min(1).max(128)).max(50).optional(),
Comment thread
eshurakov marked this conversation as resolved.
Resolved
})
.strict();

/**
* Route spec accepted at the registration boundary. `secretEnvKeys` is
* frame-only, so a registration that carries it is rejected here rather than
* being stored and then failing every later `session.prepare`.
*/
export const controlPlaneRegistrationRouteSpecSchema = controlPlaneRouteSpecSchema.superRefine(
(spec, context) => {
if (spec.secretEnvKeys !== undefined) {
context.addIssue({
code: 'custom',
path: ['secretEnvKeys'],
message: 'registration spec must not carry secret env key names',
});
}
}
);

/**
* DO-only credential source (plan "Clarification (2026-09-27, B3 credentials)").
* The Session DO has the session metadata; the Sandbox DO does not. `prepare`
Expand Down Expand Up @@ -370,6 +394,13 @@ export const controlPlanePrepareInputSchema = z
message: 'prepare spec must not carry a snapshot capture request',
});
}
if (value.spec.secretEnvKeys !== undefined) {
context.addIssue({
code: 'custom',
path: ['spec', 'secretEnvKeys'],
message: 'prepare spec must not carry secret env key names',
});
}
});

export type ControlPlaneCredentialRepository = z.infer<
Expand Down Expand Up @@ -608,6 +639,11 @@ const controlPlaneHelloFrameSchema = z
allocationId: z.string().min(1).max(128),
protocolVersion: z.literal(CONTROL_PLANE_PROTOCOL_VERSION),
heartbeatAck: z.literal(true).optional(),
/**
* Optional capability: the wrapper redacts a value by each named secret key
* from `session.prepare`, not only names matching the `SECRET_NAME` heuristic.
*/
redactsNamedSecrets: z.literal(true).optional(),
})
.strict();

Expand Down
2 changes: 2 additions & 0 deletions services/cloud-agent-next/src/shared/wrapper-bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export type WrapperBootstrapMaterializedConfig = {
env: Record<string, string>;
setupCommands?: string[];
runtimeSkills?: WrapperBootstrapRuntimeSkill[];
/** Env key names whose values the wrapper redacts in setup output and diagnostics. */
secretEnvKeys?: string[];
};

export type WrapperRuntimeCredentialProxyConfig = {
Expand Down
29 changes: 29 additions & 0 deletions services/cloud-agent-next/test/unit/wrapper/auto-commit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,35 @@ describe('runAutoCommit', () => {
expect(JSON.stringify(vi.mocked(logToFile).mock.calls)).not.toContain(secret);
});

it('redacts a non-heuristic named secret from push failure events and logs', async () => {
const secret = 'postgres://user:pass@localhost:5432/prod';
mockGetCurrentBranch.mockResolvedValue('feature/worktree');
mockGit
.mockResolvedValueOnce(ok(' M file.ts'))
.mockResolvedValueOnce(ok())
.mockResolvedValueOnce(ok('[feature/worktree abc1234] test commit'))
.mockResolvedValueOnce(raw(`${commitHash}\n`))
.mockResolvedValueOnce(commitObject('test commit'))
.mockResolvedValueOnce({
stdout: '',
stderr: `fatal: cannot reach ${secret}`,
exitCode: 1,
});
const { opts, events } = createOpts({
env: { DATABASE_URL: secret },
secretEnvKeys: ['DATABASE_URL'],
});

await runAutoCommit(opts);

expect(
events.find(event => event.streamEventType === 'autocommit_completed')?.data.message
).toContain('push failed');
expect(JSON.stringify(events)).toContain('[REDACTED]');
expect(JSON.stringify(events)).not.toContain(secret);
expect(JSON.stringify(vi.mocked(logToFile).mock.calls)).not.toContain(secret);
});

it('redacts authenticated GitHub remotes from push failure events', async () => {
mockGetCurrentBranch.mockResolvedValue('feature/cool-stuff');
mockGit
Expand Down
4 changes: 3 additions & 1 deletion services/cloud-agent-next/wrapper/src/auto-commit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export type AutoCommitOptions = {
commitCoAuthor?: WrapperCommitCoAuthor;
signal?: AbortSignal;
env?: NodeJS.ProcessEnv;
/** Env key names whose values must be redacted even when the name is not secret-like. */
secretEnvKeys?: readonly string[];
};

function emitStarted(
Expand Down Expand Up @@ -102,7 +104,7 @@ export async function runAutoCommit(opts: AutoCommitOptions): Promise<AutoCommit
let locallyCommitted = false;
let evidence: CommitEvidence | undefined;
let pushStatus: PushStatus = 'not_attempted';
const redact = createSecretRedactor(process.env, env ?? {});
const redact = createSecretRedactor(process.env, env ? [env] : [], opts.secretEnvKeys ?? []);
const logToFile = (message: string): void => writeLog(redact(message));
const released = Promise.withResolvers<void>();
const previous = worktreeAutoCommits.get(workspacePath) ?? Promise.resolve();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,7 @@ describe('createControlPlaneConnection', () => {
allocationId: 'alloc-1',
protocolVersion: CONTROL_PLANE_PROTOCOL_VERSION,
heartbeatAck: true,
redactsNamedSecrets: true,
});
expect(sandbox.authorizations[0]).toBe('Bearer secret-credential');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ export function createControlPlaneConnection(
allocationId: options.allocationId,
protocolVersion: CONTROL_PLANE_PROTOCOL_VERSION,
heartbeatAck: true,
redactsNamedSecrets: true,
});
// Older v2 peers strictly reject the capability-bearing hello.
negotiationTimer = setTimeout(() => {
Expand Down
Loading
Loading