@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 +12 -3
- package/package.json +1 -1
- package/schemas/agent-tools.schema.json +17 -8
- package/src/config/loader.ts +38 -4
- package/src/config/types.ts +3 -2
- package/src/k8s-tool/index.ts +12 -11
- package/src/k8s-tool/security.ts +265 -49
- package/src/k8s-tool/service.ts +136 -16
- package/src/logs-tool/index.ts +1 -1
- package/src/logs-tool/service.ts +94 -47
- package/src/shared/prerequisites/driver-commands.ts +88 -0
- package/src/shared/prerequisites/guardian-entry.ts +49 -0
- package/src/shared/prerequisites/guardian.ts +157 -0
- package/src/shared/prerequisites/runtime.ts +703 -531
- package/src/shared/prerequisites/store.ts +715 -0
- package/src/shared/prerequisites/types.ts +0 -25
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
|
-
//
|
|
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
|
@@ -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
|
-
|
|
567
|
-
"
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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",
|
package/src/config/loader.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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}: ${
|
package/src/config/types.ts
CHANGED
|
@@ -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
|
-
|
|
48
|
-
|
|
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?: {
|
package/src/k8s-tool/index.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
237
|
-
3. Use -
|
|
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
|
|
244
|
-
bun run src/k8s-tool kubectl --env test --cmd "logs -l app=web-app -n my-app-test --tail=100
|
|
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
|
|
250
|
-
bun run src/k8s-tool kubectl --env test --cmd "describe pod web-app-xxx -n my-app-test
|
|
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
|
|
253
|
-
bun run src/k8s-tool kubectl --env test --cmd "exec web-app-xxx -n my-app-test --
|
|
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("
|
|
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("
|
|
395
|
+
).pipe(Command.withDescription("Run an allowlisted diagnostic in a pod"));
|
|
395
396
|
|
|
396
397
|
const topCommand = Command.make(
|
|
397
398
|
"top",
|
package/src/k8s-tool/security.ts
CHANGED
|
@@ -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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
}
|