Skip to content
Open
Show file tree
Hide file tree
Changes from 14 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
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { IoMessage, IoMessageCode, IoMessageLevel } from '../io-message';
import type { IoMessage, IoMessageCode, IoMessageLevel, IoRequest } from '../io-message';
import type { ActionLessMessage, ActionLessRequest } from './io-helper';

/**
Expand Down Expand Up @@ -118,6 +118,11 @@ export interface IoRequestMaker<T, U> extends MessageInfo {
: [U] extends [boolean]
? (message: string, data: T) => ActionLessRequest<T, U>
: (message: string, data: T, defaultResponse: U) => ActionLessRequest<T, U>;

/**
* Returns whether the given `IoMessage` instance matches the current request definition
*/
is(x: IoMessage<unknown>): x is IoRequest<T, U>;
}

/**
Expand All @@ -137,6 +142,7 @@ function request<T = AbsentData, U = ImpossibleType>(level: IoMessageLevel, deta
...details,
level,
req: maker as any,
is: (m): m is IoRequest<T, U> => m.code === details.code,
};
}

Expand Down Expand Up @@ -166,5 +172,6 @@ export function question<T>(details: CodeInfo): IoRequestMaker<T, string> {
...details,
level,
req: maker as any,
is: (m): m is IoRequest<T, string> => m.code === details.code,
};
}
6 changes: 6 additions & 0 deletions packages/@aws-cdk/toolkit-lib/lib/payloads/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ export interface DeployConfirmationRequest extends ConfirmationRequest {
*/
readonly permissionChangeType: PermissionChangeType;

/**
* True when the change includes security-sensitive updates (IAM or security
* group changes). IoHosts use this to phrase the prompt accordingly.
*/
readonly hasSecurityChanges: boolean;

@mrgrain mrgrain Jun 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is redundant, permissionChangeType already includes this.

Suggested change
/**
* True when the change includes security-sensitive updates (IAM or security
* group changes). IoHosts use this to phrase the prompt accordingly.
*/
readonly hasSecurityChanges: boolean;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated and removed hasSecurityChanges


/**
* The template diffs of the stack
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as util from 'node:util';
import * as chalk from 'chalk';
import type { IIoHost, IoMessage, IoMessageLevel, IoRequest } from '../api/io';
import { isMessageRelevantForLevel } from '../api/io/private';
Expand Down Expand Up @@ -120,12 +121,20 @@ export class NonInteractiveIoHost implements IIoHost {
/**
* Notifies the host of a message that requires a response.
*
* If the host does not return a response the suggested
* default response from the input message will be used.
* The non-interactive IoHost cannot prompt the user, so it always returns
* the request's `defaultResponse`. To make this visible, the message is
* annotated to indicate the auto-response — same convention as the CLI's
* `--yes` mode.
*/
public async requestResponse<DataType, ResponseType>(msg: IoRequest<DataType, ResponseType>): Promise<ResponseType> {
// in the non-interactive IoHost, no requests are promptable
await this.notify(msg);
const annotation = typeof msg.defaultResponse === 'boolean'
? (msg.defaultResponse ? '(auto-confirmed)' : '(auto-denied)')
: `(auto-responded with default: ${util.format(msg.defaultResponse)})`;

await this.notify({
...msg,
message: `${msg.message} ${annotation}`,
});
return msg.defaultResponse;
}

