@blogic-cz/agent-tools 0.14.61 → 0.15.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/README.md CHANGED
@@ -178,6 +178,10 @@ bun run agent-tools/example-tool/index.ts ping
178
178
  // auto defaults to true:
179
179
  // darwin -> macos-scutil, linux -> linux-nmcli, win32 -> windows-rasdial
180
180
  name: "ExampleVPN",
181
+ // Reuse package-managed connections for 30 seconds after the last command. Set 0 for immediate cleanup.
182
+ idleDisconnectMs: 30000,
183
+ // Total window for stop plus disconnected-status confirmation.
184
+ disconnectTimeoutMs: 10000,
181
185
  // Optional: pass IPSec shared secret to macOS scutil from env without storing the value in config.
182
186
  secretEnvVar: "EXAMPLE_VPN_IPSEC_SHARED_SECRET",
183
187
  },
@@ -187,8 +191,7 @@ bun run agent-tools/example-tool/index.ts ping
187
191
  clusterId: "your-cluster-id",
188
192
  namespaces: { test: "your-ns-test", prod: "your-ns-prod" },
189
193
  prerequisites: [{ type: "vpn", key: "exampleVpn" }],
190
- // Prerequisites are currently decoded and validated as config metadata;
191
- // automatic VPN connect/disconnect execution is planned for a follow-up release.
194
+ // agent-tools starts disconnected VPNs, shares package-local leases, and disconnects only connections it owns.
192
195
  },
193
196
  },
194
197
  logs: {
@@ -252,6 +255,12 @@ export default { handleToolExecuteBefore };
252
255
 
253
256
  All tools support `--help` for full usage documentation. Legacy `agent-tools-*` binary names (e.g. `agent-tools-gh`) still work for backwards compatibility.
254
257
 
258
+ ### Kubernetes command safety
259
+
260
+ `k8s-tool` parses generic kubectl commands into arguments and invokes `kubectl` directly. Shell pipelines, chaining, substitution, user overrides of the configured cluster or credentials, mutating `config`/`auth` subcommands, and `cluster-info dump` are rejected. Direct Secret reads, raw kubeconfig output, filename/kustomize reads, and `kubectl diff` are also blocked.
261
+
262
+ Pod `exec` is limited to direct `redis-cli PING/INFO` and `ls` diagnostics. Generic exec cannot read file contents; use `logs-tool`, which confines files to the configured log directory, tails them through an internal structured operation, and applies the same case-insensitive literal substring filter locally and remotely. Configured log directories are a trusted boundary and must not permit adversarial symlink replacement during reads.
263
+
255
264
  ### gh-tool machine contracts
256
265
 
257
266
  `pr view` adds `headSha` and `baseSha`; failed-check evidence adds the same SHA pair. Review summaries, inline comments, and threads add `commitSha` plus `feedbackOrigin`: `current_head` only for an exact `commitSha === headSha`, `pre_existing` for a different known SHA (not an obsolescence verdict), and `unknown` when either SHA is absent. Issue comments always use `commitSha: null` and `feedbackOrigin: unknown`. `review-triage` preserves existing fields and adds `inlineComments` plus per-kind `feedbackOriginCounts`; batch triage returns the same object per PR.
@@ -414,7 +423,7 @@ Secrets are **never** stored in the config file. The `db-tool` config references
414
423
  }
415
424
  ```
416
425
 
417
- Database VPN prerequisites can be set at the database profile or environment level. If an environment declares `vpn` or `prerequisites`, that environment config replaces the profile prerequisites; `prerequisites: []` explicitly disables inherited VPN setup. DB commands try the query directly first and only connect VPN prerequisites if direct access fails.
426
+ Database VPN prerequisites can be set at the database profile or environment level. If an environment declares `vpn` or `prerequisites`, that environment config replaces the profile prerequisites; `prerequisites: []` explicitly disables inherited VPN setup. DB commands try the query directly first and only connect VPN prerequisites if direct access fails. Package-managed VPNs remain reusable for `idleDisconnectMs` (default 30000) after the last lease; `0` restores immediate cleanup. Preconnected or `leave-running` connections are treated as external and never stopped automatically. If runtime state is corrupt, unknown, or contains legacy artifacts, first stop all agent-tools processes using the VPN, then remove that VPN state directory under `~/.agent-tools/runtime/vpn-prerequisites`.
418
427
 
419
428
  ```json5
