aws-cli-agent 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/LICENSE +21 -0
- package/README.md +373 -0
- package/dist/agent.d.ts +40 -0
- package/dist/agent.js +293 -0
- package/dist/audit.d.ts +39 -0
- package/dist/audit.js +33 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +179 -0
- package/dist/config.d.ts +43 -0
- package/dist/config.js +131 -0
- package/dist/history.d.ts +34 -0
- package/dist/history.js +71 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -0
- package/dist/logger.d.ts +29 -0
- package/dist/logger.js +68 -0
- package/dist/paths.d.ts +23 -0
- package/dist/paths.js +36 -0
- package/dist/providers.d.ts +16 -0
- package/dist/providers.js +59 -0
- package/dist/reasoning.d.ts +59 -0
- package/dist/reasoning.js +125 -0
- package/dist/tools/aws-cli.d.ts +48 -0
- package/dist/tools/aws-cli.js +279 -0
- package/dist/tools/bash.d.ts +47 -0
- package/dist/tools/bash.js +197 -0
- package/dist/tools/history.d.ts +18 -0
- package/dist/tools/history.js +27 -0
- package/dist/tools/index.d.ts +157 -0
- package/dist/tools/index.js +43 -0
- package/dist/tools/profiles.d.ts +7 -0
- package/dist/tools/profiles.js +37 -0
- package/dist/tools/prompt.d.ts +37 -0
- package/dist/tools/prompt.js +145 -0
- package/dist/usage.d.ts +39 -0
- package/dist/usage.js +28 -0
- package/package.json +73 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Logger } from '../logger.js';
|
|
2
|
+
import type { Config } from '../config.js';
|
|
3
|
+
export declare function awsCliTool(opts: {
|
|
4
|
+
logger: Logger;
|
|
5
|
+
config: Config;
|
|
6
|
+
audit: import('../audit.js').AuditLogger;
|
|
7
|
+
record: (entry: import('./index.js').ExecutionRecord) => void;
|
|
8
|
+
}): import("ai").Tool<{
|
|
9
|
+
args: string[];
|
|
10
|
+
purpose: string;
|
|
11
|
+
interactive?: boolean | undefined;
|
|
12
|
+
}, {
|
|
13
|
+
ok: boolean;
|
|
14
|
+
declined: boolean;
|
|
15
|
+
error: string;
|
|
16
|
+
exitCode?: undefined;
|
|
17
|
+
interactive?: undefined;
|
|
18
|
+
note?: undefined;
|
|
19
|
+
stdout?: undefined;
|
|
20
|
+
stderr?: undefined;
|
|
21
|
+
} | {
|
|
22
|
+
ok: boolean;
|
|
23
|
+
exitCode: number;
|
|
24
|
+
interactive: boolean;
|
|
25
|
+
note: string;
|
|
26
|
+
declined?: undefined;
|
|
27
|
+
error?: undefined;
|
|
28
|
+
stdout?: undefined;
|
|
29
|
+
stderr?: undefined;
|
|
30
|
+
} | {
|
|
31
|
+
ok: boolean;
|
|
32
|
+
exitCode: number;
|
|
33
|
+
stdout: string;
|
|
34
|
+
stderr: string;
|
|
35
|
+
declined?: undefined;
|
|
36
|
+
error?: undefined;
|
|
37
|
+
interactive?: undefined;
|
|
38
|
+
note?: undefined;
|
|
39
|
+
} | {
|
|
40
|
+
ok: boolean;
|
|
41
|
+
error: string;
|
|
42
|
+
declined?: undefined;
|
|
43
|
+
exitCode?: undefined;
|
|
44
|
+
interactive?: undefined;
|
|
45
|
+
note?: undefined;
|
|
46
|
+
stdout?: undefined;
|
|
47
|
+
stderr?: undefined;
|
|
48
|
+
}>;
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { tool } from 'ai';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { confirm } from '@inquirer/prompts';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
const READ_ONLY_VERBS = [
|
|
7
|
+
/^describe-/,
|
|
8
|
+
/^list-/,
|
|
9
|
+
/^get-/,
|
|
10
|
+
/^lookup-/,
|
|
11
|
+
/^batch-get-/,
|
|
12
|
+
/^head-/,
|
|
13
|
+
/^filter-/,
|
|
14
|
+
/^scan$/,
|
|
15
|
+
/^query$/,
|
|
16
|
+
];
|
|
17
|
+
const READ_ONLY_FULL = [
|
|
18
|
+
/^s3\s+ls(\s|$)/,
|
|
19
|
+
];
|
|
20
|
+
/**
|
|
21
|
+
* Commands that need direct terminal access — either because they open a TTY
|
|
22
|
+
* (interactive shells), bind a local port and wait for connections, or stream
|
|
23
|
+
* indefinitely until the user interrupts. For these we connect the child's
|
|
24
|
+
* stdio to the parent's terminal instead of capturing stdout/stderr into
|
|
25
|
+
* strings. The agent doesn't get to "see" the output, which is correct:
|
|
26
|
+
* data flow is user ↔ AWS, not user ↔ agent ↔ AWS.
|
|
27
|
+
*
|
|
28
|
+
* If a command should be interactive but isn't listed here, the user can
|
|
29
|
+
* force it with the `--interactive` / `-i` CLI flag or the agent can set
|
|
30
|
+
* `interactive: true` in the tool call.
|
|
31
|
+
*/
|
|
32
|
+
const INTERACTIVE_FULL = [
|
|
33
|
+
// SSM Session Manager — opens a shell or a port-forward listener. Both
|
|
34
|
+
// need stdio inheritance: the shell case needs stdin connected, the
|
|
35
|
+
// port-forward case needs to print "Waiting for connections" and survive.
|
|
36
|
+
/^ssm\s+start-session(\s|$)/,
|
|
37
|
+
// CloudShell — interactive shell in AWS console replica.
|
|
38
|
+
/^cloudshell\s+(start|connect)(-.*|\s|$)/,
|
|
39
|
+
// ECS Exec — runs a command inside a container, often a shell.
|
|
40
|
+
/^ecs\s+execute-command(\s|$)/,
|
|
41
|
+
// EKS exec via aws — wraps kubectl exec for cluster pods.
|
|
42
|
+
/^eks\s+(exec|kubeconfig)(\s|$)/,
|
|
43
|
+
// CloudWatch Logs tail with --follow runs until Ctrl-C.
|
|
44
|
+
/^logs\s+tail(\s|$).*--follow/,
|
|
45
|
+
];
|
|
46
|
+
function isReadOnly(args) {
|
|
47
|
+
const joined = args.join(' ');
|
|
48
|
+
if (READ_ONLY_FULL.some((re) => re.test(joined)))
|
|
49
|
+
return true;
|
|
50
|
+
const verb = args[1];
|
|
51
|
+
if (verb && READ_ONLY_VERBS.some((re) => re.test(verb)))
|
|
52
|
+
return true;
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
function isInteractive(args) {
|
|
56
|
+
const joined = args.join(' ');
|
|
57
|
+
return INTERACTIVE_FULL.some((re) => re.test(joined));
|
|
58
|
+
}
|
|
59
|
+
function shellQuote(arg) {
|
|
60
|
+
if (/^[a-zA-Z0-9_\-/.=:]+$/.test(arg))
|
|
61
|
+
return arg;
|
|
62
|
+
return `'${arg.replace(/'/g, `'\\''`)}'`;
|
|
63
|
+
}
|
|
64
|
+
function extractProfile(args) {
|
|
65
|
+
const i = args.indexOf('--profile');
|
|
66
|
+
return i >= 0 && i + 1 < args.length ? args[i + 1] : null;
|
|
67
|
+
}
|
|
68
|
+
function hasRegion(args) {
|
|
69
|
+
return args.includes('--region');
|
|
70
|
+
}
|
|
71
|
+
// Default cap is generous because list/describe output for a real AWS account
|
|
72
|
+
// can easily exceed 50 KB (hundreds of buckets, dozens of instances with full
|
|
73
|
+
// describe-instances JSON). The model needs the full data to surface it to the
|
|
74
|
+
// user. If you hit memory pressure, lower this — but the model's own context
|
|
75
|
+
// window is the real limiter.
|
|
76
|
+
function truncate(s, max = 200_000) {
|
|
77
|
+
return s.length <= max ? s : s.slice(0, max) + `\n... [truncated ${s.length - max} bytes]`;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Run aws CLI with stdout/stderr captured into strings. Right for discovery
|
|
81
|
+
* calls where the agent (and the host program) need to read the output.
|
|
82
|
+
*/
|
|
83
|
+
function runCaptured(cmd, args) {
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
const proc = spawn(cmd, args, { env: process.env });
|
|
86
|
+
let stdout = '';
|
|
87
|
+
let stderr = '';
|
|
88
|
+
proc.stdout.on('data', (c) => {
|
|
89
|
+
stdout += c.toString();
|
|
90
|
+
});
|
|
91
|
+
proc.stderr.on('data', (c) => {
|
|
92
|
+
stderr += c.toString();
|
|
93
|
+
});
|
|
94
|
+
proc.on('error', reject);
|
|
95
|
+
proc.on('close', (code) => resolve({ stdout, stderr, code: code ?? 0 }));
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Run aws CLI with the child's stdio connected directly to the parent's
|
|
100
|
+
* terminal. Used for interactive sessions (ssm start-session shells),
|
|
101
|
+
* commands that bind local ports and wait (ssm port-forwarding sessions),
|
|
102
|
+
* and long-running streams (logs tail --follow).
|
|
103
|
+
*
|
|
104
|
+
* Returns no stdout/stderr — the bytes went straight to the user's terminal
|
|
105
|
+
* and we never see them. This is correct: data flow is user ↔ AWS, not
|
|
106
|
+
* user ↔ agent ↔ AWS.
|
|
107
|
+
*/
|
|
108
|
+
function runInteractive(cmd, args) {
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
const proc = spawn(cmd, args, {
|
|
111
|
+
env: process.env,
|
|
112
|
+
stdio: 'inherit', // ← the fix: child reuses parent's stdin/stdout/stderr
|
|
113
|
+
});
|
|
114
|
+
proc.on('error', reject);
|
|
115
|
+
proc.on('close', (code) =>
|
|
116
|
+
// We can't observe stdout/stderr — they went to the user's terminal.
|
|
117
|
+
resolve({ stdout: '', stderr: '', code: code ?? 0 }));
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
export function awsCliTool(opts) {
|
|
121
|
+
return tool({
|
|
122
|
+
description: 'Execute an AWS CLI command. `args` does NOT include the leading "aws" - just the subcommand and parameters, e.g. ["ec2","describe-instances","--profile","my-profile","--output","json"]. ALWAYS use --output json on discovery calls so you can parse results. Read-only commands (describe-/list-/get-/s3 ls) auto-approve if allowed by config; mutating commands always prompt. ' +
|
|
123
|
+
'Set `interactive: true` for commands that need direct terminal access — interactive shells (ssm start-session, ecs execute-command), port-forwarding sessions, log tails with --follow. Common interactive patterns auto-detect, but you can force it. When interactive, the child process is connected directly to the user\'s terminal; you will not see the output and should not attempt to parse it.',
|
|
124
|
+
inputSchema: z.object({
|
|
125
|
+
args: z.array(z.string()).min(1).describe('Arguments after the "aws" binary.'),
|
|
126
|
+
purpose: z
|
|
127
|
+
.string()
|
|
128
|
+
.describe('Brief explanation of why this call is being made (shown to the user).'),
|
|
129
|
+
interactive: z
|
|
130
|
+
.boolean()
|
|
131
|
+
.optional()
|
|
132
|
+
.describe('Force interactive mode (inherit terminal stdio). Use for shells, port-forwards, and long-running streams. ' +
|
|
133
|
+
'If unset, the host auto-detects common patterns (ssm start-session, ecs execute-command, logs tail --follow, etc.).'),
|
|
134
|
+
}),
|
|
135
|
+
execute: async ({ args, purpose, interactive }) => {
|
|
136
|
+
// Inject defaultRegion if the agent didn't pass --region.
|
|
137
|
+
let effectiveArgs = args;
|
|
138
|
+
if (!hasRegion(args) && opts.config.defaultRegion) {
|
|
139
|
+
effectiveArgs = [...args, '--region', opts.config.defaultRegion];
|
|
140
|
+
}
|
|
141
|
+
const display = 'aws ' + effectiveArgs.map(shellQuote).join(' ');
|
|
142
|
+
opts.logger.info(`AWS CLI requested: ${purpose}`);
|
|
143
|
+
opts.logger.debug('Command', display);
|
|
144
|
+
// Decide interactive mode. Priority: explicit override on the tool
|
|
145
|
+
// call > CLI flag (via config.forceInteractive) > pattern detection.
|
|
146
|
+
// The CLI flag is the user's escape hatch for cases not in our
|
|
147
|
+
// INTERACTIVE_FULL list.
|
|
148
|
+
const useInteractive = interactive === true ||
|
|
149
|
+
opts.config.forceInteractive === true ||
|
|
150
|
+
isInteractive(effectiveArgs);
|
|
151
|
+
const readOnly = isReadOnly(effectiveArgs);
|
|
152
|
+
// Interactive commands are never auto-approved. The user is about to
|
|
153
|
+
// hand their terminal over to a subprocess — that always warrants a
|
|
154
|
+
// confirmation, regardless of autoApprove settings.
|
|
155
|
+
const autoApprove = !useInteractive &&
|
|
156
|
+
(opts.config.autoApprove.all || (opts.config.autoApprove.readOnly && readOnly));
|
|
157
|
+
// Extract profile up-front so the declined-branch audit/record
|
|
158
|
+
// entries can include it. extractProfile just scans args, no I/O.
|
|
159
|
+
const profile = extractProfile(effectiveArgs);
|
|
160
|
+
if (!autoApprove) {
|
|
161
|
+
process.stderr.write('\n');
|
|
162
|
+
process.stderr.write(`${chalk.bold(' Reason: ')}${purpose}\n`);
|
|
163
|
+
process.stderr.write(`${chalk.bold(' Command: ')}${chalk.green(display)}\n`);
|
|
164
|
+
if (useInteractive) {
|
|
165
|
+
process.stderr.write(`${chalk.bold(' Mode: ')}${chalk.yellow('interactive')} (your terminal will be connected to the command)\n`);
|
|
166
|
+
}
|
|
167
|
+
const ok = await confirm({ message: 'Execute this command?', default: true });
|
|
168
|
+
if (!ok) {
|
|
169
|
+
opts.logger.warn('User declined command');
|
|
170
|
+
// Record the declined call so the agent's end-of-run logic sees
|
|
171
|
+
// that the last action was a refusal, not the most recent
|
|
172
|
+
// successful intermediate call. Without this, cli.ts would print
|
|
173
|
+
// the stdout of an earlier describe/list call as if it were the
|
|
174
|
+
// final answer — confusing the user with scaffolding output.
|
|
175
|
+
// Audit log gets a separate marker for the same reason: clear
|
|
176
|
+
// trail of "user said no" rather than absence-of-record.
|
|
177
|
+
opts.audit.logCommand({
|
|
178
|
+
cmd: display,
|
|
179
|
+
profile,
|
|
180
|
+
exitCode: -1,
|
|
181
|
+
ok: false,
|
|
182
|
+
stdout: '',
|
|
183
|
+
stderr: '[declined by user]',
|
|
184
|
+
});
|
|
185
|
+
opts.record({
|
|
186
|
+
cmd: display,
|
|
187
|
+
profile,
|
|
188
|
+
stdout: '',
|
|
189
|
+
stderr: '[declined by user]',
|
|
190
|
+
exitCode: -1,
|
|
191
|
+
ok: false,
|
|
192
|
+
});
|
|
193
|
+
return { ok: false, declined: true, error: 'User declined to execute this command.' };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
opts.logger.debug(`Auto-approved (${readOnly ? 'read-only' : 'all'})`);
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
const { stdout, stderr, code } = useInteractive
|
|
201
|
+
? await runInteractive('aws', effectiveArgs)
|
|
202
|
+
: await runCaptured('aws', effectiveArgs);
|
|
203
|
+
opts.logger.debug('Exit code', code);
|
|
204
|
+
if (!useInteractive) {
|
|
205
|
+
opts.logger.trace('stdout', stdout);
|
|
206
|
+
if (code !== 0) {
|
|
207
|
+
opts.logger.warn(`AWS CLI failed (exit ${code})`);
|
|
208
|
+
opts.logger.trace('stderr', stderr);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
else if (code !== 0) {
|
|
212
|
+
opts.logger.warn(`Interactive AWS CLI exited non-zero (${code})`);
|
|
213
|
+
}
|
|
214
|
+
// Audit captures whatever we have. For interactive runs stdout/stderr
|
|
215
|
+
// are empty — that's accurate, the bytes went to the terminal — and
|
|
216
|
+
// the audit entry serves as a record that "an interactive session
|
|
217
|
+
// ran" rather than a transcript of what happened in it.
|
|
218
|
+
opts.audit.logCommand({
|
|
219
|
+
cmd: display,
|
|
220
|
+
profile,
|
|
221
|
+
exitCode: code,
|
|
222
|
+
ok: code === 0,
|
|
223
|
+
stdout: useInteractive ? '[interactive session — output not captured]' : stdout,
|
|
224
|
+
stderr: useInteractive ? '' : stderr,
|
|
225
|
+
});
|
|
226
|
+
opts.record({
|
|
227
|
+
cmd: display,
|
|
228
|
+
profile,
|
|
229
|
+
// For interactive runs, give the host CLI a one-line summary to
|
|
230
|
+
// emit instead of empty output. Users finishing an SSM session see
|
|
231
|
+
// their shell ouptut as it happens; this just confirms it ended.
|
|
232
|
+
stdout: useInteractive
|
|
233
|
+
? `[interactive session ended, exit ${code}]\n`
|
|
234
|
+
: stdout,
|
|
235
|
+
stderr: useInteractive ? '' : stderr,
|
|
236
|
+
exitCode: code,
|
|
237
|
+
ok: code === 0,
|
|
238
|
+
});
|
|
239
|
+
// For the agent's context, return a clear signal that interactive
|
|
240
|
+
// mode ran so it doesn't try to parse fictional stdout.
|
|
241
|
+
if (useInteractive) {
|
|
242
|
+
return {
|
|
243
|
+
ok: code === 0,
|
|
244
|
+
exitCode: code,
|
|
245
|
+
interactive: true,
|
|
246
|
+
note: 'Interactive session ran. Output went directly to the user\'s terminal and was not captured. Do not summarize or describe its contents.',
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
ok: code === 0,
|
|
251
|
+
exitCode: code,
|
|
252
|
+
stdout: truncate(stdout),
|
|
253
|
+
stderr: truncate(stderr),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
258
|
+
opts.logger.error('Failed to spawn aws CLI', msg);
|
|
259
|
+
opts.audit.logCommand({
|
|
260
|
+
cmd: display,
|
|
261
|
+
profile,
|
|
262
|
+
exitCode: -1,
|
|
263
|
+
ok: false,
|
|
264
|
+
stdout: '',
|
|
265
|
+
stderr: msg,
|
|
266
|
+
});
|
|
267
|
+
opts.record({
|
|
268
|
+
cmd: display,
|
|
269
|
+
profile,
|
|
270
|
+
stdout: '',
|
|
271
|
+
stderr: msg,
|
|
272
|
+
exitCode: -1,
|
|
273
|
+
ok: false,
|
|
274
|
+
});
|
|
275
|
+
return { ok: false, error: msg };
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Logger } from '../logger.js';
|
|
2
|
+
import type { Config } from '../config.js';
|
|
3
|
+
export declare function bashScriptTool(opts: {
|
|
4
|
+
logger: Logger;
|
|
5
|
+
config: Config;
|
|
6
|
+
audit: import('../audit.js').AuditLogger;
|
|
7
|
+
record: (entry: import('./index.js').ExecutionRecord) => void;
|
|
8
|
+
}): import("ai").Tool<{
|
|
9
|
+
script: string;
|
|
10
|
+
purpose: string;
|
|
11
|
+
}, {
|
|
12
|
+
ok: boolean;
|
|
13
|
+
declined: boolean;
|
|
14
|
+
error: string;
|
|
15
|
+
saved?: undefined;
|
|
16
|
+
path?: undefined;
|
|
17
|
+
stdout?: undefined;
|
|
18
|
+
exitCode?: undefined;
|
|
19
|
+
stderr?: undefined;
|
|
20
|
+
} | {
|
|
21
|
+
ok: boolean;
|
|
22
|
+
error: string;
|
|
23
|
+
declined?: undefined;
|
|
24
|
+
saved?: undefined;
|
|
25
|
+
path?: undefined;
|
|
26
|
+
stdout?: undefined;
|
|
27
|
+
exitCode?: undefined;
|
|
28
|
+
stderr?: undefined;
|
|
29
|
+
} | {
|
|
30
|
+
ok: boolean;
|
|
31
|
+
saved: boolean;
|
|
32
|
+
path: string;
|
|
33
|
+
stdout: string;
|
|
34
|
+
declined?: undefined;
|
|
35
|
+
error?: undefined;
|
|
36
|
+
exitCode?: undefined;
|
|
37
|
+
stderr?: undefined;
|
|
38
|
+
} | {
|
|
39
|
+
ok: boolean;
|
|
40
|
+
exitCode: number;
|
|
41
|
+
stdout: string;
|
|
42
|
+
stderr: string;
|
|
43
|
+
declined?: undefined;
|
|
44
|
+
error?: undefined;
|
|
45
|
+
saved?: undefined;
|
|
46
|
+
path?: undefined;
|
|
47
|
+
}>;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { tool } from 'ai';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { select } from '@inquirer/prompts';
|
|
8
|
+
import chalk from 'chalk';
|
|
9
|
+
import { DEFAULT_SCRIPT_FOLDER } from '../paths.js';
|
|
10
|
+
function runProcess(cmd, args) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const proc = spawn(cmd, args, { env: process.env });
|
|
13
|
+
let stdout = '';
|
|
14
|
+
let stderr = '';
|
|
15
|
+
proc.stdout.on('data', (c) => {
|
|
16
|
+
stdout += c.toString();
|
|
17
|
+
});
|
|
18
|
+
proc.stderr.on('data', (c) => {
|
|
19
|
+
stderr += c.toString();
|
|
20
|
+
});
|
|
21
|
+
proc.on('error', reject);
|
|
22
|
+
proc.on('close', (code) => resolve({ stdout, stderr, code: code ?? 0 }));
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
function truncate(s, max = 200_000) {
|
|
26
|
+
return s.length <= max ? s : s.slice(0, max) + `\n... [truncated]`;
|
|
27
|
+
}
|
|
28
|
+
function indent(s, prefix) {
|
|
29
|
+
return s
|
|
30
|
+
.split('\n')
|
|
31
|
+
.map((l) => prefix + l)
|
|
32
|
+
.join('\n');
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Compute a filesystem-friendly filename for a saved script.
|
|
36
|
+
* Combines a timestamp (so files sort chronologically) with a short slug
|
|
37
|
+
* derived from the purpose (so a directory listing is humanly scannable).
|
|
38
|
+
*/
|
|
39
|
+
function scriptFileName(purpose) {
|
|
40
|
+
const slug = purpose
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
43
|
+
.replace(/^-+|-+$/g, '')
|
|
44
|
+
.slice(0, 40) || 'script';
|
|
45
|
+
const ts = new Date()
|
|
46
|
+
.toISOString()
|
|
47
|
+
.replace(/[:.]/g, '-')
|
|
48
|
+
.replace(/T/, '_')
|
|
49
|
+
.slice(0, 19);
|
|
50
|
+
return `${ts}_${slug}.sh`;
|
|
51
|
+
}
|
|
52
|
+
export function bashScriptTool(opts) {
|
|
53
|
+
return tool({
|
|
54
|
+
description: 'Execute a bash script. Use this for multi-step / multi-account workflows that need looping, jq filtering, or composition (e.g. "list all RDS Aurora databases in all accounts of org X"). The user is prompted to (a) execute the script now, (b) save it to disk for later review or scheduled execution, or (c) cancel. Always start scripts with `set -euo pipefail`.',
|
|
55
|
+
inputSchema: z.object({
|
|
56
|
+
script: z.string().min(1).describe('Full bash script source.'),
|
|
57
|
+
purpose: z.string().describe('What this script accomplishes.'),
|
|
58
|
+
}),
|
|
59
|
+
execute: async ({ script, purpose }) => {
|
|
60
|
+
opts.logger.info(`Bash script requested: ${purpose}`);
|
|
61
|
+
opts.logger.debug('Script', script);
|
|
62
|
+
// Show what would run so the user can make an informed choice.
|
|
63
|
+
process.stderr.write('\n');
|
|
64
|
+
process.stderr.write(`${chalk.bold(' Reason: ')}${purpose}\n`);
|
|
65
|
+
process.stderr.write(`${chalk.bold(' Script:')}\n`);
|
|
66
|
+
process.stderr.write(chalk.green(indent(script, ' ')) + '\n');
|
|
67
|
+
// The save-to-disk option respects the configured folder, or falls back
|
|
68
|
+
// to the XDG default. Compute the would-be path *before* prompting so
|
|
69
|
+
// the user can see exactly where it'll land.
|
|
70
|
+
const scriptFolder = opts.config.scriptFolder ?? DEFAULT_SCRIPT_FOLDER;
|
|
71
|
+
const savePath = path.join(scriptFolder, scriptFileName(purpose));
|
|
72
|
+
// Scripts always go through the three-way prompt regardless of
|
|
73
|
+
// autoApprove. Scripts are arbitrary code with shell-level capability
|
|
74
|
+
// — auto-approving them would defeat a primary safety boundary. The
|
|
75
|
+
// autoApprove flag remains in effect for individual aws CLI commands
|
|
76
|
+
// (where read-only is a meaningful and enforceable category).
|
|
77
|
+
const action = await select({
|
|
78
|
+
message: 'What would you like to do with this script?',
|
|
79
|
+
choices: [
|
|
80
|
+
{ value: 'execute', name: 'Execute now' },
|
|
81
|
+
{ value: 'save', name: `Save to disk (${savePath})` },
|
|
82
|
+
{ value: 'cancel', name: 'Cancel' },
|
|
83
|
+
],
|
|
84
|
+
default: 'execute',
|
|
85
|
+
});
|
|
86
|
+
if (action === 'cancel') {
|
|
87
|
+
opts.logger.warn('User cancelled script');
|
|
88
|
+
// Record the cancelled call so the agent's end-of-run logic sees
|
|
89
|
+
// refusal as the final action, not an earlier successful step.
|
|
90
|
+
// See the parallel comment in aws-cli.ts for the reasoning.
|
|
91
|
+
const cmdLabel = `[bash script: ${purpose}]`;
|
|
92
|
+
opts.audit.logCommand({
|
|
93
|
+
cmd: cmdLabel,
|
|
94
|
+
profile: null,
|
|
95
|
+
exitCode: -1,
|
|
96
|
+
ok: false,
|
|
97
|
+
stdout: '',
|
|
98
|
+
stderr: '[cancelled by user]',
|
|
99
|
+
});
|
|
100
|
+
opts.record({
|
|
101
|
+
cmd: cmdLabel,
|
|
102
|
+
profile: null,
|
|
103
|
+
stdout: '',
|
|
104
|
+
stderr: '[cancelled by user]',
|
|
105
|
+
exitCode: -1,
|
|
106
|
+
ok: false,
|
|
107
|
+
});
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
declined: true,
|
|
111
|
+
error: 'User cancelled. No script was executed or saved.',
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (action === 'save') {
|
|
115
|
+
try {
|
|
116
|
+
fs.mkdirSync(scriptFolder, { recursive: true });
|
|
117
|
+
fs.writeFileSync(savePath, script, { mode: 0o700 });
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
121
|
+
opts.logger.error('Failed to save script', msg);
|
|
122
|
+
return { ok: false, error: `Failed to save script: ${msg}` };
|
|
123
|
+
}
|
|
124
|
+
opts.logger.info(`Script saved to ${savePath}`);
|
|
125
|
+
// Still audit the save action so the trail is complete. exitCode=0
|
|
126
|
+
// is honest here: the user-visible action succeeded.
|
|
127
|
+
opts.audit.logScript({
|
|
128
|
+
cmd: `saved ${savePath}`,
|
|
129
|
+
profile: null,
|
|
130
|
+
exitCode: 0,
|
|
131
|
+
ok: true,
|
|
132
|
+
stdout: '',
|
|
133
|
+
stderr: '',
|
|
134
|
+
script,
|
|
135
|
+
});
|
|
136
|
+
// Record so the CLI can print a confirmation in place of stdout —
|
|
137
|
+
// the script wasn't run, so there's no real stdout to forward.
|
|
138
|
+
opts.record({
|
|
139
|
+
cmd: `saved ${savePath}`,
|
|
140
|
+
profile: null,
|
|
141
|
+
stdout: `Script saved to ${savePath}\n`,
|
|
142
|
+
stderr: '',
|
|
143
|
+
exitCode: 0,
|
|
144
|
+
ok: true,
|
|
145
|
+
});
|
|
146
|
+
return {
|
|
147
|
+
ok: true,
|
|
148
|
+
saved: true,
|
|
149
|
+
path: savePath,
|
|
150
|
+
stdout: `Script saved to ${savePath}`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
// action === 'execute' — same path as before.
|
|
154
|
+
const tmp = path.join(os.tmpdir(), `aws-cli-agent-${Date.now()}-${process.pid}.sh`);
|
|
155
|
+
fs.writeFileSync(tmp, script, { mode: 0o700 });
|
|
156
|
+
const cmdLabel = `bash ${tmp}`;
|
|
157
|
+
try {
|
|
158
|
+
const { stdout, stderr, code } = await runProcess('bash', [tmp]);
|
|
159
|
+
opts.logger.debug('Script exit code', code);
|
|
160
|
+
opts.logger.trace('stdout', stdout);
|
|
161
|
+
if (code !== 0)
|
|
162
|
+
opts.logger.trace('stderr', stderr);
|
|
163
|
+
opts.audit.logScript({
|
|
164
|
+
cmd: cmdLabel,
|
|
165
|
+
profile: null,
|
|
166
|
+
exitCode: code,
|
|
167
|
+
ok: code === 0,
|
|
168
|
+
stdout,
|
|
169
|
+
stderr,
|
|
170
|
+
script,
|
|
171
|
+
});
|
|
172
|
+
opts.record({
|
|
173
|
+
cmd: cmdLabel,
|
|
174
|
+
profile: null,
|
|
175
|
+
stdout,
|
|
176
|
+
stderr,
|
|
177
|
+
exitCode: code,
|
|
178
|
+
ok: code === 0,
|
|
179
|
+
});
|
|
180
|
+
return {
|
|
181
|
+
ok: code === 0,
|
|
182
|
+
exitCode: code,
|
|
183
|
+
stdout: truncate(stdout),
|
|
184
|
+
stderr: truncate(stderr),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
try {
|
|
189
|
+
fs.unlinkSync(tmp);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
/* ignore */
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { History } from '../history.js';
|
|
2
|
+
import type { Logger } from '../logger.js';
|
|
3
|
+
export declare function historyTool(opts: {
|
|
4
|
+
history: History;
|
|
5
|
+
logger: Logger;
|
|
6
|
+
}): import("ai").Tool<{
|
|
7
|
+
query: string;
|
|
8
|
+
limit: number;
|
|
9
|
+
}, {
|
|
10
|
+
count: number;
|
|
11
|
+
entries: {
|
|
12
|
+
timestamp: string;
|
|
13
|
+
input: string;
|
|
14
|
+
commands: string[];
|
|
15
|
+
profile: string | null;
|
|
16
|
+
resources: Record<string, string>;
|
|
17
|
+
}[];
|
|
18
|
+
}>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { tool } from 'ai';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
export function historyTool(opts) {
|
|
4
|
+
return tool({
|
|
5
|
+
description: 'Search the local history of past requests/commands to recover context — e.g. which AWS profile was used for a given account name, common bucket/instance names, etc. Run this EARLY (typically first) when a request mentions an account, resource, or scope by name.',
|
|
6
|
+
inputSchema: z.object({
|
|
7
|
+
query: z
|
|
8
|
+
.string()
|
|
9
|
+
.describe('Search tokens. Matched against past input, commands, profile, and resources.'),
|
|
10
|
+
limit: z.number().int().min(1).max(20).default(5),
|
|
11
|
+
}),
|
|
12
|
+
execute: async ({ query, limit }) => {
|
|
13
|
+
opts.logger.debug('History search', { query, limit });
|
|
14
|
+
const results = opts.history.search(query, limit);
|
|
15
|
+
return {
|
|
16
|
+
count: results.length,
|
|
17
|
+
entries: results.map((e) => ({
|
|
18
|
+
timestamp: e.timestamp,
|
|
19
|
+
input: e.input,
|
|
20
|
+
commands: e.commands,
|
|
21
|
+
profile: e.profile,
|
|
22
|
+
resources: e.resources,
|
|
23
|
+
})),
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
}
|