@blogic-cz/agent-tools 0.18.1 → 1.0.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.
Files changed (68) hide show
  1. package/README.md +23 -6
  2. package/dist/az-tool/config.d.ts +60 -12
  3. package/dist/az-tool/config.d.ts.map +1 -1
  4. package/dist/az-tool/errors.d.ts +10 -1
  5. package/dist/az-tool/errors.d.ts.map +1 -1
  6. package/dist/az-tool/profile.d.ts +16 -0
  7. package/dist/az-tool/profile.d.ts.map +1 -0
  8. package/dist/az-tool/security.d.ts +20 -3
  9. package/dist/az-tool/security.d.ts.map +1 -1
  10. package/dist/az-tool/service.d.ts +9 -4
  11. package/dist/az-tool/service.d.ts.map +1 -1
  12. package/dist/az-tool/types.d.ts +10 -60
  13. package/dist/az-tool/types.d.ts.map +1 -1
  14. package/dist/{az-tool → azdo-tool}/build.d.ts +9 -9
  15. package/dist/azdo-tool/build.d.ts.map +1 -0
  16. package/dist/azdo-tool/config.d.ts +13 -0
  17. package/dist/azdo-tool/config.d.ts.map +1 -0
  18. package/dist/azdo-tool/errors.d.ts +43 -0
  19. package/dist/azdo-tool/errors.d.ts.map +1 -0
  20. package/dist/azdo-tool/extract-option-value.d.ts.map +1 -0
  21. package/dist/azdo-tool/index.d.ts +3 -0
  22. package/dist/azdo-tool/index.d.ts.map +1 -0
  23. package/dist/azdo-tool/security.d.ts +4 -0
  24. package/dist/azdo-tool/security.d.ts.map +1 -0
  25. package/dist/azdo-tool/service.d.ts +15 -0
  26. package/dist/azdo-tool/service.d.ts.map +1 -0
  27. package/dist/azdo-tool/transformers.d.ts.map +1 -0
  28. package/dist/azdo-tool/types.d.ts +64 -0
  29. package/dist/azdo-tool/types.d.ts.map +1 -0
  30. package/dist/config/index.d.ts +1 -1
  31. package/dist/config/index.d.ts.map +1 -1
  32. package/dist/config/loader.d.ts +1 -1
  33. package/dist/config/loader.d.ts.map +1 -1
  34. package/dist/config/types.d.ts +18 -1
  35. package/dist/config/types.d.ts.map +1 -1
  36. package/dist/credential-guard/index.d.ts.map +1 -1
  37. package/dist/shared/azure-credentials.d.ts +20 -0
  38. package/dist/shared/azure-credentials.d.ts.map +1 -0
  39. package/dist/shared/binary-preflight.d.ts +1 -1
  40. package/package.json +8 -2
  41. package/schemas/agent-tools.schema.json +42 -1
  42. package/src/az-tool/config.ts +135 -25
  43. package/src/az-tool/errors.ts +14 -1
  44. package/src/az-tool/index.ts +67 -134
  45. package/src/az-tool/profile.ts +48 -0
  46. package/src/az-tool/security.ts +248 -89
  47. package/src/az-tool/service.ts +137 -242
  48. package/src/az-tool/types.ts +11 -64
  49. package/src/{az-tool → azdo-tool}/build.ts +11 -11
  50. package/src/azdo-tool/config.ts +33 -0
  51. package/src/azdo-tool/errors.ts +41 -0
  52. package/src/azdo-tool/index.ts +222 -0
  53. package/src/azdo-tool/security.ts +157 -0
  54. package/src/azdo-tool/service.ts +322 -0
  55. package/src/azdo-tool/types.ts +67 -0
  56. package/src/config/index.ts +1 -0
  57. package/src/config/loader.ts +10 -1
  58. package/src/config/types.ts +19 -1
  59. package/src/credential-guard/index.ts +7 -2
  60. package/src/shared/azure-credentials.ts +50 -0
  61. package/src/shared/binary-preflight.ts +1 -1
  62. package/dist/az-tool/build.d.ts.map +0 -1
  63. package/dist/az-tool/extract-option-value.d.ts.map +0 -1
  64. package/dist/az-tool/transformers.d.ts.map +0 -1
  65. /package/dist/{az-tool → azdo-tool}/extract-option-value.d.ts +0 -0
  66. /package/dist/{az-tool → azdo-tool}/transformers.d.ts +0 -0
  67. /package/src/{az-tool → azdo-tool}/extract-option-value.ts +0 -0
  68. /package/src/{az-tool → azdo-tool}/transformers.ts +0 -0