420
429
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogic-cz/agent-tools",
3
- "version": "0.14.61",
3
+ "version": "0.15.1",
4
4
  "description": "CLI tools for AI coding agent workflows — GitHub, database, Kubernetes, Azure DevOps, logs, sessions, and audit",
5
5
  "keywords": [
6
6
  "agent",
@@ -558,16 +558,25 @@
558
558
  "enum": ["leave-running", "stop-if-started"]
559
559
  },
560
560
  "connectTimeoutMs": {
561
- "type": "number"
561
+ "type": "number",
562
+ "minimum": 0,
563
+ "maximum": 2147483647,
564
+ "multipleOf": 1
562
565
  },
563
566
  "disconnectTimeoutMs": {
564
- "type": "number"
565
- },
566
- "cooldownMs": {
567
- "type": "number"
568
- },
569
- "leaseTtlMs": {
570
- "type": "number"
567
+ "type": "number",
568
+ "minimum": 0,
569
+ "maximum": 2147483647,
570
+ "multipleOf": 1,
571
+ "description": "Total bounded stop-and-confirm window in milliseconds."
572
+ },
573
+ "idleDisconnectMs": {
574
+ "type": "number",
575
+ "minimum": 0,
576
+ "maximum": 2147483647,
577
+ "multipleOf": 1,
578
+ "default": 30000,
579
+ "description": "Managed VPN reuse window after the last lease. Set to 0 for immediate cleanup."
571
580
  },