Expand Down
11 changes: 6 additions & 5 deletions packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -857,18 +857,19 @@ export class Toolkit extends CloudAssemblySourceBuilder {
const stackDiff = formatter.formatStackDiff();

// Send a request response with the diff as part of the message,
// and the template diff as data
// (IoHost decides whether to print depending on permissionChangeType)
// and the template diff as data. The IoHost decides how to handle the
// request — interactively prompt the user, auto-confirm, or suppress.
const hasSecurityChanges = securityDiff.permissionChangeType !== PermissionChangeType.NONE;
const deployMotivation = hasSecurityChanges
? '"--require-approval" is enabled and stack includes security-sensitive updates.'
: '"--require-approval" is enabled and stack includes updates.';
? 'Stack includes security-sensitive updates'
: 'Stack includes updates';
const diffOutput = hasSecurityChanges ? securityDiff.formattedDiff : stackDiff.formattedDiff;
const deployQuestion = `${diffOutput}\n\n${deployMotivation}\nDo you wish to deploy these changes`;
const deployQuestion = `${diffOutput}\n\n${deployMotivation}. Do you wish to deploy these changes?`;
const deployConfirmed = await ioHelper.requestResponse(IO.CDK_TOOLKIT_I5060.req(deployQuestion, {
motivation: deployMotivation,
concurrency,
permissionChangeType: securityDiff.permissionChangeType,
hasSecurityChanges,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

redundant permissionChangeType: securityDiff.permissionChangeType has the same info

Suggested change
hasSecurityChanges,

templateDiffs: formatter.diffs,
}));
if (!deployConfirmed) {
Expand Down
8 changes: 4 additions & 4 deletions packages/@aws-cdk/toolkit-lib/test/actions/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,9 @@ IAM Statement Changes
action: 'deploy',
level: 'info',
code: 'CDK_TOOLKIT_I5060',
message: expect.stringContaining('Do you wish to deploy these changes'),
message: expect.stringContaining('Do you wish to deploy these changes?'),
data: expect.objectContaining({
motivation: expect.stringContaining('stack includes security-sensitive updates.'),
motivation: 'Stack includes security-sensitive updates',
permissionChangeType: 'broadening',
templateDiffs: expect.objectContaining({
Stack1: expect.objectContaining({
Expand Down Expand Up @@ -114,12 +114,12 @@ IAM Statement Changes

// Message includes formatted stack diff (not just security diff)
expect(request).toContain('AWS::S3::Bucket');
expect(request).toContain('Do you wish to deploy these changes');
expect(request).toContain('Do you wish to deploy these changes?');

expect(ioHost.requestSpy).toHaveBeenCalledWith(expect.objectContaining({
code: 'CDK_TOOLKIT_I5060',
data: expect.objectContaining({
motivation: expect.stringContaining('stack includes updates.'),
motivation: 'Stack includes updates',
permissionChangeType: 'none',
}),
}));
Expand Down
7 changes: 5 additions & 2 deletions packages/aws-cdk/lib/cli/cdk-toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2334,9 +2334,11 @@ class WorkGraphDeploymentActions implements WorkGraphActions {
const securityDiff = formatter.formatSecurityDiff();
if (requiresApproval(this.options.requireApproval, securityDiff.permissionChangeType)) {
const hasSecurityChanges = securityDiff.permissionChangeType !== PermissionChangeType.NONE;
// Bare-fact motivation. `CliIoHost.augmentDeployApprovalMessage`
// adds the `--require-approval` framing for terminal users.
const motivation = hasSecurityChanges
? '"--require-approval" is enabled and stack includes security-sensitive updates'
: `"--require-approval" is set to '${RequireApproval.ANYCHANGE}'`;
? 'Stack includes security-sensitive updates'
: 'Stack includes updates';
const diffOutput = hasSecurityChanges ? securityDiff.formattedDiff : formatter.formatStackDiff().formattedDiff;
await this.ioHost.asIoHelper().defaults.info(diffOutput);

Expand All @@ -2347,6 +2349,7 @@ class WorkGraphDeploymentActions implements WorkGraphActions {
motivation,
concurrency: this.options.concurrency,
permissionChangeType: securityDiff.permissionChangeType,
hasSecurityChanges,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see above

templateDiffs: formatter.diffs,
}),
);
Expand Down
37 changes: 33 additions & 4 deletions packages/aws-cdk/lib/cli/io-host/cli-io-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,30 @@ export class CliIoHost implements IIoHost {
}
}

/**
* Add the `--require-approval` flag to the deploy approval prompt.
*/
private augmentDeployApprovalMessage(msg: IoRequest<any, any>): string {

@rix0rrr rix0rrr Jun 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm entirely ready to ship this now. HOWEVER, it would be even better if this used the message formatting feature that @mrgrain just formalized here: #1603

So oooooooooone final thing 😉

// Library-emitted I5060 messages always carry data, but defend against
// partially-shaped inputs (e.g. tests, stubs) so we never crash a prompt.
if (!IO.CDK_TOOLKIT_I5060.is(msg) || !msg.data) {
return msg.message;
}

const hasSecurityChanges = msg.data.hasSecurityChanges;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check msg.data.permissionChangeType instead


if (this.requireDeployApproval === RequireApproval.BROADENING && hasSecurityChanges) {
return 'Stack includes security-sensitive updates and "--require-approval" is set to \'broadening\'.\nDo you wish to deploy these changes?';
}
if (this.requireDeployApproval === RequireApproval.ANYCHANGE && hasSecurityChanges) {
return 'Stack includes security-sensitive updates and "--require-approval" is set to \'any-change\'.\nDo you wish to deploy these changes?';
}
if (this.requireDeployApproval === RequireApproval.ANYCHANGE && !hasSecurityChanges) {
return 'Stack includes updates and "--require-approval" is set to \'any-change\'.\nDo you wish to deploy these changes?';
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm. I feel this much harder to read than the old system. I had to read three times to see the differences here. Also this if is going to be annoying if we are adding more states.

I suggest you keep this as a templating string:

const updateTypeText = hasSecurityChanges ? "security-sensitive updates" : "updates";
const requireApproval = this.requireDeployApproval; // maybe? not sure.

return `Stack includes ${updateTypeText} and "--require-approval" is set to '${requireApproval}'.\nDo you wish to deploy these changes?`;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

switched to the suggested template string form. it's now in the rewrite listener in cdk-toolkit.ts

return msg.message;
}

/**
* Determines the output stream, based on message and configuration.
*/
Expand Down Expand Up @@ -464,13 +488,18 @@ export class CliIoHost implements IIoHost {
return true;
}

// The library emits I5060 with a flag-free motivation. The CLI is the
// layer that knows about `--require-approval` and adds it to the
// user-facing message before any downstream rendering.
const promptMessage = this.augmentDeployApprovalMessage(msg);

// In --yes mode, respond for the user if we can
if (this.autoRespond) {
// respond with yes to all confirmations
if (isConfirmationPrompt(msg)) {
await this.notify({
...msg,
message: `${chalk.cyan(msg.message)} (auto-confirmed)`,
message: `${chalk.cyan(promptMessage)} (auto-confirmed)`,
});
return true;
}
Expand All @@ -479,7 +508,7 @@ export class CliIoHost implements IIoHost {
if (msg.defaultResponse) {
await this.notify({
...msg,
message: `${chalk.cyan(msg.message)} (auto-responded with default: ${util.format(msg.defaultResponse)})`,
message: `${chalk.cyan(promptMessage)} (auto-responded with default: ${util.format(msg.defaultResponse)})`,
});
return msg.defaultResponse;
}
Expand All @@ -498,7 +527,7 @@ export class CliIoHost implements IIoHost {
// Basic confirmation prompt
// We treat all requests with a boolean response as confirmation prompts
if (isConfirmationPrompt(msg)) {
const confirmed = await promptly.confirm(`${chalk.cyan(msg.message)} (y/n)`);
const confirmed = await promptly.confirm(`${chalk.cyan(promptMessage)} (y/n)`);
if (!confirmed) {
throw new ToolkitError('AbortedByUser', 'Aborted by user');
}
Expand All @@ -508,7 +537,7 @@ export class CliIoHost implements IIoHost {
// Asking for a specific value
const prompt = extractPromptInfo(msg);
const desc = responseDescription ?? prompt.default;
const answer = await promptly.prompt(`${chalk.cyan(msg.message)}${desc ? ` (${desc})` : ''}`, {
const answer = await promptly.prompt(`${chalk.cyan(promptMessage)}${desc ? ` (${desc})` : ''}`, {
default: prompt.default,
trim: true,
});
Expand Down
4 changes: 3 additions & 1 deletion packages/aws-cdk/test/cli/cdk-toolkit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,9 +223,11 @@ describe('deploy', () => {
// The confirmation prompt should use the non-security motivation and report no permission changes
expect(requestSpy).toHaveBeenCalledWith(expect.objectContaining({
code: 'CDK_TOOLKIT_I5060',
message: expect.stringContaining("'any-change'"),
message: expect.stringContaining('Stack includes updates'),
data: expect.objectContaining({
motivation: 'Stack includes updates',
permissionChangeType: 'none',
hasSecurityChanges: false,
}),
}));
});
Expand Down
79 changes: 79 additions & 0 deletions packages/aws-cdk/test/cli/io-host/cli-io-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -819,6 +819,85 @@ describe('CliIoHost', () => {
expect(mockStdout).not.toHaveBeenCalledWith(chalk.cyan('test message') + ' (y/n) ');
expect(response).toEqual(true);
});

describe('CLI augments the library motivation with --require-approval framing', () => {
const bareMotivationMessage = 'Stack includes security-sensitive updates: Do you wish to deploy these changes?';
const bareNonSecurityMessage = 'Stack includes updates: Do you wish to deploy these changes?';

test('BROADENING + has-security adds the --require-approval suffix and breaks the question to a new line', async () => {
ioHost.requireDeployApproval = RequireApproval.BROADENING;
await requestResponse('y', {
time: new Date(),
level: 'info',
action: 'synth',
code: 'CDK_TOOLKIT_I5060',
message: bareMotivationMessage,
data: {
permissionChangeType: 'broadening',
hasSecurityChanges: true,
},
defaultResponse: true,
});

expect(mockStdout).toHaveBeenCalledWith(
chalk.cyan('Stack includes security-sensitive updates and "--require-approval" is set to \'broadening\'.\nDo you wish to deploy these changes?') + ' (y/n) ',
);
});

test('ANYCHANGE + has-security renders the any-change variant', async () => {
ioHost.requireDeployApproval = RequireApproval.ANYCHANGE;
await requestResponse('y', {
time: new Date(),
level: 'info',
action: 'synth',
code: 'CDK_TOOLKIT_I5060',
message: bareMotivationMessage,
data: {
permissionChangeType: 'broadening',
hasSecurityChanges: true,
},
defaultResponse: true,
});

expect(mockStdout).toHaveBeenCalledWith(
chalk.cyan('Stack includes security-sensitive updates and "--require-approval" is set to \'any-change\'.\nDo you wish to deploy these changes?') + ' (y/n) ',
);
});

test('ANYCHANGE + non-security renders the non-security any-change variant', async () => {
ioHost.requireDeployApproval = RequireApproval.ANYCHANGE;
await requestResponse('y', {
time: new Date(),
level: 'info',
action: 'synth',
code: 'CDK_TOOLKIT_I5060',
message: bareNonSecurityMessage,
data: {
permissionChangeType: 'none',
hasSecurityChanges: false,
},
defaultResponse: true,
});

expect(mockStdout).toHaveBeenCalledWith(
chalk.cyan('Stack includes updates and "--require-approval" is set to \'any-change\'.\nDo you wish to deploy these changes?') + ' (y/n) ',
);
});

test('non-I5060 messages pass through unchanged', async () => {
ioHost.requireDeployApproval = RequireApproval.ANYCHANGE;
await requestResponse('y', plainMessage({
time: new Date(),
level: 'info',
action: 'synth',
code: 'CDK_TOOLKIT_I5050',
message: 'some other prompt',
defaultResponse: true,
}));

expect(mockStdout).toHaveBeenCalledWith(chalk.cyan('some other prompt') + ' (y/n) ');
});
});
});
});
});
Expand Down
Loading