@@ -1,130 +1,289 @@
1
- import type { InvokeParams, SecurityCheckResult } from "./types";
1
+ import type { AzSecurityCheckOptions, AzSecurityCheckResult } from "./types";
2
2
 
3
3
  import {
4
- ALLOWED_SUBCOMMANDS,
5
- BLOCKED_SUBCOMMANDS,
6
- ALLOWED_INVOKE_AREAS,
7
- ALLOWED_INVOKE_RESOURCES,
8
- BLOCKED_INVOKE_AREAS,
9
- BLOCKED_INVOKE_RESOURCES,
4
+ AZURE_DEVOPS_GROUPS,
5
+ BLOCKED_VERBS,
6
+ CONTROLLED_FLAGS,
7
+ CREDENTIAL_BLOCKED_SEGMENTS,
8
+ CREDENTIAL_BLOCKED_VERBS,
9
+ KEYVAULT_GROUP,
10
+ KEYVAULT_NAME_ONLY_SUBGROUPS,
11
+ KEYVAULT_PUBLIC_SUBGROUPS,
12
+ READ_ONLY_VERBS,
13
+ READ_ONLY_VERB_PREFIXES,
14
+ REJECTED_ADDRESSING_FLAGS,
15
+ RESOURCE_GROUP_FLAGS,
16
+ RESOURCE_GROUP_IN_ID_PATTERN,
10
17
  } from "./config";
11
- import { extractOptionValue } from "./extract-option-value";
12
18
 