572
581
  "secretEnvVar": {
573
582
  "type": "string",
@@ -18,6 +18,20 @@ const CredentialGuardConfigSchema = Schema.Struct({
18
18
  });
19
19
 
20
20
  const CleanupPolicySchema = Schema.Literals(["leave-running", "stop-if-started"]);
21
+ const VPN_TIMER_FIELDS = ["connectTimeoutMs", "disconnectTimeoutMs", "idleDisconnectMs"] as const;
22
+ const MAX_TIMER_MS = 2_147_483_647;
23
+
24
+ function validateVpnTimer(key: string, field: (typeof VPN_TIMER_FIELDS)[number], value: unknown) {
25
+ if (
26
+ typeof value !== "number" ||
27
+ !Number.isFinite(value) ||
28
+ !Number.isInteger(value) ||
29
+ value < 0 ||
30
+ value > MAX_TIMER_MS
31
+ ) {
32
+ throw new Error(`VPN "${key}" ${field} must be an integer from 0 to ${MAX_TIMER_MS}.`);
33
+ }
34
+ }
21
35
 
22
36
  const VpnPrerequisiteSchema = Schema.Struct({
23
37
  type: Schema.Literal("vpn"),
@@ -56,8 +70,7 @@ const VpnConfigSchema = Schema.Struct({
56
70
  defaultCleanup: Schema.optionalKey(CleanupPolicySchema),
57
71
  connectTimeoutMs: Schema.optionalKey(Schema.Number),
58
72
  disconnectTimeoutMs: Schema.optionalKey(Schema.Number),
59
- cooldownMs: Schema.optionalKey(Schema.Number),
60
- leaseTtlMs: Schema.optionalKey(Schema.Number),
73
+ idleDisconnectMs: Schema.optionalKey(Schema.Number),
61
74
  secretEnvVar: Schema.optionalKey(Schema.String),
62
75
  drivers: Schema.optionalKey(
63
76
  Schema.Struct({
@@ -215,8 +228,29 @@ export function decodeConfig(
215
228
  const sanitized = stripUnknownTopLevelKeys(parsed);
216
229
 
217
230
  try {
218
- const decoded = Schema.decodeUnknownSync(AgentToolsConfigSchema)(sanitized);
219
- return decoded as AgentToolsConfig;
231
+ if (isRecord(sanitized) && isRecord(sanitized.vpns)) {
232
+ for (const [key, value] of Object.entries(sanitized.vpns)) {
233
+ if (!isRecord(value)) continue;
234
+ if ("cooldownMs" in value || "leaseTtlMs" in value) {
235
+ throw new Error(`VPN "${key}" uses removed cooldownMs or leaseTtlMs configuration.`);
236
+ }
237
+ for (const field of VPN_TIMER_FIELDS) {
238
+ if (value[field] !== undefined) validateVpnTimer(key, field, value[field]);
239
+ }
240
+ }
241
+ }
242
+ const decoded = Schema.decodeUnknownSync(AgentToolsConfigSchema)(sanitized) as AgentToolsConfig;
243
+ return decoded.vpns
244
+ ? {
245
+ ...decoded,
246
+ vpns: Object.fromEntries(
247
+ Object.entries(decoded.vpns).map(([key, vpn]) => [
248
+ key,
249
+ { idleDisconnectMs: 30_000, ...vpn },
250
+ ]),
251
+ ),
252
+ }
253
+ : decoded;
220
254
  } catch (error) {
221
255
  throw new Error(
222
256
  `Invalid agent-tools config at ${configPath}: ${
@@ -43,9 +43,10 @@ export type VpnConfig = {
43
43
  auto?: boolean;
44
44
  defaultCleanup?: CleanupPolicy;
45
45
  connectTimeoutMs?: number;
46
+ /** Total bounded stop-and-confirm window in milliseconds. */
46
47
  disconnectTimeoutMs?: number;
47
- cooldownMs?: number;
48
- leaseTtlMs?: number;
48
+ /** Managed VPN reuse window after the last lease. Defaults to 30000; 0 disconnects immediately. */
49
+ idleDisconnectMs?: number;
49
50
  /** Name of environment variable holding the VPN shared secret for supported drivers. */
50
51
  secretEnvVar?: string;
51
52
  drivers?: {
@@ -219,7 +219,7 @@ const kubectlCommand = Command.make(
219
219
  `Kubernetes CLI Tool for Coding Agents
220
220
 
221
221
  Executes kubectl commands against the correct cluster context.
222
- Supports shell pipes and complex commands.
222
+ Parses commands into arguments and rejects shell syntax.
223
223
 
224
224
  IMPORTANT FOR AI AGENTS:
225
225
  Always use this tool instead of kubectl directly to ensure
@@ -233,24 +233,25 @@ CLUSTER CONFIGURATION:
233
233
 
234
234
  WORKFLOW FOR AI AGENTS:
235
235
  1. Use this tool for ALL kubectl operations on test/prod
236
- 2. Pipes are supported - use shell syntax
237
- 3. Use -n <namespace> for target namespace
236
+ 2. Do not use pipes, chaining, substitution, or shell interpreters
237
+ 3. Use logs-tool for remote log filtering
238
+ 4. Use -n <namespace> for target namespace
238
239
 
239
240
  EXAMPLES:
240
241
  # List pods in test namespace
241
242
  bun run src/k8s-tool kubectl --env test --cmd "get pods -n my-app-test"
242
243
 
243
- # Get pod logs with grep
244
- bun run src/k8s-tool kubectl --env test --cmd "logs -l app=web-app -n my-app-test --tail=100 | grep error"
244
+ # Get pod logs (use logs-tool when filtering is required)
245
+ bun run src/k8s-tool kubectl --env test --cmd "logs -l app=web-app -n my-app-test --tail=100"
245
246
 
246
247
  # Check resource usage
247
248
  bun run src/k8s-tool kubectl --env test --cmd "top pod -n my-app-test"
248
249
 
249
- # Describe pod with filtered output
250
- bun run src/k8s-tool kubectl --env test --cmd "describe pod web-app-xxx -n my-app-test | grep -A20 Events"
250
+ # Describe a pod
251
+ bun run src/k8s-tool kubectl --env test --cmd "describe pod web-app-xxx -n my-app-test"
251
252
 
252
- # Execute command in pod
253
- bun run src/k8s-tool kubectl --env test --cmd "exec web-app-xxx -n my-app-test -- cat /app/logs/app.log | tail -50"
253
+ # Execute an allowlisted diagnostic in a pod
254
+ bun run src/k8s-tool kubectl --env test --cmd "exec web-app-xxx -n my-app-test -- redis-cli INFO commandstats"
254
255
 
255
256
  # Dry run - show command without executing
256
257
  bun run src/k8s-tool kubectl --env test --cmd "get pods -n my-app-test" --dry-run
@@ -367,7 +368,7 @@ const execCommand = Command.make(
367
368
  ...commonFlags,
368
369
  pod: Flag.string("pod").pipe(Flag.withDescription("Pod name")),
369
370
  execCmd: Flag.string("exec-cmd").pipe(
370
- Flag.withDescription("Command to run inside the pod; wrap in quotes for spaces"),
371
+ Flag.withDescription("Allowlisted diagnostic: redis-cli PING/INFO or ls"),
371
372
  ),
372
373
  namespace: Flag.string("namespace").pipe(
373
374
  Flag.withDescription("Namespace containing the pod"),
@@ -391,7 +392,7 @@ const execCommand = Command.make(
391
392
  ]);
392
393
  return yield* runK8sCommand(command, { dryRun, env, format, profile });
393
394
  }),
394
- ).pipe(Command.withDescription("Execute a command in a pod (kubectl exec <pod> -- <cmd>)"));
395
+ ).pipe(Command.withDescription("Run an allowlisted diagnostic in a pod"));
395
396
 
396
397
  const topCommand = Command.make(
397
398
  "top",
@@ -1,12 +1,5 @@
1
- /**
2
- * K8s Security Module
3
- *
4
- * Validates kubectl commands before execution. Only read-only operations
5
- * are allowed for AI agents. Mutating operations (delete, apply, patch, etc.)
6
- * are blocked to prevent accidental or unauthorized changes to clusters.
7
- */
1
+ import { posix } from "node:path";
8
2
 
9
- /** Kubectl verbs that are safe for AI agents (read-only / non-destructive) */
10
3
  export const ALLOWED_KUBECTL_VERBS = [
11
4
  "get",
12
5
  "describe",
@@ -18,14 +11,10 @@ export const ALLOWED_KUBECTL_VERBS = [
18
11
  "version",
19
12
  "cluster-info",
20
13
  "auth",
21
- "diff",
22
14
  "wait",
23
15
  "exec",
24
- "port-forward",
25
16
  "config",
26
17
  ] as const;
27
-
28
- /** Kubectl verbs that are explicitly blocked (mutating / destructive) */
29
18
  export const BLOCKED_KUBECTL_VERBS = [
30
19
  "delete",
31
20
  "drain",
@@ -51,51 +40,278 @@ export const BLOCKED_KUBECTL_VERBS = [
51
40
  export type K8sSecurityCheckResult = {
52
41
  allowed: boolean;
53
42
  command: string;
43
+ argv?: string[];
54
44
  reason?: string;
45
+ hint?: string;
55
46
  verb?: string;
56
47
  };
57
48
 
58
- /**
59
- * Checks if a kubectl command is allowed for AI agent execution.
60
- * Extracts the kubectl verb and validates against allow/block lists.
61
- *
62
- * Handles piped commands by checking only the kubectl portion (before first pipe).
63
- */
64
- export function isKubectlCommandAllowed(cmd: string): K8sSecurityCheckResult {
65
- const trimmed = cmd.trim();
66
-
67
- // Handle piped commands — only validate the kubectl verb (before first |)
68
- const kubectlPart = trimmed.split("|")[0].trim();
49
+ export function parseKubectlCommand(cmd: string): string[] | undefined {
50
+ const argv: string[] = [];
51
+ let word = "";
52
+ let quote: "'" | '"' | undefined;
53
+ let escaped = false;
54
+ for (const char of cmd) {
55
+ if (escaped) {
56
+ word += char;
57
+ escaped = false;
58
+ continue;
59
+ }
60
+ if (char === "\n" || char === "\r") return undefined;
61
+ if (char === "\\") {
62
+ escaped = true;
63
+ continue;
64
+ }
65
+ if (quote) {
66
+ if (char === quote) quote = undefined;
67
+ else word += char;
68
+ } else if (char === "'" || char === '"') quote = char;
69
+ else if (/[$`;&|<>()[\]]/.test(char)) return undefined;
70
+ else if (/\s/.test(char)) {
71
+ if (word) {
72
+ argv.push(word);
73
+ word = "";
74
+ }
75
+ } else word += char;
76
+ }
77
+ if (quote || escaped) return undefined;
78
+ if (word) argv.push(word);
79
+ return argv.length ? argv : undefined;
80
+ }
69
81
 
70
- // Extract verb: first non-flag word
71
- const verb = kubectlPart
72
- .split(/\s+/)
73
- .find((word) => !word.startsWith("-"))
74
- ?.toLowerCase();
82
+ const flagsWithValues = new Set([
83
+ "-n",
84
+ "--namespace",
85
+ "-o",
86
+ "--output",
87
+ "-l",
88
+ "--selector",
89
+ "--field-selector",
90
+ "-L",
91
+ "--label-columns",
92
+ "--chunk-size",
93
+ "--sort-by",
94
+ "--subresource",
95
+ "--template",
96
+ "--context",
97
+ "--kubeconfig",
98
+ "--request-timeout",
99
+ "-s",
100
+ "--server",
101
+ "--as",
102
+ "--as-group",
103
+ "--as-uid",
104
+ "--token",
105
+ "--certificate-authority",
106
+ "--cache-dir",
107
+ "--client-certificate",
108
+ "--client-key",
109
+ "--cluster",
110
+ "--password",
111
+ "--profile",
112
+ "--profile-output",
113
+ "--tls-server-name",
114
+ "--user",
115
+ "--username",
116
+ ]);
117
+ const controlledFlags = new Set([
118
+ "-s",
119
+ "--context",
120
+ "--kubeconfig",
121
+ "--server",
122
+ "--token",
123
+ "--user",
124
+ "--username",
125
+ "--password",
126
+ "--profile",
127
+ "--profile-output",
128
+ "--as",
129
+ "--as-group",
130
+ "--as-uid",
131
+ "--certificate-authority",
132
+ "--client-certificate",
133
+ "--client-key",
134
+ "--cluster",
135
+ "--tls-server-name",
136
+ "--insecure-skip-tls-verify",
137
+ "--insecure-skip-tls-verify-backend",
138
+ ]);
139
+ const attachedShortValueFlags = ["-n", "-o", "-l", "-L"] as const;
140
+ const flagsWithoutValues = new Set([
141
+ "-A",
142
+ "--all-namespaces",
143
+ "--allow-missing-template-keys",
144
+ "--ignore-not-found",
145
+ "--no-headers",
146
+ "--output-watch-events",
147
+ "-R",
148
+ "--recursive",
149
+ "--server-print",
150
+ "--show-events",
151
+ "--show-kind",
152
+ "--show-labels",
153
+ "--show-managed-fields",
154
+ "--use-openapi-print-columns",
155
+ "-w",
156
+ "--watch",
157
+ "--watch-only",
158
+ ]);
159
+ const isSecretResource = (resource: string) =>
160
+ resource.split(",").some((part) => /^(?:secrets?|secrets?\.[^/]+)(?:\/|$)/i.test(part));
75
161
 
76
- if (!verb) {
77
- return { allowed: false, command: cmd, reason: "Empty kubectl command." };
162
+ function resourceOperand(argv: string[], verbIndex: number): string | null | undefined {
163
+ let flagsEnded = false;
164
+ for (let i = verbIndex + 1; i < argv.length; i++) {
165
+ const arg = argv[i];
166
+ if (arg === undefined) return undefined;
167
+ if (!flagsEnded && arg === "--") {
168
+ flagsEnded = true;
169
+ continue;
170
+ }
171
+ if (!flagsEnded && arg.startsWith("-")) {
172
+ const [flag] = arg.split("=", 1);
173
+ if (flag !== undefined && flagsWithValues.has(flag)) {
174
+ if (!arg.includes("=")) i++;
175
+ continue;
176
+ }
177
+ if (
178
+ attachedShortValueFlags.some(
179
+ (shortFlag) => arg.startsWith(shortFlag) && arg.length > shortFlag.length,
180
+ )
181
+ )
182
+ continue;
183
+ if (flag !== undefined && flagsWithoutValues.has(flag)) continue;
184
+ return null;
185
+ }
186
+ return arg;
78
187
  }
188
+ }
79
189
 
80
- // Explicit blocklist — clear message about what's blocked and why
81
- if ((BLOCKED_KUBECTL_VERBS as readonly string[]).includes(verb)) {
82
- return {
83
- allowed: false,
84
- command: cmd,
85
- verb,
86
- reason: `'${verb}' is a mutating operation blocked for AI agents. Only read-only operations are allowed: ${ALLOWED_KUBECTL_VERBS.join(", ")}.`,
87
- };
88
- }
190
+ export function isSafeLogPath(path: string): boolean {
191
+ const normalizedPath = posix.normalize(path);
192
+ return (
193
+ path.startsWith("/") &&
194
+ !path.split("/").includes("..") &&
195
+ /^\/(?!proc(?:\/|$)|sys(?:\/|$)|var\/run\/secrets(?:\/|$)).*\.log$/.test(normalizedPath) &&
196
+ !normalizedPath.toLowerCase().includes("serviceaccount")
197
+ );
198
+ }
89
199
 
90
- // Allowlist — unknown verbs are also blocked for safety
91
- if (!(ALLOWED_KUBECTL_VERBS as readonly string[]).includes(verb)) {
92
- return {
93
- allowed: false,
94
- command: cmd,
95
- verb,
96
- reason: `Unknown kubectl verb '${verb}'. Only known read-only operations are allowed: ${ALLOWED_KUBECTL_VERBS.join(", ")}.`,
97
- };
98
- }
200
+ function execAllowed(argv: string[], separator: number): boolean {
201
+ const command = argv[separator + 1];
202
+ const args = argv.slice(separator + 2);
203
+ if (!command) return false;
204
+ if (command === "redis-cli")
205
+ return (
206
+ (args.length === 1 && args[0] === "PING") ||
207
+ ((args.length === 1 || args.length === 2) && args[0]?.toUpperCase() === "INFO")
208
+ );
209
+ if (command === "ls")
210
+ return args.every((arg) => arg === "-la" || (arg.startsWith("/") && !arg.includes("..")));
211
+ return false;
212
+ }
99
213
 
100
- return { allowed: true, command: cmd, verb };
214
+ export function isKubectlCommandAllowed(cmd: string): K8sSecurityCheckResult {
215
+ const argv = parseKubectlCommand(cmd);
216
+ if (!argv)
217
+ return { allowed: false, command: cmd, reason: "Empty, malformed, or shell syntax command." };
218
+ let verbIndex = 0;
219
+ while (argv[verbIndex]?.startsWith("-")) {
220
+ const flag = argv[verbIndex];
221
+ if (flag !== undefined && flagsWithValues.has(flag)) verbIndex++;
222
+ verbIndex++;
223
+ }
224
+ const verb = argv[verbIndex]?.toLowerCase();
225
+ if (!verb) return { allowed: false, command: cmd, reason: "Empty kubectl command." };
226
+ const denied = (reason: string, hint?: string): K8sSecurityCheckResult => ({
227
+ allowed: false,
228
+ command: cmd,
229
+ verb,
230
+ reason,
231
+ hint,
232
+ });
233
+ if ((BLOCKED_KUBECTL_VERBS as readonly string[]).includes(verb))
234
+ return denied(
235
+ `'${verb}' is a mutating operation blocked for AI agents. Only read-only operations are allowed: ${ALLOWED_KUBECTL_VERBS.join(", ")}.`,
236
+ );
237
+ if (!(ALLOWED_KUBECTL_VERBS as readonly string[]).includes(verb))
238
+ return denied(
239
+ `Unknown kubectl verb '${verb}'. Only known read-only operations are allowed: ${ALLOWED_KUBECTL_VERBS.join(", ")}.`,
240
+ );
241
+ const controlledFlag = argv.find((arg) => {
242
+ const flag = arg.split("=", 1)[0] ?? "";
243
+ return controlledFlags.has(flag) || (arg.startsWith("-s") && !arg.startsWith("--"));
244
+ });
245
+ if (controlledFlag !== undefined)
246
+ return denied(
247
+ `Cluster, authentication, and impersonation flag '${controlledFlag}' is controlled by the selected profile.`,
248
+ "Remove the override and select the intended Kubernetes profile instead.",
249
+ );
250
+ const subcommand = argv[verbIndex + 1]?.toLowerCase();
251
+ if (
252
+ verb === "config" &&
253
+ (subcommand === undefined || !["view", "get-contexts", "current-context"].includes(subcommand))
254
+ )
255
+ return denied(
256
+ "Only read-only kubectl config subcommands are allowed.",
257
+ "Use config view, config get-contexts, or config current-context.",
258
+ );
259
+ if (verb === "auth" && !["can-i", "whoami"].includes(subcommand ?? ""))
260
+ return denied(
261
+ "Only read-only kubectl auth subcommands are allowed.",
262
+ "Use auth can-i or auth whoami.",
263
+ );
264
+ if (verb === "cluster-info" && subcommand === "dump")
265
+ return denied(
266
+ "cluster-info dump is blocked because it may expose sensitive diagnostic data.",
267
+ "Use cluster-info without dump.",
268
+ );
269
+ const resource = resourceOperand(argv, verbIndex);
270
+ const hasSensitiveInputFlag = argv.some(
271
+ (arg) =>
272
+ arg === "-f" ||
273
+ arg.startsWith("-f") ||
274
+ arg === "--filename" ||
275
+ arg === "-k" ||
276
+ arg.startsWith("-k") ||
277
+ arg === "--kustomize" ||
278
+ /^(?:--filename|--kustomize)=/.test(arg) ||
279
+ /^--raw(?:=|$)/.test(arg),
280
+ );
281
+ if ((verb === "get" || verb === "describe") && hasSensitiveInputFlag)
282
+ return denied(
283
+ "Kubernetes Secret reads and file-based reads are blocked because they may expose credentials.",
284
+ "Use a targeted non-secret resource diagnostic instead.",
285
+ );
286
+ if ((verb === "get" || verb === "describe") && resource === null)
287
+ return denied(
288
+ "Unsupported flag before the resource operand.",
289
+ "Use a documented get/describe flag or place the resource first.",
290
+ );
291
+ const hasNamedSecretResource = argv
292
+ .slice(verbIndex + 1)
293
+ .some((arg) => arg.includes("/") && isSecretResource(arg));
294
+ if (
295
+ (verb === "get" || verb === "describe") &&
296
+ ((resource !== undefined && resource !== null && isSecretResource(resource)) ||
297
+ hasNamedSecretResource)
298
+ )
299
+ return denied(
300
+ "Kubernetes Secret reads and file-based reads are blocked because they may expose credentials.",
301
+ "Use a targeted non-secret resource diagnostic instead.",
302
+ );
303
+ if (verb === "config" && argv.includes("view") && argv.some((arg) => /^--raw(?:=|$)/.test(arg)))
304
+ return denied(
305
+ "Raw kubeconfig output is blocked because it may expose credentials.",
306
+ "Use 'config view' without --raw.",
307
+ );
308
+ if (verb === "exec") {
309
+ const separator = argv.indexOf("--", verbIndex + 1);
310
+ if (separator < 0 || !execAllowed(argv, separator))
311
+ return denied(
312
+ "Only narrow diagnostic commands are allowed in pods.",
313
+ "Use redis-cli PING/INFO or ls. Use logs-tool to read log files.",
314
+ );
315
+ }
316
+ return { allowed: true, command: cmd, argv, verb };
101
317
  }