kubeagent 0.1.35 → 0.1.43
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 +34 -0
- package/dist/auth.js +15 -0
- package/dist/check/index.d.ts +34 -0
- package/dist/check/index.js +77 -0
- package/dist/check/index.test.d.ts +1 -0
- package/dist/check/index.test.js +202 -0
- package/dist/check/run.d.ts +10 -0
- package/dist/check/run.js +114 -0
- package/dist/cli.js +131 -0
- package/dist/detector/cache.d.ts +6 -0
- package/dist/detector/cache.js +44 -0
- package/dist/detector/cache.test.d.ts +1 -0
- package/dist/detector/cache.test.js +54 -0
- package/dist/detector/catalog.d.ts +17 -0
- package/dist/detector/catalog.js +88 -0
- package/dist/detector/catalog.test.d.ts +1 -0
- package/dist/detector/catalog.test.js +33 -0
- package/dist/detector/eol.d.ts +13 -0
- package/dist/detector/eol.js +64 -0
- package/dist/detector/eol.test.d.ts +1 -0
- package/dist/detector/eol.test.js +78 -0
- package/dist/detector/image-parser.d.ts +11 -0
- package/dist/detector/image-parser.js +59 -0
- package/dist/detector/image-parser.test.d.ts +1 -0
- package/dist/detector/image-parser.test.js +87 -0
- package/dist/detector/index.d.ts +33 -0
- package/dist/detector/index.js +217 -0
- package/dist/detector/index.test.d.ts +1 -0
- package/dist/detector/index.test.js +210 -0
- package/dist/detector/osv.d.ts +12 -0
- package/dist/detector/osv.js +50 -0
- package/dist/detector/osv.test.d.ts +1 -0
- package/dist/detector/osv.test.js +86 -0
- package/dist/detector/report.d.ts +6 -0
- package/dist/detector/report.js +91 -0
- package/dist/detector/sources.d.ts +55 -0
- package/dist/detector/sources.js +218 -0
- package/dist/detector/sources.test.d.ts +1 -0
- package/dist/detector/sources.test.js +36 -0
- package/dist/diagnoser/approval.test.d.ts +1 -0
- package/dist/diagnoser/approval.test.js +49 -0
- package/dist/diagnoser/index.d.ts +16 -0
- package/dist/diagnoser/index.js +37 -11
- package/dist/diagnoser/tools.d.ts +2 -2
- package/dist/kb/loader.js +6 -0
- package/dist/kb/writer.d.ts +1 -0
- package/dist/kb/writer.js +4 -0
- package/dist/monitor/index.d.ts +11 -1
- package/dist/monitor/index.js +45 -14
- package/dist/monitor/scope.test.d.ts +1 -0
- package/dist/monitor/scope.test.js +87 -0
- package/dist/notify/discord.js +6 -9
- package/dist/notify/ssrf-guard.d.ts +5 -0
- package/dist/notify/ssrf-guard.js +150 -0
- package/dist/notify/teams.js +6 -9
- package/dist/notify/webhook.js +7 -9
- package/dist/notify/webhook.test.js +21 -0
- package/dist/onboard/index.js +23 -1
- package/dist/orchestrator.js +2 -1
- package/dist/telemetry.js +27 -1
- package/package.json +10 -1
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type KubectlOptions } from "../kubectl.js";
|
|
2
|
+
export type WorkloadKind = "Deployment" | "StatefulSet" | "DaemonSet";
|
|
3
|
+
export interface WorkloadRef {
|
|
4
|
+
kind: WorkloadKind;
|
|
5
|
+
namespace: string;
|
|
6
|
+
name: string;
|
|
7
|
+
hosts?: string[];
|
|
8
|
+
}
|
|
9
|
+
export declare function workloadKey(ref: {
|
|
10
|
+
kind: string;
|
|
11
|
+
namespace: string;
|
|
12
|
+
name: string;
|
|
13
|
+
}): string;
|
|
14
|
+
export interface ImageCandidate {
|
|
15
|
+
source: "image";
|
|
16
|
+
image: string;
|
|
17
|
+
workload: WorkloadRef;
|
|
18
|
+
container: string;
|
|
19
|
+
}
|
|
20
|
+
export interface LabelCandidate {
|
|
21
|
+
source: "label";
|
|
22
|
+
appName: string;
|
|
23
|
+
appVersion: string;
|
|
24
|
+
workload: WorkloadRef;
|
|
25
|
+
}
|
|
26
|
+
export interface HelmCandidate {
|
|
27
|
+
source: "helm";
|
|
28
|
+
chart: string;
|
|
29
|
+
chartVersion: string;
|
|
30
|
+
appVersion?: string;
|
|
31
|
+
namespace: string;
|
|
32
|
+
releaseName: string;
|
|
33
|
+
}
|
|
34
|
+
export type Candidate = ImageCandidate | LabelCandidate | HelmCandidate;
|
|
35
|
+
export declare function fetchWorkloadCandidates(options: KubectlOptions): Promise<Candidate[]>;
|
|
36
|
+
interface HelmReleasePayload {
|
|
37
|
+
name: string;
|
|
38
|
+
namespace: string;
|
|
39
|
+
chart?: {
|
|
40
|
+
metadata?: {
|
|
41
|
+
name?: string;
|
|
42
|
+
version?: string;
|
|
43
|
+
appVersion?: string;
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export declare function decodeHelmRelease(b64: string): HelmReleasePayload | null;
|
|
48
|
+
export declare function fetchHelmCandidates(options: KubectlOptions): Promise<HelmCandidate[]>;
|
|
49
|
+
/**
|
|
50
|
+
* Build a map of workload key (Kind/namespace/name) → ingress hostnames.
|
|
51
|
+
* Joins Ingresses → Services → workload pod-template labels best-effort.
|
|
52
|
+
* Returns an empty map on failure (ingress visibility is non-essential).
|
|
53
|
+
*/
|
|
54
|
+
export declare function fetchIngressHostMap(options: KubectlOptions): Promise<Map<string, string[]>>;
|
|
55
|
+
export {};
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { gunzipSync } from "node:zlib";
|
|
2
|
+
import { Buffer } from "node:buffer";
|
|
3
|
+
import { kubectlJson } from "../kubectl.js";
|
|
4
|
+
import { dbg } from "../debug.js";
|
|
5
|
+
export function workloadKey(ref) {
|
|
6
|
+
return `${ref.kind}/${ref.namespace}/${ref.name}`;
|
|
7
|
+
}
|
|
8
|
+
const LABEL_NAME = "app.kubernetes.io/name";
|
|
9
|
+
const LABEL_VERSION = "app.kubernetes.io/version";
|
|
10
|
+
async function fetchWorkload(kind, resource, options) {
|
|
11
|
+
return (await kubectlJson(["get", resource, "--all-namespaces"], options));
|
|
12
|
+
}
|
|
13
|
+
function extractFromWorkload(kind, w) {
|
|
14
|
+
const out = [];
|
|
15
|
+
const ref = { kind, namespace: w.metadata.namespace, name: w.metadata.name };
|
|
16
|
+
const containers = [
|
|
17
|
+
...(w.spec?.template?.spec?.containers ?? []),
|
|
18
|
+
...(w.spec?.template?.spec?.initContainers ?? []),
|
|
19
|
+
];
|
|
20
|
+
for (const c of containers) {
|
|
21
|
+
if (c.image) {
|
|
22
|
+
out.push({ source: "image", image: c.image, workload: ref, container: c.name });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const labels = {
|
|
26
|
+
...(w.metadata.labels ?? {}),
|
|
27
|
+
...(w.spec?.template?.metadata?.labels ?? {}),
|
|
28
|
+
};
|
|
29
|
+
if (labels[LABEL_NAME] && labels[LABEL_VERSION]) {
|
|
30
|
+
out.push({
|
|
31
|
+
source: "label",
|
|
32
|
+
appName: labels[LABEL_NAME],
|
|
33
|
+
appVersion: labels[LABEL_VERSION],
|
|
34
|
+
workload: ref,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
export async function fetchWorkloadCandidates(options) {
|
|
40
|
+
const [deploys, statefulsets, daemonsets] = await Promise.all([
|
|
41
|
+
fetchWorkload("Deployment", "deployments", options),
|
|
42
|
+
fetchWorkload("StatefulSet", "statefulsets", options),
|
|
43
|
+
fetchWorkload("DaemonSet", "daemonsets", options),
|
|
44
|
+
]);
|
|
45
|
+
const all = [];
|
|
46
|
+
for (const w of deploys.items)
|
|
47
|
+
all.push(...extractFromWorkload("Deployment", w));
|
|
48
|
+
for (const w of statefulsets.items)
|
|
49
|
+
all.push(...extractFromWorkload("StatefulSet", w));
|
|
50
|
+
for (const w of daemonsets.items)
|
|
51
|
+
all.push(...extractFromWorkload("DaemonSet", w));
|
|
52
|
+
return all;
|
|
53
|
+
}
|
|
54
|
+
export function decodeHelmRelease(b64) {
|
|
55
|
+
try {
|
|
56
|
+
// Helm v3 stores releases as base64(gzip(json)). When fetched via
|
|
57
|
+
// `kubectl get secrets -o json`, the API server applies its own base64
|
|
58
|
+
// layer on top of `data.release`, so we decode twice before gunzip.
|
|
59
|
+
const outer = Buffer.from(b64, "base64");
|
|
60
|
+
const inner = Buffer.from(outer.toString("utf-8"), "base64");
|
|
61
|
+
const json = gunzipSync(inner).toString("utf-8");
|
|
62
|
+
return JSON.parse(json);
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
dbg("detector", `helm release decode failed: ${err.message}`);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export async function fetchHelmCandidates(options) {
|
|
70
|
+
let list;
|
|
71
|
+
try {
|
|
72
|
+
list = (await kubectlJson(["get", "secrets", "--all-namespaces", "-l", "owner=helm,status=deployed"], options));
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
dbg("detector", `helm secret listing failed: ${err.message}`);
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
const seen = new Set();
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const s of list.items) {
|
|
81
|
+
const release = s.data?.release;
|
|
82
|
+
if (!release)
|
|
83
|
+
continue;
|
|
84
|
+
const payload = decodeHelmRelease(release);
|
|
85
|
+
if (!payload)
|
|
86
|
+
continue;
|
|
87
|
+
const chart = payload.chart?.metadata?.name;
|
|
88
|
+
const version = payload.chart?.metadata?.version;
|
|
89
|
+
if (!chart || !version)
|
|
90
|
+
continue;
|
|
91
|
+
const key = `${s.metadata.namespace}/${payload.name}/${version}`;
|
|
92
|
+
if (seen.has(key))
|
|
93
|
+
continue;
|
|
94
|
+
seen.add(key);
|
|
95
|
+
out.push({
|
|
96
|
+
source: "helm",
|
|
97
|
+
chart,
|
|
98
|
+
chartVersion: version,
|
|
99
|
+
appVersion: payload.chart?.metadata?.appVersion,
|
|
100
|
+
namespace: s.metadata.namespace,
|
|
101
|
+
releaseName: payload.name,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
function selectorMatches(selector, labels) {
|
|
107
|
+
const keys = Object.keys(selector);
|
|
108
|
+
if (keys.length === 0)
|
|
109
|
+
return false;
|
|
110
|
+
for (const k of keys) {
|
|
111
|
+
if (labels[k] !== selector[k])
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Build a map of workload key (Kind/namespace/name) → ingress hostnames.
|
|
118
|
+
* Joins Ingresses → Services → workload pod-template labels best-effort.
|
|
119
|
+
* Returns an empty map on failure (ingress visibility is non-essential).
|
|
120
|
+
*/
|
|
121
|
+
export async function fetchIngressHostMap(options) {
|
|
122
|
+
const out = new Map();
|
|
123
|
+
let services;
|
|
124
|
+
let ingresses;
|
|
125
|
+
let workloadLists;
|
|
126
|
+
try {
|
|
127
|
+
[services, ingresses, ...workloadLists] = await Promise.all([
|
|
128
|
+
kubectlJson(["get", "services", "--all-namespaces"], options),
|
|
129
|
+
kubectlJson(["get", "ingresses", "--all-namespaces"], options),
|
|
130
|
+
fetchWorkload("Deployment", "deployments", options),
|
|
131
|
+
fetchWorkload("StatefulSet", "statefulsets", options),
|
|
132
|
+
fetchWorkload("DaemonSet", "daemonsets", options),
|
|
133
|
+
]);
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
dbg("detector", `ingress map fetch failed: ${err.message}`);
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
// service ns/name → selector
|
|
140
|
+
const serviceSelectors = new Map();
|
|
141
|
+
for (const svc of services.items) {
|
|
142
|
+
const sel = svc.spec?.selector;
|
|
143
|
+
if (!sel || Object.keys(sel).length === 0)
|
|
144
|
+
continue;
|
|
145
|
+
serviceSelectors.set(`${svc.metadata.namespace}/${svc.metadata.name}`, sel);
|
|
146
|
+
}
|
|
147
|
+
// service ns/name → set of hosts
|
|
148
|
+
const serviceHosts = new Map();
|
|
149
|
+
const addServiceHost = (ns, svc, host) => {
|
|
150
|
+
if (!svc || !host)
|
|
151
|
+
return;
|
|
152
|
+
const key = `${ns}/${svc}`;
|
|
153
|
+
let s = serviceHosts.get(key);
|
|
154
|
+
if (!s) {
|
|
155
|
+
s = new Set();
|
|
156
|
+
serviceHosts.set(key, s);
|
|
157
|
+
}
|
|
158
|
+
s.add(host);
|
|
159
|
+
};
|
|
160
|
+
for (const ing of ingresses.items) {
|
|
161
|
+
const ns = ing.metadata.namespace;
|
|
162
|
+
const tlsHosts = new Set();
|
|
163
|
+
for (const t of ing.spec?.tls ?? []) {
|
|
164
|
+
for (const h of t.hosts ?? [])
|
|
165
|
+
tlsHosts.add(h);
|
|
166
|
+
}
|
|
167
|
+
for (const rule of ing.spec?.rules ?? []) {
|
|
168
|
+
// A rule with no `host` matches all inbound hosts per the Ingress
|
|
169
|
+
// spec — the backend is still publicly reachable, so record it as `*`
|
|
170
|
+
// rather than dropping it (which would mis-classify as internal-only).
|
|
171
|
+
const host = rule.host ?? "*";
|
|
172
|
+
for (const p of rule.http?.paths ?? []) {
|
|
173
|
+
const svc = p.backend?.service?.name ?? p.backend?.serviceName;
|
|
174
|
+
addServiceHost(ns, svc, host);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const defSvc = ing.spec?.defaultBackend?.service?.name ?? ing.spec?.defaultBackend?.serviceName;
|
|
178
|
+
if (defSvc) {
|
|
179
|
+
// defaultBackend catches anything not matched by a rule — also wildcard
|
|
180
|
+
// exposure. Prefer explicit TLS hosts when present for better display.
|
|
181
|
+
if (tlsHosts.size > 0) {
|
|
182
|
+
for (const h of tlsHosts)
|
|
183
|
+
addServiceHost(ns, defSvc, h);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
addServiceHost(ns, defSvc, "*");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const allWorkloads = [];
|
|
191
|
+
const kinds = ["Deployment", "StatefulSet", "DaemonSet"];
|
|
192
|
+
workloadLists.forEach((list, idx) => {
|
|
193
|
+
for (const item of list.items)
|
|
194
|
+
allWorkloads.push({ kind: kinds[idx], item });
|
|
195
|
+
});
|
|
196
|
+
for (const { kind, item } of allWorkloads) {
|
|
197
|
+
const ns = item.metadata.namespace;
|
|
198
|
+
const podLabels = item.spec?.template?.metadata?.labels ?? {};
|
|
199
|
+
if (Object.keys(podLabels).length === 0)
|
|
200
|
+
continue;
|
|
201
|
+
const hosts = new Set();
|
|
202
|
+
for (const [svcKey, sel] of serviceSelectors) {
|
|
203
|
+
if (!svcKey.startsWith(`${ns}/`))
|
|
204
|
+
continue;
|
|
205
|
+
if (!selectorMatches(sel, podLabels))
|
|
206
|
+
continue;
|
|
207
|
+
const hs = serviceHosts.get(svcKey);
|
|
208
|
+
if (!hs)
|
|
209
|
+
continue;
|
|
210
|
+
for (const h of hs)
|
|
211
|
+
hosts.add(h);
|
|
212
|
+
}
|
|
213
|
+
if (hosts.size > 0) {
|
|
214
|
+
out.set(workloadKey({ kind, namespace: ns, name: item.metadata.name }), [...hosts].sort());
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { gzipSync } from "node:zlib";
|
|
3
|
+
import { Buffer } from "node:buffer";
|
|
4
|
+
import { decodeHelmRelease } from "./sources.js";
|
|
5
|
+
// Reproduce the encoding chain Helm + the k8s API actually produce.
|
|
6
|
+
// Helm stores the release as `base64(gzip(json))` inside the Secret value,
|
|
7
|
+
// and the k8s API base64-encodes Secret `data.*` values on the wire — so the
|
|
8
|
+
// string kubectl returns is `base64(base64(gzip(json)))`. This test exists
|
|
9
|
+
// to lock in the decoder's behaviour against that chain so we can't
|
|
10
|
+
// regress to a single-base64 decode (which silently drops every Helm
|
|
11
|
+
// release at runtime).
|
|
12
|
+
function encodeAsKubectlReturnsIt(payload) {
|
|
13
|
+
const json = JSON.stringify(payload);
|
|
14
|
+
const gzipped = gzipSync(Buffer.from(json, "utf-8"));
|
|
15
|
+
const helmLayer = gzipped.toString("base64");
|
|
16
|
+
const apiLayer = Buffer.from(helmLayer, "utf-8").toString("base64");
|
|
17
|
+
return apiLayer;
|
|
18
|
+
}
|
|
19
|
+
describe("decodeHelmRelease", () => {
|
|
20
|
+
it("decodes a release blob in the format kubectl returns", () => {
|
|
21
|
+
const release = {
|
|
22
|
+
name: "postgres",
|
|
23
|
+
namespace: "data",
|
|
24
|
+
chart: { metadata: { name: "postgresql", version: "12.0.0", appVersion: "15.2.0" } },
|
|
25
|
+
};
|
|
26
|
+
const encoded = encodeAsKubectlReturnsIt(release);
|
|
27
|
+
expect(decodeHelmRelease(encoded)).toEqual(release);
|
|
28
|
+
});
|
|
29
|
+
it("returns null on garbage input rather than throwing", () => {
|
|
30
|
+
expect(decodeHelmRelease("not-base64-and-not-gzip")).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
it("returns null on valid base64 that isn't gzip", () => {
|
|
33
|
+
const apiLayer = Buffer.from("aGVsbG8gd29ybGQ=", "utf-8").toString("base64");
|
|
34
|
+
expect(decodeHelmRelease(apiLayer)).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { resolveApprovalDecision } from "./index.js";
|
|
3
|
+
const SAFE = new Set(["get_logs", "get_events", "describe_resource", "ask_user_question"]);
|
|
4
|
+
describe("resolveApprovalDecision", () => {
|
|
5
|
+
it("auto-executes a safe (read-only) action with no approver", async () => {
|
|
6
|
+
const decision = await resolveApprovalDecision("get_logs", {}, SAFE, undefined);
|
|
7
|
+
expect(decision.execute).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
it("DENIES a non-safe mutating action when no approver exists (non-interactive mode)", async () => {
|
|
10
|
+
const decision = await resolveApprovalDecision("scale_deployment", { deployment: "web", replicas: 0 }, SAFE, undefined);
|
|
11
|
+
expect(decision.execute).toBe(false);
|
|
12
|
+
if (!decision.execute) {
|
|
13
|
+
expect(decision.reason).toMatch(/no approver is available/i);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
it("never invokes an executor implicitly — caller must gate on execute=false", async () => {
|
|
17
|
+
// Simulates the tool loop: a denied decision must not run the executor.
|
|
18
|
+
const executor = vi.fn();
|
|
19
|
+
const decision = await resolveApprovalDecision("restart_pod", { pod: "x" }, SAFE, undefined);
|
|
20
|
+
if (decision.execute)
|
|
21
|
+
executor();
|
|
22
|
+
expect(executor).not.toHaveBeenCalled();
|
|
23
|
+
});
|
|
24
|
+
it("calls onApproval for non-safe actions and executes when approved", async () => {
|
|
25
|
+
const onApproval = vi.fn().mockResolvedValue(true);
|
|
26
|
+
const decision = await resolveApprovalDecision("scale_deployment", { deployment: "web", replicas: 3 }, SAFE, onApproval);
|
|
27
|
+
expect(onApproval).toHaveBeenCalledWith("scale_deployment", { deployment: "web", replicas: 3 });
|
|
28
|
+
expect(decision.execute).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
it("denies with the user-denial message when onApproval returns false", async () => {
|
|
31
|
+
const onApproval = vi.fn().mockResolvedValue(false);
|
|
32
|
+
const decision = await resolveApprovalDecision("restart_pod", {}, SAFE, onApproval);
|
|
33
|
+
expect(decision.execute).toBe(false);
|
|
34
|
+
if (!decision.execute) {
|
|
35
|
+
expect(decision.reason).toMatch(/denied by user/i);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
it("does not call onApproval for safe actions", async () => {
|
|
39
|
+
const onApproval = vi.fn().mockResolvedValue(false);
|
|
40
|
+
const decision = await resolveApprovalDecision("get_logs", {}, SAFE, onApproval);
|
|
41
|
+
expect(onApproval).not.toHaveBeenCalled();
|
|
42
|
+
expect(decision.execute).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
it("treats user-configured safe_actions as auto-executable", async () => {
|
|
45
|
+
const safe = new Set([...SAFE, "rollout_restart"]);
|
|
46
|
+
const decision = await resolveApprovalDecision("rollout_restart", {}, safe, undefined);
|
|
47
|
+
expect(decision.execute).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
import type { Issue } from "../monitor/types.js";
|
|
2
|
+
export type ApprovalDecision = {
|
|
3
|
+
execute: true;
|
|
4
|
+
} | {
|
|
5
|
+
execute: false;
|
|
6
|
+
reason: string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Decide whether a tool call may execute, given the set of auto-safe actions and
|
|
10
|
+
* the (optional) approval callback.
|
|
11
|
+
*
|
|
12
|
+
* Security-critical: a missing/undefined `onApproval` MUST mean DENY for any
|
|
13
|
+
* action that is not in `safeActions`. In non-interactive mode the CLI passes no
|
|
14
|
+
* (or a deny-all) approver, and destructive remediation (e.g. scale-to-0) must
|
|
15
|
+
* never run unattended.
|
|
16
|
+
*/
|
|
17
|
+
export declare function resolveApprovalDecision(toolName: string, params: Record<string, unknown>, safeActions: Set<string>, onApproval?: (action: string, params: Record<string, unknown>) => Promise<boolean>): Promise<ApprovalDecision>;
|
|
2
18
|
export interface DiagnosisResult {
|
|
3
19
|
analysis: string;
|
|
4
20
|
action?: {
|
package/dist/diagnoser/index.js
CHANGED
|
@@ -19,6 +19,32 @@ function truncateOutput(output) {
|
|
|
19
19
|
const half = Math.floor(MAX_TOOL_OUTPUT_CHARS / 2);
|
|
20
20
|
return output.slice(0, half) + "\n\n... [truncated] ...\n\n" + output.slice(-half);
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Decide whether a tool call may execute, given the set of auto-safe actions and
|
|
24
|
+
* the (optional) approval callback.
|
|
25
|
+
*
|
|
26
|
+
* Security-critical: a missing/undefined `onApproval` MUST mean DENY for any
|
|
27
|
+
* action that is not in `safeActions`. In non-interactive mode the CLI passes no
|
|
28
|
+
* (or a deny-all) approver, and destructive remediation (e.g. scale-to-0) must
|
|
29
|
+
* never run unattended.
|
|
30
|
+
*/
|
|
31
|
+
export async function resolveApprovalDecision(toolName, params, safeActions, onApproval) {
|
|
32
|
+
// Safe (read-only or explicitly allowlisted) actions auto-execute.
|
|
33
|
+
if (safeActions.has(toolName))
|
|
34
|
+
return { execute: true };
|
|
35
|
+
// Non-safe action requires approval regardless of whether a callback exists.
|
|
36
|
+
if (!onApproval) {
|
|
37
|
+
return {
|
|
38
|
+
execute: false,
|
|
39
|
+
reason: "Action requires approval but no approver is available (non-interactive mode); denied. Propose a safe alternative or escalate to a human.",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const approved = await onApproval(toolName, params);
|
|
43
|
+
if (!approved) {
|
|
44
|
+
return { execute: false, reason: "Action denied by user. Propose an alternative." };
|
|
45
|
+
}
|
|
46
|
+
return { execute: true };
|
|
47
|
+
}
|
|
22
48
|
async function askUserQuestion(question, choices) {
|
|
23
49
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
24
50
|
return new Promise((resolve) => {
|
|
@@ -184,17 +210,17 @@ If it requires a risky action (rollback, delete, scale to zero), propose it but
|
|
|
184
210
|
});
|
|
185
211
|
continue;
|
|
186
212
|
}
|
|
187
|
-
// Check if action requires approval
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
213
|
+
// Check if action requires approval. A missing onApproval callback
|
|
214
|
+
// (non-interactive mode) means DENY for any non-safe action — never
|
|
215
|
+
// execute destructive remediation unattended.
|
|
216
|
+
const decision = await resolveApprovalDecision(block.name, block.input, effectiveSafeActions, options?.onApproval);
|
|
217
|
+
if (!decision.execute) {
|
|
218
|
+
toolResults.push({
|
|
219
|
+
type: "tool_result",
|
|
220
|
+
tool_use_id: block.id,
|
|
221
|
+
content: decision.reason,
|
|
222
|
+
});
|
|
223
|
+
continue;
|
|
198
224
|
}
|
|
199
225
|
try {
|
|
200
226
|
const result = await executor(block.input, { context: clusterContext });
|
|
@@ -25,11 +25,11 @@ export declare const kubectlDescribeSchema: z.ZodObject<{
|
|
|
25
25
|
}, "strip", z.ZodTypeAny, {
|
|
26
26
|
name: string;
|
|
27
27
|
namespace: string;
|
|
28
|
-
resource_type: "
|
|
28
|
+
resource_type: "service" | "node" | "pod" | "deployment" | "statefulset" | "daemonset" | "replicaset" | "job" | "cronjob" | "ingress" | "persistentvolumeclaim" | "configmap" | "endpoints";
|
|
29
29
|
}, {
|
|
30
30
|
name: string;
|
|
31
31
|
namespace: string;
|
|
32
|
-
resource_type: "
|
|
32
|
+
resource_type: "service" | "node" | "pod" | "deployment" | "statefulset" | "daemonset" | "replicaset" | "job" | "cronjob" | "ingress" | "persistentvolumeclaim" | "configmap" | "endpoints";
|
|
33
33
|
}>;
|
|
34
34
|
export declare const restartPodSchema: z.ZodObject<{
|
|
35
35
|
namespace: z.ZodString;
|
package/dist/kb/loader.js
CHANGED
|
@@ -13,6 +13,12 @@ export function buildSystemPrompt(kbDir) {
|
|
|
13
13
|
sections.push(readFileSync(clusterPath, "utf-8"));
|
|
14
14
|
sections.push("");
|
|
15
15
|
}
|
|
16
|
+
// Load applications.md (OSS app inventory with CVE/EOL status)
|
|
17
|
+
const applicationsPath = join(kbDir, "applications.md");
|
|
18
|
+
if (existsSync(applicationsPath)) {
|
|
19
|
+
sections.push(readFileSync(applicationsPath, "utf-8"));
|
|
20
|
+
sections.push("");
|
|
21
|
+
}
|
|
16
22
|
// Load project files
|
|
17
23
|
const projectsDir = join(kbDir, "projects");
|
|
18
24
|
if (existsSync(projectsDir)) {
|
package/dist/kb/writer.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export declare function ensureKbDir(kbDir: string): void;
|
|
2
2
|
export declare function writeClusterKb(kbDir: string, content: string): void;
|
|
3
|
+
export declare function writeApplicationsKb(kbDir: string, content: string): void;
|
|
3
4
|
export declare function writeProjectKb(kbDir: string, projectName: string, content: string): void;
|
|
4
5
|
export declare function writeRunbook(kbDir: string, name: string, content: string): void;
|
|
5
6
|
/**
|
package/dist/kb/writer.js
CHANGED
|
@@ -12,6 +12,10 @@ export function writeClusterKb(kbDir, content) {
|
|
|
12
12
|
ensureKbDir(kbDir);
|
|
13
13
|
writeFileSync(join(kbDir, "cluster.md"), content);
|
|
14
14
|
}
|
|
15
|
+
export function writeApplicationsKb(kbDir, content) {
|
|
16
|
+
ensureKbDir(kbDir);
|
|
17
|
+
writeFileSync(join(kbDir, "applications.md"), content);
|
|
18
|
+
}
|
|
15
19
|
export function writeProjectKb(kbDir, projectName, content) {
|
|
16
20
|
ensureKbDir(kbDir);
|
|
17
21
|
writeFileSync(join(kbDir, "projects", `${projectName}.md`), content);
|
package/dist/monitor/index.d.ts
CHANGED
|
@@ -8,8 +8,18 @@ export interface CheckSummary {
|
|
|
8
8
|
nodeCount: number;
|
|
9
9
|
namespaceCount: number;
|
|
10
10
|
}
|
|
11
|
+
export interface RunChecksScope {
|
|
12
|
+
/**
|
|
13
|
+
* Namespaces to scope the scan to. When set, kubectl queries are issued
|
|
14
|
+
* per-namespace (e.g. `kubectl get pods -n foo`) instead of the
|
|
15
|
+
* cluster-wide `--all-namespaces` form. Cluster-scoped queries
|
|
16
|
+
* (`get nodes`, `get namespaces`) are still attempted but their failures
|
|
17
|
+
* are tolerated so namespace-scoped service accounts can use `kubeagent check`.
|
|
18
|
+
*/
|
|
19
|
+
namespaces?: string[];
|
|
20
|
+
}
|
|
11
21
|
export declare function runChecks(options: KubectlOptions): Promise<Issue[]>;
|
|
12
|
-
export declare function runChecks(options: KubectlOptions, withSummary: true): Promise<CheckSummary>;
|
|
22
|
+
export declare function runChecks(options: KubectlOptions, withSummary: true, scope?: RunChecksScope): Promise<CheckSummary>;
|
|
13
23
|
export declare function computeResolvedKeys(activeKeys: Set<string>, currentKeys: Set<string>): string[];
|
|
14
24
|
export declare function updateActiveKeys(activeKeys: Set<string>, resolvedKeys: string[], currentKeys: Set<string>): void;
|
|
15
25
|
export declare function startMonitor(options: KubectlOptions, intervalMs: number, onIssues: IssueCallback, onResolved?: ResolveCallback, onFirstCycle?: () => void): {
|
package/dist/monitor/index.js
CHANGED
|
@@ -33,28 +33,59 @@ function deduplicateIssues(issues) {
|
|
|
33
33
|
return true;
|
|
34
34
|
});
|
|
35
35
|
}
|
|
36
|
-
export async function runChecks(options, withSummary) {
|
|
36
|
+
export async function runChecks(options, withSummary, scope) {
|
|
37
37
|
const allIssues = [];
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
const scoped = scope?.namespaces && scope.namespaces.length > 0 ? scope.namespaces : null;
|
|
39
|
+
// Pods: query per-namespace when scoped (so ns-bound service accounts
|
|
40
|
+
// don't need cluster-wide list-pods), otherwise all-namespaces.
|
|
41
|
+
let podCount = 0;
|
|
42
|
+
if (scoped) {
|
|
43
|
+
for (const ns of scoped) {
|
|
44
|
+
const list = (await kubectlJson(["get", "pods", "-n", ns], options).catch(() => ({ items: [] })));
|
|
45
|
+
allIssues.push(...findPodIssues(list));
|
|
46
|
+
podCount += list.items.length;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
const podList = (await kubectlJson(["get", "pods", "--all-namespaces"], options));
|
|
51
|
+
allIssues.push(...findPodIssues(podList));
|
|
52
|
+
podCount = podList.items.length;
|
|
53
|
+
}
|
|
54
|
+
// Nodes: cluster-scoped. Tolerate forbidden so ns-bound service accounts
|
|
55
|
+
// still get a useful pod-level report (node issues just don't apply to them).
|
|
56
|
+
const nodeList = (await kubectlJson(["get", "nodes"], options).catch(() => ({ items: [] })));
|
|
43
57
|
allIssues.push(...findNodeIssues(nodeList));
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
58
|
+
// Failed jobs: per-namespace when scoped, otherwise all-namespaces.
|
|
59
|
+
if (scoped) {
|
|
60
|
+
for (const ns of scoped) {
|
|
61
|
+
const list = (await kubectlJson(["get", "jobs", "-n", ns, "--field-selector=status.failed>0"], options).catch(() => ({ items: [] })));
|
|
62
|
+
allIssues.push(...findJobIssues(list));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
const jobList = (await kubectlJson(["get", "jobs", "--all-namespaces", "--field-selector=status.failed>0"], options).catch(() => ({ items: [] })));
|
|
67
|
+
allIssues.push(...findJobIssues(jobList));
|
|
68
|
+
}
|
|
47
69
|
const issues = deduplicateIssues(allIssues);
|
|
48
70
|
if (!withSummary)
|
|
49
71
|
return issues;
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
72
|
+
// Namespace count: cluster-scoped, tolerate forbidden the same way as nodes.
|
|
73
|
+
// When the caller already specified namespaces, use that length so the
|
|
74
|
+
// summary reflects the requested scope, not the cluster's namespace total.
|
|
75
|
+
let namespaceCount;
|
|
76
|
+
if (scoped) {
|
|
77
|
+
namespaceCount = scoped.length;
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
const nsList = await kubectlJson(["get", "namespaces"], options)
|
|
81
|
+
.catch(() => ({ items: [] }));
|
|
82
|
+
namespaceCount = nsList.items.length;
|
|
83
|
+
}
|
|
53
84
|
return {
|
|
54
85
|
issues,
|
|
55
|
-
podCount
|
|
86
|
+
podCount,
|
|
56
87
|
nodeCount: nodeList.items.length,
|
|
57
|
-
namespaceCount
|
|
88
|
+
namespaceCount,
|
|
58
89
|
};
|
|
59
90
|
}
|
|
60
91
|
// How long a pod must be Pending before it's reported as an issue.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|