@pecot/tools-mcp 0.0.0-stage → 1.0.1
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/.claude-plugin/plugin.json +8 -0
- package/LICENSE +21 -0
- package/README.md +113 -2
- package/bin/pecot-tools-mcp.mjs +10 -0
- package/cli/dist/cli.d.ts +9 -0
- package/cli/dist/cli.js +65 -0
- package/cli/dist/domain/allowlist.d.ts +228 -0
- package/cli/dist/domain/allowlist.js +657 -0
- package/cli/dist/domain/arguments.d.ts +37 -0
- package/cli/dist/domain/arguments.js +453 -0
- package/cli/dist/domain/athena-query.d.ts +22 -0
- package/cli/dist/domain/athena-query.js +191 -0
- package/cli/dist/domain/failure.d.ts +22 -0
- package/cli/dist/domain/failure.js +99 -0
- package/cli/dist/domain/redact.d.ts +5 -0
- package/cli/dist/domain/redact.js +38 -0
- package/cli/dist/domain/s3-input.d.ts +20 -0
- package/cli/dist/domain/s3-input.js +50 -0
- package/cli/dist/domain/sso-output.d.ts +23 -0
- package/cli/dist/domain/sso-output.js +70 -0
- package/cli/dist/infra/awscli.d.ts +104 -0
- package/cli/dist/infra/awscli.js +245 -0
- package/cli/dist/infra/ignore-rejection.d.ts +6 -0
- package/cli/dist/infra/ignore-rejection.js +13 -0
- package/cli/dist/infra/local-file.d.ts +9 -0
- package/cli/dist/infra/local-file.js +21 -0
- package/cli/dist/infra/sso/login-registry.d.ts +99 -0
- package/cli/dist/infra/sso/login-registry.js +314 -0
- package/cli/dist/mcp/executor.d.ts +14 -0
- package/cli/dist/mcp/executor.js +44 -0
- package/cli/dist/mcp/result.d.ts +57 -0
- package/cli/dist/mcp/result.js +53 -0
- package/cli/dist/mcp/serve.d.ts +70 -0
- package/cli/dist/mcp/serve.js +185 -0
- package/cli/dist/mcp/server.d.ts +69 -0
- package/cli/dist/mcp/server.js +115 -0
- package/cli/dist/mcp/tools/_shared.d.ts +141 -0
- package/cli/dist/mcp/tools/_shared.js +290 -0
- package/cli/dist/mcp/tools/athena.d.ts +4 -0
- package/cli/dist/mcp/tools/athena.js +82 -0
- package/cli/dist/mcp/tools/cloudwatch.d.ts +4 -0
- package/cli/dist/mcp/tools/cloudwatch.js +46 -0
- package/cli/dist/mcp/tools/ec2.d.ts +4 -0
- package/cli/dist/mcp/tools/ec2.js +31 -0
- package/cli/dist/mcp/tools/ecr.d.ts +4 -0
- package/cli/dist/mcp/tools/ecr.js +37 -0
- package/cli/dist/mcp/tools/index.d.ts +7 -0
- package/cli/dist/mcp/tools/index.js +37 -0
- package/cli/dist/mcp/tools/lambda.d.ts +4 -0
- package/cli/dist/mcp/tools/lambda.js +36 -0
- package/cli/dist/mcp/tools/logs.d.ts +4 -0
- package/cli/dist/mcp/tools/logs.js +74 -0
- package/cli/dist/mcp/tools/rds.d.ts +4 -0
- package/cli/dist/mcp/tools/rds.js +60 -0
- package/cli/dist/mcp/tools/s3.d.ts +4 -0
- package/cli/dist/mcp/tools/s3.js +96 -0
- package/cli/dist/mcp/tools/scheduler.d.ts +4 -0
- package/cli/dist/mcp/tools/scheduler.js +44 -0
- package/cli/dist/mcp/tools/sqs.d.ts +4 -0
- package/cli/dist/mcp/tools/sqs.js +33 -0
- package/cli/dist/mcp/tools/sso.d.ts +11 -0
- package/cli/dist/mcp/tools/sso.js +240 -0
- package/cli/dist/mcp/tools/sts.d.ts +4 -0
- package/cli/dist/mcp/tools/sts.js +15 -0
- package/cli/dist/node-runtime.d.ts +20 -0
- package/cli/dist/node-runtime.js +84 -0
- package/cli/dist/package.json +1 -0
- package/cli/dist/version.d.ts +19 -0
- package/cli/dist/version.js +44 -0
- package/npm-shrinkwrap.json +67 -0
- package/package.json +21 -4
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
// 入力の検証と awscli 引数配列の組み立て。
|
|
2
|
+
//
|
|
3
|
+
// shell を介さず spawn するため、利用者の値は必ず 1 要素 1 値で配列へ入れる。
|
|
4
|
+
// 値が '-' で始まると awscli の引数解析でオプションと解釈され得るので、原則として先頭 '-' を
|
|
5
|
+
// 拒否する(research R3)。例外はフラグで渡す 1 値の string だけで、こちらは
|
|
6
|
+
// `--flag=value` の 1 トークン形にして渡す(doesAllowLeadingDash / flagTokens)。
|
|
7
|
+
//
|
|
8
|
+
// shorthand-list(`--dimensions` / `--filters` / `--image-ids`)は JSON.stringify で 1 トークンに
|
|
9
|
+
// する。awscli はこれらのフラグを shorthand と JSON の双方で受ける。
|
|
10
|
+
// JSON を選べば区切り文字の解釈がエスケープに閉じ、利用者の値が構文へ漏れる余地が無くなる(`,` を含むタグ値も渡せる)。
|
|
11
|
+
import { PROFILE_INPUT, REGION_INPUT } from './allowlist.js';
|
|
12
|
+
import { validateLocalPath, validateS3ListUri, validateS3Uri } from './s3-input.js';
|
|
13
|
+
/**
|
|
14
|
+
shorthand の構成要素の値に混入すると構文として解釈される文字。
|
|
15
|
+
|
|
16
|
+
awscli の shorthand は `Key=value,Key=value` の書式。
|
|
17
|
+
構成要素の値に `,` や `=` を含めると、契約に無いキー(`ExpectedBucketOwner` 等)を後ろに足せる。
|
|
18
|
+
実 awscli が受理することを動作確認済みなので、組み立て前に弾く。対象は文字列を連結する `shorthand`(Athena / ECR の
|
|
19
|
+
`--filter`)だけで、JSON で組み立てる `shorthand-list` には要らない。
|
|
20
|
+
*/
|
|
21
|
+
const SHORTHAND_SYNTAX = /[,={}]/;
|
|
22
|
+
/**
|
|
23
|
+
制御文字(U+0000〜U+001F と U+007F)を含むか。
|
|
24
|
+
|
|
25
|
+
argv に NUL を載せると spawn が同期 throw し、ExecutionResult を伴わない応答になる。
|
|
26
|
+
他の制御文字も awscli の解釈が値ごとに変わるため、検証の段階で invalid-input にする。
|
|
27
|
+
*/
|
|
28
|
+
function hasControlCharacter(text) {
|
|
29
|
+
for (const character of text) {
|
|
30
|
+
const code = character.codePointAt(0) ?? 0;
|
|
31
|
+
if (code === 0x7F || code <= 0x1F)
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
共通入力として末尾で付与するフラグ。
|
|
38
|
+
|
|
39
|
+
エントリ側が同じフラグを inputs に宣言していても、付与箇所は末尾の 1 本に絞る
|
|
40
|
+
(フラグ列挙でも出すと `--profile x --profile x` が並ぶ)。
|
|
41
|
+
*/
|
|
42
|
+
const COMMON_FLAGS = new Set([PROFILE_INPUT.awsFlag, REGION_INPUT.awsFlag].filter(flag => flag !== undefined));
|
|
43
|
+
/**
|
|
44
|
+
検証対象の入力宣言。エントリ固有の入力に共通の profile / region を足したもの。
|
|
45
|
+
*/
|
|
46
|
+
function specsOf(entry) {
|
|
47
|
+
return [...entry.inputs, PROFILE_INPUT, REGION_INPUT];
|
|
48
|
+
}
|
|
49
|
+
function invalid(message) {
|
|
50
|
+
return { category: 'invalid-input', message };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
入力レコードから 1 項目を読む。キーは許可リストの静的宣言に由来する。
|
|
54
|
+
*/
|
|
55
|
+
function readInput(input, name) {
|
|
56
|
+
// eslint-disable-next-line security/detect-object-injection -- name は AllowlistEntry の静的宣言由来で利用者入力ではない
|
|
57
|
+
return input[name];
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
shorthand-list の要素から構成要素 1 件を読む。キーは InputSpec.elements の静的宣言に由来する。
|
|
61
|
+
*/
|
|
62
|
+
function readElement(element, field) {
|
|
63
|
+
// eslint-disable-next-line security/detect-object-injection -- field は InputSpec.elements の静的宣言由来。利用者入力ではない
|
|
64
|
+
return element[field];
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
shorthand-list の要素になり得る値か。配列は要素として認めない。
|
|
68
|
+
*/
|
|
69
|
+
function isElementRecord(value) {
|
|
70
|
+
return value instanceof Object && !Array.isArray(value);
|
|
71
|
+
}
|
|
72
|
+
function isStringArray(value) {
|
|
73
|
+
return Array.isArray(value) && value.every(element => typeof element === 'string');
|
|
74
|
+
}
|
|
75
|
+
function isInputValue(value) {
|
|
76
|
+
return typeof value === 'string'
|
|
77
|
+
|| typeof value === 'number'
|
|
78
|
+
|| typeof value === 'boolean'
|
|
79
|
+
|| isStringArray(value);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
kind 固有の書式検証。述部を返す関数は s3-input.ts が持つ(書式の定義を 1 箇所に置く)。
|
|
83
|
+
*/
|
|
84
|
+
function kindPredicate(kind, value) {
|
|
85
|
+
switch (kind) {
|
|
86
|
+
case 'abs-path': {
|
|
87
|
+
return validateLocalPath(value);
|
|
88
|
+
}
|
|
89
|
+
case 's3-uri': {
|
|
90
|
+
return validateS3Uri(value);
|
|
91
|
+
}
|
|
92
|
+
case 's3-list-uri': {
|
|
93
|
+
return validateS3ListUri(value);
|
|
94
|
+
}
|
|
95
|
+
case 'string':
|
|
96
|
+
case 'string-list':
|
|
97
|
+
case 'integer':
|
|
98
|
+
case 'boolean':
|
|
99
|
+
case 'enum': {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
/* c8 ignore start -- shorthand 系は validateValue が先に分岐するため到達しない。switch の網羅性のために置く */
|
|
103
|
+
case 'shorthand':
|
|
104
|
+
case 'shorthand-list': {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
/* c8 ignore stop */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
値の先頭 '-' を許す入力か。
|
|
112
|
+
|
|
113
|
+
対象はフラグで渡す `kind: 'string'` の 1 値だけ。CloudWatch Logs の除外パターン(`-INFO`)の
|
|
114
|
+
ように、先頭 '-' に意味を持つ値が実在する。
|
|
115
|
+
実 awscli 2.36.40 では `--filter-pattern=-INFO` が受理される(`--filter-pattern -INFO` は 252)。組み立ては buildArguments が
|
|
116
|
+
`--flag=value` の 1 トークン形で行うため、オプション注入にはならない。
|
|
117
|
+
|
|
118
|
+
string-list は nargs が貪欲で、1 トークン形へ畳めない。
|
|
119
|
+
位置引数には `--flag=value` の逃がし先が無い。
|
|
120
|
+
そのため、いずれも従来どおり拒否する(enum は許容値が許可リストの静的宣言なので、
|
|
121
|
+
そもそも利用者の値が本判定に届かない)。
|
|
122
|
+
*/
|
|
123
|
+
function doesAllowLeadingDash(spec) {
|
|
124
|
+
return spec.kind === 'string' && spec.awsFlag !== undefined && spec.positional === undefined;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
string 系 1 値の検証。制御文字・先頭 '-' 拒否と kind 固有の書式を見る。
|
|
128
|
+
*/
|
|
129
|
+
function validateStringValue(spec, value) {
|
|
130
|
+
if (hasControlCharacter(value)) {
|
|
131
|
+
return invalid(`${spec.name} は制御文字を含まない値で指定してください`);
|
|
132
|
+
}
|
|
133
|
+
if (value.startsWith('-') && !doesAllowLeadingDash(spec)) {
|
|
134
|
+
return invalid(`${spec.name} の値は '-' で始められません: ${value}`);
|
|
135
|
+
}
|
|
136
|
+
const predicate = kindPredicate(spec.kind, value);
|
|
137
|
+
if (predicate !== undefined) {
|
|
138
|
+
return invalid(`${spec.name} ${predicate}: ${value}`);
|
|
139
|
+
}
|
|
140
|
+
if (spec.pattern !== undefined && !spec.pattern.test(value)) {
|
|
141
|
+
return invalid(`${spec.name} の形式が不正です: ${value}`);
|
|
142
|
+
}
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
shorthand の構成要素として載る値を検証する。
|
|
147
|
+
|
|
148
|
+
shorthand 自身は入力レコードから読まない(利用者は指定できない)。見るのは構成要素の
|
|
149
|
+
取得元入力の値だけで、取得元自体の型・書式は通常の入力として別途検証されている。
|
|
150
|
+
*/
|
|
151
|
+
function validateShorthandParts(spec, input) {
|
|
152
|
+
const declaredParts = spec.parts ?? [];
|
|
153
|
+
for (const part of declaredParts) {
|
|
154
|
+
const value = readInput(input, part.from);
|
|
155
|
+
if (typeof value === 'string' && SHORTHAND_SYNTAX.test(value)) {
|
|
156
|
+
return invalid(`${part.from} に , = { } は含められません: ${value}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
shorthand-list の構成要素の値 1 つずつを検証する。
|
|
163
|
+
|
|
164
|
+
区切り文字(`, = { }`)は見ない。組み立てが JSON.stringify で、値はエスケープされて JSON の
|
|
165
|
+
文字列リテラルに収まるため、要素の中へ契約外のキーを足す手段が無い。
|
|
166
|
+
空文字は awscli が空値として解釈するので弾く。制御文字は spawn へ届く前にここで拒否する。
|
|
167
|
+
*/
|
|
168
|
+
function validateElementTexts(spec, field, texts) {
|
|
169
|
+
for (const text of texts) {
|
|
170
|
+
if (text === '')
|
|
171
|
+
return invalid(`${spec.name} の ${field} に空文字は指定できません`);
|
|
172
|
+
if (hasControlCharacter(text)) {
|
|
173
|
+
return invalid(`${spec.name} の ${field} は制御文字を含まない値で指定してください`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
shorthand-list の要素 1 件を検証する。
|
|
180
|
+
|
|
181
|
+
値を持つ構成要素が 1 つも無い要素は拒否する。`--filters` に空の要素を渡すと awscli が
|
|
182
|
+
空トークンを受け取り、意図しない絞り込みになるため。
|
|
183
|
+
*/
|
|
184
|
+
function validateShorthandListElement(spec, element) {
|
|
185
|
+
const parts = spec.elements ?? [];
|
|
186
|
+
/* c8 ignore start -- inputSchema の z.strictObject が入力検証エラーで先に落とすため到達しない(二重防御) */
|
|
187
|
+
for (const key of Object.keys(element)) {
|
|
188
|
+
if (parts.every(part => part.field !== key)) {
|
|
189
|
+
return invalid(`${spec.name} の要素に指定できないキーです: ${key}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/* c8 ignore stop */
|
|
193
|
+
let filled = 0;
|
|
194
|
+
for (const part of parts) {
|
|
195
|
+
const value = readElement(element, part.field);
|
|
196
|
+
if (value === undefined)
|
|
197
|
+
continue;
|
|
198
|
+
let texts;
|
|
199
|
+
if (part.list === true) {
|
|
200
|
+
if (!isStringArray(value)) {
|
|
201
|
+
return invalid(`${spec.name} の ${part.field} は文字列の配列で指定してください`);
|
|
202
|
+
}
|
|
203
|
+
if (value.length === 0) {
|
|
204
|
+
return invalid(`${spec.name} の ${part.field} は 1 つ以上の値で指定してください`);
|
|
205
|
+
}
|
|
206
|
+
texts = value;
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
if (typeof value !== 'string') {
|
|
210
|
+
return invalid(`${spec.name} の ${part.field} は文字列で指定してください`);
|
|
211
|
+
}
|
|
212
|
+
texts = [value];
|
|
213
|
+
}
|
|
214
|
+
const rejected = validateElementTexts(spec, part.field, texts);
|
|
215
|
+
if (rejected !== undefined)
|
|
216
|
+
return rejected;
|
|
217
|
+
filled += 1;
|
|
218
|
+
}
|
|
219
|
+
return filled === 0 ? invalid(`${spec.name} の要素は ${parts.map(part => part.field).join(' / ')} のいずれかで指定してください`) : undefined;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
shorthand-list の値(オブジェクトの配列)を検証する。
|
|
223
|
+
*/
|
|
224
|
+
function validateShorthandList(spec, value) {
|
|
225
|
+
if (!Array.isArray(value))
|
|
226
|
+
return invalid(`${spec.name} はオブジェクトの配列で指定してください`);
|
|
227
|
+
for (const element of value) {
|
|
228
|
+
if (!isElementRecord(element)) {
|
|
229
|
+
return invalid(`${spec.name} の要素はオブジェクトで指定してください`);
|
|
230
|
+
}
|
|
231
|
+
const rejected = validateShorthandListElement(spec, element);
|
|
232
|
+
if (rejected !== undefined)
|
|
233
|
+
return rejected;
|
|
234
|
+
}
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
function validateValue(spec, value) {
|
|
238
|
+
switch (spec.kind) {
|
|
239
|
+
case 'string':
|
|
240
|
+
case 'abs-path':
|
|
241
|
+
case 's3-uri':
|
|
242
|
+
case 's3-list-uri': {
|
|
243
|
+
return typeof value === 'string' ? validateStringValue(spec, value) : invalid(`${spec.name} は文字列で指定してください`);
|
|
244
|
+
}
|
|
245
|
+
case 'string-list': {
|
|
246
|
+
if (!isStringArray(value))
|
|
247
|
+
return invalid(`${spec.name} は文字列の配列で指定してください`);
|
|
248
|
+
for (const element of value) {
|
|
249
|
+
const rejected = validateStringValue(spec, element);
|
|
250
|
+
if (rejected !== undefined)
|
|
251
|
+
return rejected;
|
|
252
|
+
// 列挙を持つ string-list(--statistics 等)は要素ごとに比較する。awscli は列挙を
|
|
253
|
+
// クライアント側で検証しないため、ここを抜くと綴り違いがそのまま API へ届く。
|
|
254
|
+
if (spec.values !== undefined && !spec.values.includes(element)) {
|
|
255
|
+
return invalid(`${spec.name} に指定できない値です: ${element}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
case 'integer': {
|
|
261
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value)) {
|
|
262
|
+
return invalid(`${spec.name} は整数で指定してください`);
|
|
263
|
+
}
|
|
264
|
+
if (spec.min !== undefined && value < spec.min) {
|
|
265
|
+
return invalid(`${spec.name} は ${String(spec.min)} 以上で指定してください: ${String(value)}`);
|
|
266
|
+
}
|
|
267
|
+
if (spec.max !== undefined && value > spec.max) {
|
|
268
|
+
return invalid(`${spec.name} は ${String(spec.max)} 以下で指定してください: ${String(value)}`);
|
|
269
|
+
}
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
case 'boolean': {
|
|
273
|
+
return typeof value === 'boolean' ? undefined : invalid(`${spec.name} は true / false で指定してください`);
|
|
274
|
+
}
|
|
275
|
+
case 'enum': {
|
|
276
|
+
if (typeof value !== 'string')
|
|
277
|
+
return invalid(`${spec.name} は文字列で指定してください`);
|
|
278
|
+
if (spec.values?.includes(value) !== true) {
|
|
279
|
+
return invalid(`${spec.name} に指定できない値です: ${value}`);
|
|
280
|
+
}
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
case 'shorthand-list': {
|
|
284
|
+
return validateShorthandList(spec, value);
|
|
285
|
+
}
|
|
286
|
+
/* c8 ignore start -- shorthand は validateInput が先に分岐するため到達しない。switch の網羅性のために置く */
|
|
287
|
+
case 'shorthand': {
|
|
288
|
+
return invalid(`${spec.name} は直接指定できません`);
|
|
289
|
+
}
|
|
290
|
+
/* c8 ignore stop */
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
入力を検証する。問題があれば invalid-input を、無ければ undefined を返す。
|
|
295
|
+
|
|
296
|
+
profile / region も同じ処理で検証する(別扱いにすると書式検証が抜ける)。
|
|
297
|
+
*/
|
|
298
|
+
export function validateInput(entry, input) {
|
|
299
|
+
for (const spec of specsOf(entry)) {
|
|
300
|
+
if (spec.kind === 'shorthand') {
|
|
301
|
+
const rejected = validateShorthandParts(spec, input);
|
|
302
|
+
if (rejected !== undefined)
|
|
303
|
+
return rejected;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
const value = readInput(input, spec.name);
|
|
307
|
+
if (value === undefined) {
|
|
308
|
+
if (spec.required)
|
|
309
|
+
return invalid(`${spec.name} は必須です`);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
const rejected = validateValue(spec, value);
|
|
313
|
+
if (rejected !== undefined)
|
|
314
|
+
return rejected;
|
|
315
|
+
}
|
|
316
|
+
const exclusiveGroups = entry.exclusive ?? [];
|
|
317
|
+
for (const group of exclusiveGroups) {
|
|
318
|
+
const specified = group.filter(name => readInput(input, name) !== undefined);
|
|
319
|
+
if (specified.length > 1) {
|
|
320
|
+
return invalid(`${specified.join(' と ')} は同時に指定できません`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
位置引数・フラグに載せる値を決める(未指定なら default)。
|
|
327
|
+
*/
|
|
328
|
+
function effectiveValue(spec, input) {
|
|
329
|
+
const value = readInput(input, spec.name) ?? spec.default;
|
|
330
|
+
return isInputValue(value) ? value : undefined;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
shorthand フラグに載せる `Key=value,…` を組み立てる。
|
|
334
|
+
|
|
335
|
+
値を持つ構成要素だけを並べ、1 つも無ければ undefined(= フラグごと省く)。
|
|
336
|
+
*/
|
|
337
|
+
function shorthandValue(spec, input) {
|
|
338
|
+
const parts = [];
|
|
339
|
+
const shorthandParts = spec.parts ?? [];
|
|
340
|
+
for (const part of shorthandParts) {
|
|
341
|
+
const value = readInput(input, part.from);
|
|
342
|
+
if (typeof value === 'string')
|
|
343
|
+
parts.push(`${part.key}=${value}`);
|
|
344
|
+
}
|
|
345
|
+
return parts.length === 0 ? undefined : parts.join(',');
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
shorthand-list フラグに載せる JSON 1 トークンを組み立てる。
|
|
349
|
+
|
|
350
|
+
配列全体が `[{"Name":"…","Value":"…"}]` の 1 トークンになり、`list` の構成要素は JSON の配列のまま載せる。
|
|
351
|
+
値を持つ構成要素が無い要素は落とす。要素が 1 つも残らなければ undefined
|
|
352
|
+
(= フラグごと省く)。
|
|
353
|
+
*/
|
|
354
|
+
function shorthandListToken(spec, input) {
|
|
355
|
+
const value = readInput(input, spec.name);
|
|
356
|
+
if (!Array.isArray(value))
|
|
357
|
+
return undefined;
|
|
358
|
+
const elements = [];
|
|
359
|
+
for (const element of value) {
|
|
360
|
+
if (!isElementRecord(element))
|
|
361
|
+
continue;
|
|
362
|
+
// キーは elements の静的宣言由来。動的な代入を避けて entries から組む。
|
|
363
|
+
const written = [];
|
|
364
|
+
const elementParts = spec.elements ?? [];
|
|
365
|
+
for (const part of elementParts) {
|
|
366
|
+
const partValue = readElement(element, part.field);
|
|
367
|
+
if (typeof partValue === 'string')
|
|
368
|
+
written.push([part.key, partValue]);
|
|
369
|
+
else if (isStringArray(partValue) && partValue.length > 0) {
|
|
370
|
+
written.push([part.key, [...partValue]]);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (written.length > 0)
|
|
374
|
+
elements.push(Object.fromEntries(written));
|
|
375
|
+
}
|
|
376
|
+
return elements.length === 0 ? undefined : JSON.stringify(elements);
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
値を awscli の引数トークン列にする(string-list は 1 要素 1 トークン)。
|
|
380
|
+
*/
|
|
381
|
+
function valueTexts(value) {
|
|
382
|
+
if (isStringArray(value))
|
|
383
|
+
return [...value];
|
|
384
|
+
return [typeof value === 'string' ? value : String(value)];
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
フラグ 1 つ分のトークン列。値が '-' で始まる string のときだけ `--flag=value` の 1 トークンにする。
|
|
388
|
+
|
|
389
|
+
別トークンで渡すと awscli の argparse が値をオプションとして読む。`=` 連結は shell を
|
|
390
|
+
介さない配列渡しなので、別トークンへ割れることも別コマンドへ委譲されることも無い。
|
|
391
|
+
*/
|
|
392
|
+
function flagTokens(flag, value) {
|
|
393
|
+
return typeof value === 'string' && value.startsWith('-') ? [`${flag}=${value}`] : [flag, ...valueTexts(value)];
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
awscli へ渡す引数配列を組み立てる。
|
|
397
|
+
|
|
398
|
+
並びは `<service> <subcommand> <位置引数…> <フラグ…> --output json --no-cli-pager
|
|
399
|
+
--profile <p> --region <r>`。位置引数を先・フラグを後に置くと、string-list(nargs が貪欲)と
|
|
400
|
+
位置引数が同居しても awscli v2 の argparse が取り違えない(動作確認済み)。
|
|
401
|
+
|
|
402
|
+
`--no-cli-pager` は jsonOutput の有無に依らず常に付ける(非 tty では無害で、分岐を持たない方が単純)。
|
|
403
|
+
|
|
404
|
+
`--profile` / `--region` は末尾で 1 度だけ付ける。エントリ側が inputs に同名を宣言していても
|
|
405
|
+
フラグ列挙では飛ばす。
|
|
406
|
+
|
|
407
|
+
値が '-' で始まる string は `--flag=value` の 1 トークンにする(flagTokens)。
|
|
408
|
+
*/
|
|
409
|
+
export function buildArguments(entry, input, common = {}) {
|
|
410
|
+
const argv = [entry.service, entry.subcommand];
|
|
411
|
+
const positionals = entry.inputs
|
|
412
|
+
.filter(spec => spec.positional !== undefined)
|
|
413
|
+
.toSorted((left, right) => (left.positional ?? 0) - (right.positional ?? 0));
|
|
414
|
+
for (const spec of positionals) {
|
|
415
|
+
const value = effectiveValue(spec, input);
|
|
416
|
+
if (value !== undefined)
|
|
417
|
+
argv.push(...valueTexts(value));
|
|
418
|
+
}
|
|
419
|
+
for (const spec of entry.inputs) {
|
|
420
|
+
const flag = spec.awsFlag;
|
|
421
|
+
if ((flag === undefined) || COMMON_FLAGS.has(flag))
|
|
422
|
+
continue;
|
|
423
|
+
if (spec.kind === 'shorthand') {
|
|
424
|
+
const shorthand = shorthandValue(spec, input);
|
|
425
|
+
if (shorthand !== undefined)
|
|
426
|
+
argv.push(flag, shorthand);
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (spec.kind === 'shorthand-list') {
|
|
430
|
+
const listJson = shorthandListToken(spec, input);
|
|
431
|
+
if (listJson !== undefined)
|
|
432
|
+
argv.push(flag, listJson);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
const value = effectiveValue(spec, input);
|
|
436
|
+
if (value === undefined)
|
|
437
|
+
continue;
|
|
438
|
+
if (spec.kind === 'boolean') {
|
|
439
|
+
argv.push(value === true ? flag : `--no-${flag.slice(2)}`);
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
argv.push(...flagTokens(flag, value));
|
|
443
|
+
}
|
|
444
|
+
if (entry.jsonOutput)
|
|
445
|
+
argv.push('--output', 'json');
|
|
446
|
+
argv.push('--no-cli-pager');
|
|
447
|
+
if (common.profile !== undefined)
|
|
448
|
+
argv.push(...flagTokens('--profile', common.profile));
|
|
449
|
+
if (common.region !== undefined)
|
|
450
|
+
argv.push(...flagTokens('--region', common.region));
|
|
451
|
+
return argv;
|
|
452
|
+
}
|
|
453
|
+
//# sourceMappingURL=arguments.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
拒否理由。message は呼び出し元エージェントへそのまま返す。
|
|
3
|
+
|
|
4
|
+
`unterminated`(引用符・コメントが閉じていない)を `not-read-only` と分けるのは、reason だけを
|
|
5
|
+
見る呼び出し元が「変更系だから拒否」と誤読しないようにするため。倒す先はどちらも拒否で同じ。
|
|
6
|
+
*/
|
|
7
|
+
export type AthenaQueryRejectionReason = 'empty' | 'multi-statement' | 'not-read-only' | 'unterminated';
|
|
8
|
+
export type AthenaQueryClassification = {
|
|
9
|
+
readonly allowed: true;
|
|
10
|
+
} | {
|
|
11
|
+
readonly allowed: false;
|
|
12
|
+
readonly reason: AthenaQueryRejectionReason;
|
|
13
|
+
readonly message: string;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
クエリを参照専用 / 拒否に分類する。
|
|
17
|
+
|
|
18
|
+
判定順は 未終端 → 複文 → 空 → 先頭キーワード。複文を空より先に見るのは、
|
|
19
|
+
`; SELECT 1` のように 1 文目が空の入力を「空」ではなく「複文」と伝えるため。
|
|
20
|
+
*/
|
|
21
|
+
export declare function classifyAthenaQuery(query: string): AthenaQueryClassification;
|
|
22
|
+
//# sourceMappingURL=athena-query.d.ts.map
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Athena に投げるクエリが参照専用かの判定(FR-008 / research R6)。
|
|
2
|
+
//
|
|
3
|
+
// 完全な SQL パーサは持たない。目的は「変更系と複文を awscli 起動前に落とす」ことなので、
|
|
4
|
+
// 判定できない入力(未終端の引用符・コメント)は許可側ではなく拒否側へ倒す。
|
|
5
|
+
// 判定は Trino 構文を前提にする。Athena では `WITH` で始まる文は必ず問い合わせで、
|
|
6
|
+
// `INSERT INTO … WITH` / `CREATE TABLE … AS WITH` の `WITH` は先頭に来ない。
|
|
7
|
+
// STG1 での確認(2026-09-07): `WITH … INSERT` は StartQueryExecution 自体が失敗する。
|
|
8
|
+
// 失敗の内容は InvalidRequestException / MALFORMED_QUERY で、実行に至らない。
|
|
9
|
+
// 例外は内側の文を実際に実行する `EXPLAIN ANALYZE` だけで、これは `EXPLAIN` を剥がす反復で塞ぐ。
|
|
10
|
+
/**
|
|
11
|
+
参照専用として受け付ける先頭キーワード。判定は大小文字を区別しない。
|
|
12
|
+
*/
|
|
13
|
+
const READ_ONLY_KEYWORDS = new Set(['SELECT', 'WITH', 'SHOW', 'DESCRIBE', 'DESC']);
|
|
14
|
+
/**
|
|
15
|
+
`EXPLAIN` の直後に任意個・任意順で並べられる修飾語。
|
|
16
|
+
*/
|
|
17
|
+
const EXPLAIN_MODIFIERS = new Set(['ANALYZE', 'VERBOSE']);
|
|
18
|
+
/**
|
|
19
|
+
先頭の空白を読み飛ばして最初の語(英字列)を取る。
|
|
20
|
+
*/
|
|
21
|
+
const LEADING_WORD = /^\s*[A-Z]+/i;
|
|
22
|
+
const REJECTION_MESSAGES = {
|
|
23
|
+
'empty': 'query_string が空です。参照クエリを指定してください',
|
|
24
|
+
'multi-statement': '複数の文は実行できません。; の後ろに文を続けず 1 文だけ指定してください',
|
|
25
|
+
'not-read-only': '参照クエリ(SELECT / WITH / SHOW / DESCRIBE / EXPLAIN)だけを実行できます',
|
|
26
|
+
'unterminated': '引用符またはコメントが閉じていないため、参照クエリだと判定できません',
|
|
27
|
+
};
|
|
28
|
+
function reject(reason, message) {
|
|
29
|
+
return { allowed: false, reason, message };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
`--` 行コメントの終端(改行の次)。改行が無ければ末尾。
|
|
33
|
+
*/
|
|
34
|
+
function skipLineComment(query, start) {
|
|
35
|
+
const end = query.indexOf('\n', start);
|
|
36
|
+
return end === -1 ? query.length : end + 1;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
ブロックコメントの終端。非入れ子なので最初の閉じ記号で終わる。未終端なら undefined。
|
|
40
|
+
*/
|
|
41
|
+
function skipBlockComment(query, start) {
|
|
42
|
+
const end = query.indexOf('*/', start);
|
|
43
|
+
return end === -1 ? undefined : end + 2;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
引用の終端(閉じ引用符の次)。start は開き引用符の次を指す。
|
|
47
|
+
|
|
48
|
+
`'…'` 文字列リテラルと `"…"` 引用識別子のエスケープは、同じ引用符の 2 重化だけ。
|
|
49
|
+
Trino では `\` は文字列リテラル中でも普通の文字なので、`'\'` は「閉じていない」ではなく
|
|
50
|
+
「バックスラッシュ 1 文字のリテラル」になる。
|
|
51
|
+
*/
|
|
52
|
+
function skipQuoted(query, start, quote) {
|
|
53
|
+
let index = start;
|
|
54
|
+
while (index < query.length) {
|
|
55
|
+
if (query.charAt(index) !== quote) {
|
|
56
|
+
index += 1;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (query.charAt(index + 1) === quote) {
|
|
60
|
+
index += 2;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
return index + 1;
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
コメントを空白へ、引用の中身を空の引用へ均す。
|
|
69
|
+
|
|
70
|
+
中身を捨てるのは、後段が見るのは「リテラル外の `;`」と「先頭キーワード」だけだから。
|
|
71
|
+
リテラルの内容は判定材料にならない。均しておけばリテラル内の `;` や `--` が
|
|
72
|
+
後段の単純な文字列走査に引っ掛からない。
|
|
73
|
+
*/
|
|
74
|
+
function scan(query) {
|
|
75
|
+
let code = '';
|
|
76
|
+
let index = 0;
|
|
77
|
+
while (index < query.length) {
|
|
78
|
+
const char = query.charAt(index);
|
|
79
|
+
const next = query.charAt(index + 1);
|
|
80
|
+
if (char === '-' && next === '-') {
|
|
81
|
+
index = skipLineComment(query, index + 2);
|
|
82
|
+
code += ' ';
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (char === '/' && next === '*') {
|
|
86
|
+
const end = skipBlockComment(query, index + 2);
|
|
87
|
+
if (end === undefined)
|
|
88
|
+
return { code, unterminated: true };
|
|
89
|
+
index = end;
|
|
90
|
+
code += ' ';
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (char === '\'' || char === '"') {
|
|
94
|
+
const end = skipQuoted(query, index + 1, char);
|
|
95
|
+
if (end === undefined)
|
|
96
|
+
return { code, unterminated: true };
|
|
97
|
+
index = end;
|
|
98
|
+
code += char + char;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
code += char;
|
|
102
|
+
index += 1;
|
|
103
|
+
}
|
|
104
|
+
return { code, unterminated: false };
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
最初の `;` より前(= 1 文目)。`;` が無ければ全体。
|
|
108
|
+
*/
|
|
109
|
+
function firstStatement(code) {
|
|
110
|
+
const semicolon = code.indexOf(';');
|
|
111
|
+
return semicolon === -1 ? code : code.slice(0, semicolon);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
最初の `;` の後ろに文が続くか。末尾の `;` だけなら続かない。
|
|
115
|
+
*/
|
|
116
|
+
function hasFollowingStatement(code) {
|
|
117
|
+
const semicolon = code.indexOf(';');
|
|
118
|
+
return semicolon !== -1 && code.slice(semicolon + 1).trim() !== '';
|
|
119
|
+
}
|
|
120
|
+
function leadingWord(text) {
|
|
121
|
+
const matched = LEADING_WORD.exec(text) ?? undefined;
|
|
122
|
+
return matched === undefined ? '' : matched[0].trimStart().toUpperCase();
|
|
123
|
+
}
|
|
124
|
+
function afterLeadingWord(text) {
|
|
125
|
+
const matched = LEADING_WORD.exec(text) ?? undefined;
|
|
126
|
+
return matched === undefined ? text : text.slice(matched[0].length);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
先頭が `(` ならその対応括弧までを剥がす(`EXPLAIN (TYPE DISTRIBUTED) SELECT …`)。
|
|
130
|
+
|
|
131
|
+
対応する `)` が無い場合は剥がさずに返す。剥がせないと、後段の語判定は左括弧を見て
|
|
132
|
+
語を取れず、拒否側へ倒れる。
|
|
133
|
+
*/
|
|
134
|
+
function afterOptionGroup(text) {
|
|
135
|
+
const trimmed = text.trimStart();
|
|
136
|
+
if (!trimmed.startsWith('('))
|
|
137
|
+
return text;
|
|
138
|
+
let depth = 0;
|
|
139
|
+
for (let index = 0; index < trimmed.length; index += 1) {
|
|
140
|
+
const char = trimmed.charAt(index);
|
|
141
|
+
if (char === '(')
|
|
142
|
+
depth += 1;
|
|
143
|
+
else if (char === ')') {
|
|
144
|
+
depth -= 1;
|
|
145
|
+
if (depth === 0)
|
|
146
|
+
return trimmed.slice(index + 1);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return trimmed;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
1 文が参照専用か。
|
|
153
|
+
|
|
154
|
+
`EXPLAIN` は修飾語とオプション群を剥がして内側を見直す。1 周ごとに必ず `EXPLAIN` の 1 語を
|
|
155
|
+
消費するので停止する。再帰ではなくループにするのは、`EXPLAIN` を数万段重ねた入力で
|
|
156
|
+
コールスタックを溢れさせないため(溢れると失敗種別の外の例外になる)。
|
|
157
|
+
*/
|
|
158
|
+
function isReadOnlyStatement(statement) {
|
|
159
|
+
let current = statement;
|
|
160
|
+
let word = leadingWord(current);
|
|
161
|
+
while (word === 'EXPLAIN') {
|
|
162
|
+
let rest = afterLeadingWord(current);
|
|
163
|
+
while (EXPLAIN_MODIFIERS.has(leadingWord(rest))) {
|
|
164
|
+
rest = afterLeadingWord(rest);
|
|
165
|
+
}
|
|
166
|
+
current = afterOptionGroup(rest);
|
|
167
|
+
word = leadingWord(current);
|
|
168
|
+
}
|
|
169
|
+
return READ_ONLY_KEYWORDS.has(word);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
クエリを参照専用 / 拒否に分類する。
|
|
173
|
+
|
|
174
|
+
判定順は 未終端 → 複文 → 空 → 先頭キーワード。複文を空より先に見るのは、
|
|
175
|
+
`; SELECT 1` のように 1 文目が空の入力を「空」ではなく「複文」と伝えるため。
|
|
176
|
+
*/
|
|
177
|
+
export function classifyAthenaQuery(query) {
|
|
178
|
+
const scanned = scan(query);
|
|
179
|
+
if (scanned.unterminated)
|
|
180
|
+
return reject('unterminated', REJECTION_MESSAGES.unterminated);
|
|
181
|
+
if (hasFollowingStatement(scanned.code)) {
|
|
182
|
+
return reject('multi-statement', REJECTION_MESSAGES['multi-statement']);
|
|
183
|
+
}
|
|
184
|
+
const statement = firstStatement(scanned.code);
|
|
185
|
+
if (statement.trim() === '')
|
|
186
|
+
return reject('empty', REJECTION_MESSAGES.empty);
|
|
187
|
+
return isReadOnlyStatement(statement)
|
|
188
|
+
? { allowed: true }
|
|
189
|
+
: reject('not-read-only', REJECTION_MESSAGES['not-read-only']);
|
|
190
|
+
}
|
|
191
|
+
//# sourceMappingURL=athena-query.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { FailureCategory } from './allowlist.js';
|
|
2
|
+
/**
|
|
3
|
+
判定の材料。runAwscli の SpawnResult から組み立てる。
|
|
4
|
+
*/
|
|
5
|
+
export interface FailureSignals {
|
|
6
|
+
readonly exitCode: number | null;
|
|
7
|
+
readonly stderr: string;
|
|
8
|
+
readonly timedOut: boolean;
|
|
9
|
+
/**
|
|
10
|
+
spawn 自体が失敗したときの errno(ENOENT 等)。
|
|
11
|
+
*/
|
|
12
|
+
readonly spawnErrorCode?: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
失敗の種別を決める。
|
|
16
|
+
|
|
17
|
+
順は ENOENT → 時間切れ → 終了コード 252 → stderr 比較 → other。
|
|
18
|
+
ENOENT を最優先にするのは、awscli 未導入では stderr に何が出ていても打つ手が
|
|
19
|
+
「awscli を入れる」しかないため。
|
|
20
|
+
*/
|
|
21
|
+
export declare function classifyFailure(signals: Readonly<FailureSignals>): FailureCategory;
|
|
22
|
+
//# sourceMappingURL=failure.d.ts.map
|