13
- export function isCommandAllowed(cmd: string): SecurityCheckResult {
14
- const rawCommandWords = cmd.trim().split(/\s+/);
15
- const commandWords = cmd
16
- .trim()
17
- .toLowerCase()
18
- .split(/\s+/)
19
- .filter((w) => !w.startsWith("-"));
19
+ /**
20
+ * Tokenize an az command line into argv. Returns undefined for anything
21
+ * carrying shell syntax, so a command can never expand, chain, or redirect.
22
+ * Quoted regions keep their contents verbatim, which leaves `--query
23
+ * "[?name=='web']"` usable.
24
+ */
25
+ export function parseAzCommand(cmd: string): string[] | undefined {
26
+ const argv: string[] = [];
27
+ let word = "";
28
+ let quote: "'" | '"' | undefined;
29
+ let escaped = false;
20
30
 
21
- if (commandWords.includes("invoke")) {
22
- const area = extractOptionValue(rawCommandWords, "--area");
23
- const resource = extractOptionValue(rawCommandWords, "--resource");
24
-
25
- if (!area || !resource) {
26
- return {
27
- allowed: false,
28
- command: cmd,
29
- reason: "Invoke command requires both --area and --resource options.",
30
- };
31
+ for (const char of cmd) {
32
+ if (escaped) {
33
+ word += char;
34
+ escaped = false;
35
+ continue;
36
+ }
37
+ if (char === "\n" || char === "\r") return undefined;
38
+ if (char === "\\") {
39
+ escaped = true;
40
+ continue;
31
41
  }
42
+ if (quote) {
43
+ if (char === quote) quote = undefined;
44
+ else word += char;
45
+ } else if (char === "'" || char === '"') {
46
+ quote = char;
47
+ } else if (/[$`;&|<>()]/.test(char)) {
48
+ return undefined;
49
+ } else if (/\s/.test(char)) {
50
+ if (word) {
51
+ argv.push(word);
52
+ word = "";
53
+ }
54
+ } else {
55
+ word += char;
56
+ }
57
+ }
32
58
 
33
- const method = extractOptionValue(rawCommandWords, "--http-method");
59
+ if (quote || escaped) return undefined;
60
+ if (word) argv.push(word);
61
+ return argv.length ? argv : undefined;
62
+ }
34
63
 
35
- if (method && method.toLowerCase() !== "get") {
36
- return {
37
- allowed: false,
38
- command: cmd,
39
- reason: "Invoke command only allows read-only HTTP method GET.",
40
- };
41
- }
64
+ /**
65
+ * The leading run of non-flag tokens: the command group path plus its verb.
66
+ * Everything from the first flag onwards is argument territory, so a flag
67
+ * value can never be mistaken for a verb.
68
+ */
69
+ function commandHead(argv: readonly string[]): string[] {
70
+ const head: string[] = [];
71
+ for (const arg of argv) {
72
+ if (arg.startsWith("-")) break;
73
+ head.push(arg.toLowerCase());
74
+ }
75
+ return head;
76
+ }
42
77
 
43
- const invokeSecurityCheck = isInvokeAllowed({
44
- area: area.toLowerCase(),
45
- resource: resource.toLowerCase(),
46
- });
78
+ export function isReadOnlyVerb(verb: string): boolean {
79
+ if ((READ_ONLY_VERBS as readonly string[]).includes(verb)) return true;
80
+ return READ_ONLY_VERB_PREFIXES.some((prefix) => verb.startsWith(prefix));
81
+ }
47
82
 
48
- if (!invokeSecurityCheck.allowed) {
49
- return {
50
- allowed: false,
51
- command: cmd,
52
- reason: invokeSecurityCheck.reason,
53
- };
54
- }
83
+ const isListVerb = (verb: string): boolean => verb === "list" || verb.startsWith("list-");
55
84
 
56
- return { allowed: true, command: cmd };
57
- }
85
+ /** The flag part of an argument, so `--flag=value` and `--flag value` compare alike. */
86
+ const flagName = (arg: string): string => arg.split("=", 1)[0] ?? "";
58
87
 
59
- const allowedOperationIndex = commandWords.findIndex((word) =>
60
- ALLOWED_SUBCOMMANDS.includes(word as (typeof ALLOWED_SUBCOMMANDS)[number]),
61
- );
88
+ /**
89
+ * Every resource group a command names via -g / --resource-group, in either the
90
+ * spaced or the `=` form. Empty when the command names none, which for
91
+ * subscription-wide reads like `vm list` is the normal case.
92
+ *
93
+ * All occurrences are collected, not just the first: the Azure CLI is argparse
94
+ * based, so a repeated flag silently takes the LAST value. Validating only the
95
+ * first would let `-g allowed -g denied` through while az ran against `denied`.
96
+ */
97
+ export function extractResourceGroups(argv: readonly string[]): string[] {
98
+ const groups: string[] = [];
62
99
 
63
- if (allowedOperationIndex === -1) {
64
- const blockedWord = commandWords.find((word) =>
65
- BLOCKED_SUBCOMMANDS.includes(word as (typeof BLOCKED_SUBCOMMANDS)[number]),
66
- );
100
+ for (let i = 0; i < argv.length; i++) {
101
+ const arg = argv[i];
102
+ if (arg === undefined) continue;
67
103
 
68
- if (blockedWord) {
69
- return {
70
- allowed: false,
71
- command: cmd,
72
- reason: `Command contains blocked operation '${blockedWord}'. Only read-only operations allowed: ${ALLOWED_SUBCOMMANDS.join(", ")}`,
73
- };
104
+ const equalsIndex = arg.indexOf("=");
105
+ const flag = equalsIndex === -1 ? arg : arg.slice(0, equalsIndex);
106
+ if (!(RESOURCE_GROUP_FLAGS as readonly string[]).includes(flag)) continue;
107
+
108
+ if (equalsIndex !== -1) {
109
+ const inlineValue = arg.slice(equalsIndex + 1);
110
+ if (inlineValue.length > 0) groups.push(inlineValue);
111
+ continue;
74
112
  }
75
113
 
76
- return {
77
- allowed: false,
78
- command: cmd,
79
- reason: `Command must contain a read-only operation: ${ALLOWED_SUBCOMMANDS.join(", ")}. Example: "pipelines list", "repos show --id 123"`,
80
- };
114
+ const next = argv[i + 1];
115
+ if (next !== undefined && !next.startsWith("-")) {
116
+ groups.push(next);
117
+ i++;
118
+ }
81
119
  }
82
120
 
83
- for (let i = allowedOperationIndex + 1; i < commandWords.length; i++) {
84
- if (BLOCKED_SUBCOMMANDS.includes(commandWords[i] as (typeof BLOCKED_SUBCOMMANDS)[number])) {
85
- return {
86
- allowed: false,
87
- command: cmd,
88
- reason: `Command contains blocked operation '${commandWords[i]}' after allowed operation. Only read-only operations allowed: ${ALLOWED_SUBCOMMANDS.join(", ")}`,
89
- };
121
+ for (const arg of argv) {
122
+ for (const match of arg.matchAll(RESOURCE_GROUP_IN_ID_PATTERN)) {
123
+ const group = match[1];
124
+ if (group !== undefined) groups.push(group);
90
125
  }
91
126
  }
92
127
 
93
- return { allowed: true, command: cmd };
128
+ return groups;
94
129
  }
95
130
 
96
- export function isInvokeAllowed(params: InvokeParams): SecurityCheckResult {
97
- const { area, resource } = params;
131
+ /**
132
+ * Why a command's group path is treated as credential-bearing, or undefined if
133
+ * it is not. Key Vault is handled separately from plain segment matching: its
134
+ * `key` and `certificate` subgroups expose public material only, and its
135
+ * `secret` subgroup only leaks on `show`, not on a listing.
136
+ */
137
+ function credentialDenialReason(groupPath: readonly string[], verb: string): string | undefined {
138
+ if (groupPath[0] === KEYVAULT_GROUP) {
139
+ const subgroup = groupPath[1];
98
140
 
99
- if (BLOCKED_INVOKE_AREAS.includes(area as (typeof BLOCKED_INVOKE_AREAS)[number])) {
100
- return {
101
- allowed: false,
102
- reason: `Area '${area}' is blocked. Dangerous areas not allowed: ${BLOCKED_INVOKE_AREAS.join(", ")}`,
103
- };
141
+ if (
142
+ subgroup !== undefined &&
143
+ (KEYVAULT_PUBLIC_SUBGROUPS as readonly string[]).includes(subgroup)
144
+ ) {
145
+ return undefined;
146
+ }
147
+
148
+ if (
149
+ subgroup !== undefined &&
150
+ (KEYVAULT_NAME_ONLY_SUBGROUPS as readonly string[]).includes(subgroup) &&
151
+ !isListVerb(verb)
152
+ ) {
153
+ return `'keyvault ${subgroup} ${verb}' returns secret values.`;
154
+ }
155
+
156
+ return undefined;
104
157
  }
105
158
 
106
- if (!ALLOWED_INVOKE_AREAS.includes(area as (typeof ALLOWED_INVOKE_AREAS)[number])) {
159
+ const segment = groupPath.find((part) =>
160
+ (CREDENTIAL_BLOCKED_SEGMENTS as readonly string[]).includes(part),
161
+ );
162
+
163
+ return segment ? `'${segment}' addresses credential material and is blocked.` : undefined;
164
+ }
165
+
166
+ export function isAzCommandAllowed(
167
+ cmd: string,
168
+ options?: AzSecurityCheckOptions,
169
+ ): AzSecurityCheckResult {
170
+ const argv = parseAzCommand(cmd);
171
+
172
+ if (!argv) {
107
173
  return {
108
174
  allowed: false,
109
- reason: `Area '${area}' is not in allowed list. Allowed areas: ${ALLOWED_INVOKE_AREAS.join(", ")}`,
175
+ command: cmd,
176
+ reason: "Empty, malformed, or shell syntax command.",
177
+ hint: "Pass a single az command without pipes, redirects, substitution, or chaining.",
110
178
  };
111
179
  }
112
180
 
113
- const blockedResources = BLOCKED_INVOKE_RESOURCES[area];
114
- if (blockedResources?.includes(resource)) {
181
+ const head = commandHead(argv);
182
+ const verb = head.at(-1);
183
+
184
+ if (!verb) {
115
185
  return {
116
186
  allowed: false,
117
- reason: `Resource '${resource}' in area '${area}' is blocked. Write resources not allowed.`,
187
+ command: cmd,
188
+ reason: "Command must start with an Azure CLI command group.",
189
+ hint: 'Example: "vm list", "webapp show --name my-app --resource-group my-rg".',
118
190
  };
119
191
  }
120
192
 
121
- const allowedResources = ALLOWED_INVOKE_RESOURCES[area];
122
- if (!allowedResources?.includes(resource)) {
193
+ const groupPath = head.slice(0, -1);
194
+ const group = head[0];
195
+
196
+ const denied = (reason: string, hint?: string): AzSecurityCheckResult => ({
197
+ allowed: false,
198
+ command: cmd,
199
+ verb,
200
+ reason,
201
+ ...(hint ? { hint } : {}),
202
+ });
203
+
204
+ if (group && (AZURE_DEVOPS_GROUPS as readonly string[]).includes(group)) {
205
+ return denied(
206
+ `'${group}' is an Azure DevOps command group, not an Azure platform one.`,
207
+ `Use azdo-tool instead: bun azdo-tool cmd --cmd "${cmd}"`,
208
+ );
209
+ }
210
+
211
+ if ((CREDENTIAL_BLOCKED_VERBS as readonly string[]).includes(verb)) {
212
+ return denied(
213
+ `'${verb}' returns credential material and is blocked.`,
214
+ "Read secrets from the Azure portal or Key Vault directly, outside the agent session.",
215
+ );
216
+ }
217
+
218
+ const credentialReason = credentialDenialReason(groupPath, verb);
219
+ if (credentialReason) {
220
+ return denied(
221
+ credentialReason,
222
+ "Read secrets from the Azure portal or Key Vault directly, outside the agent session.",
223
+ );
224
+ }
225
+
226
+ if ((BLOCKED_VERBS as readonly string[]).includes(verb)) {
227
+ return denied(
228
+ `'${verb}' is a mutating operation blocked for AI agents. Only read-only operations are allowed: ${READ_ONLY_VERBS.join(", ")}, and the ${READ_ONLY_VERB_PREFIXES.join("/")} families.`,
229
+ );
230
+ }
231
+
232
+ if (!isReadOnlyVerb(verb)) {
233
+ return denied(
234
+ `Unknown Azure CLI verb '${verb}'. Only known read-only operations are allowed: ${READ_ONLY_VERBS.join(", ")}, and the ${READ_ONLY_VERB_PREFIXES.join("/")} families.`,
235
+ );
236
+ }
237
+
238
+ const controlledFlag = argv.find((arg) =>
239
+ (CONTROLLED_FLAGS as readonly string[]).includes(flagName(arg)),
240
+ );
241
+ if (controlledFlag) {
242
+ return denied(
243
+ `Flag '${flagName(controlledFlag)}' is controlled by the selected profile.`,
244
+ "Remove the override and select the intended azurePlatform profile with --profile instead.",
245
+ );
246
+ }
247
+
248
+ const addressingFlag = argv.find((arg) =>
249
+ (REJECTED_ADDRESSING_FLAGS as readonly string[]).includes(flagName(arg)),
250
+ );
251
+ if (addressingFlag) {
252
+ return denied(
253
+ `Flag '${flagName(addressingFlag)}' takes a full ARM resource ID, which carries its own subscription and resource group.`,
254
+ "Address the resource within the pinned scope instead, using --resource-group and --name.",
255
+ );
256
+ }
257
+
258
+ // An empty or absent allowlist means the whole subscription is in scope.
259
+ const allowedResourceGroups = options?.allowedResourceGroups;
260
+ if (allowedResourceGroups && allowedResourceGroups.length > 0) {
261
+ const namedGroups = extractResourceGroups(argv);
262
+ const offendingGroup = namedGroups.find(
263
+ (named) =>
264
+ !allowedResourceGroups.some((allowed) => allowed.toLowerCase() === named.toLowerCase()),
265
+ );
266
+
267
+ if (offendingGroup !== undefined) {
268
+ return {
269
+ allowed: false,
270
+ command: cmd,
271
+ verb,
272
+ resourceGroup: offendingGroup,
273
+ reason: `Resource group '${offendingGroup}' is not allowed by this profile.`,
274
+ hint: `Allowed resource groups: ${allowedResourceGroups.join(", ")}.`,
275
+ };
276
+ }
277
+
278
+ const [firstGroup] = namedGroups;
123
279
  return {
124
- allowed: false,
125
- reason: `Resource '${resource}' is not in allowed list for area '${area}'. Allowed resources: ${allowedResources?.join(", ") ?? "none"}`,
280
+ allowed: true,
281
+ command: cmd,
282
+ argv,
283
+ verb,
284
+ ...(firstGroup ? { resourceGroup: firstGroup } : {}),
126
285
  };
127
286
  }
128
287
 
129
- return { allowed: true };
288
+ return { allowed: true, command: cmd, argv, verb };
130
289
  }