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.
Files changed (61) hide show
  1. package/README.md +34 -0
  2. package/dist/auth.js +15 -0
  3. package/dist/check/index.d.ts +34 -0
  4. package/dist/check/index.js +77 -0
  5. package/dist/check/index.test.d.ts +1 -0
  6. package/dist/check/index.test.js +202 -0
  7. package/dist/check/run.d.ts +10 -0
  8. package/dist/check/run.js +114 -0
  9. package/dist/cli.js +131 -0
  10. package/dist/detector/cache.d.ts +6 -0
  11. package/dist/detector/cache.js +44 -0
  12. package/dist/detector/cache.test.d.ts +1 -0
  13. package/dist/detector/cache.test.js +54 -0
  14. package/dist/detector/catalog.d.ts +17 -0
  15. package/dist/detector/catalog.js +88 -0
  16. package/dist/detector/catalog.test.d.ts +1 -0
  17. package/dist/detector/catalog.test.js +33 -0
  18. package/dist/detector/eol.d.ts +13 -0
  19. package/dist/detector/eol.js +64 -0
  20. package/dist/detector/eol.test.d.ts +1 -0
  21. package/dist/detector/eol.test.js +78 -0
  22. package/dist/detector/image-parser.d.ts +11 -0
  23. package/dist/detector/image-parser.js +59 -0
  24. package/dist/detector/image-parser.test.d.ts +1 -0
  25. package/dist/detector/image-parser.test.js +87 -0
  26. package/dist/detector/index.d.ts +33 -0
  27. package/dist/detector/index.js +217 -0
  28. package/dist/detector/index.test.d.ts +1 -0
  29. package/dist/detector/index.test.js +210 -0
  30. package/dist/detector/osv.d.ts +12 -0
  31. package/dist/detector/osv.js +50 -0
  32. package/dist/detector/osv.test.d.ts +1 -0
  33. package/dist/detector/osv.test.js +86 -0
  34. package/dist/detector/report.d.ts +6 -0
  35. package/dist/detector/report.js +91 -0
  36. package/dist/detector/sources.d.ts +55 -0
  37. package/dist/detector/sources.js +218 -0
  38. package/dist/detector/sources.test.d.ts +1 -0
  39. package/dist/detector/sources.test.js +36 -0
  40. package/dist/diagnoser/approval.test.d.ts +1 -0
  41. package/dist/diagnoser/approval.test.js +49 -0
  42. package/dist/diagnoser/index.d.ts +16 -0
  43. package/dist/diagnoser/index.js +37 -11
  44. package/dist/diagnoser/tools.d.ts +2 -2
  45. package/dist/kb/loader.js +6 -0
  46. package/dist/kb/writer.d.ts +1 -0
  47. package/dist/kb/writer.js +4 -0
  48. package/dist/monitor/index.d.ts +11 -1
  49. package/dist/monitor/index.js +45 -14
  50. package/dist/monitor/scope.test.d.ts +1 -0
  51. package/dist/monitor/scope.test.js +87 -0
  52. package/dist/notify/discord.js +6 -9
  53. package/dist/notify/ssrf-guard.d.ts +5 -0
  54. package/dist/notify/ssrf-guard.js +150 -0
  55. package/dist/notify/teams.js +6 -9
  56. package/dist/notify/webhook.js +7 -9
  57. package/dist/notify/webhook.test.js +21 -0
  58. package/dist/onboard/index.js +23 -1
  59. package/dist/orchestrator.js +2 -1
  60. package/dist/telemetry.js +27 -1
  61. package/package.json +10 -1
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { runChecks } from "./index.js";
3
+ import * as kubectl from "../kubectl.js";
4
+ describe("runChecks namespace scope", () => {
5
+ beforeEach(() => {
6
+ vi.restoreAllMocks();
7
+ });
8
+ it("queries per-namespace when scope.namespaces is set", async () => {
9
+ const calls = [];
10
+ vi.spyOn(kubectl, "kubectlJson").mockImplementation(async (args) => {
11
+ calls.push(args);
12
+ return { items: [] };
13
+ });
14
+ await runChecks({}, true, { namespaces: ["foo", "bar"] });
15
+ // Pods: one per requested namespace, never --all-namespaces
16
+ const podCalls = calls.filter((c) => c[0] === "get" && c[1] === "pods");
17
+ expect(podCalls).toHaveLength(2);
18
+ expect(podCalls[0]).toEqual(["get", "pods", "-n", "foo"]);
19
+ expect(podCalls[1]).toEqual(["get", "pods", "-n", "bar"]);
20
+ expect(calls.some((c) => c.includes("--all-namespaces") && c[1] === "pods")).toBe(false);
21
+ // Jobs: same per-namespace pattern
22
+ const jobCalls = calls.filter((c) => c[0] === "get" && c[1] === "jobs");
23
+ expect(jobCalls).toHaveLength(2);
24
+ expect(jobCalls[0]).toContain("foo");
25
+ expect(jobCalls[1]).toContain("bar");
26
+ expect(calls.some((c) => c.includes("--all-namespaces") && c[1] === "jobs")).toBe(false);
27
+ });
28
+ it("uses --all-namespaces when no scope is given", async () => {
29
+ const calls = [];
30
+ vi.spyOn(kubectl, "kubectlJson").mockImplementation(async (args) => {
31
+ calls.push(args);
32
+ return { items: [] };
33
+ });
34
+ await runChecks({});
35
+ expect(calls.some((c) => c[0] === "get" && c[1] === "pods" && c.includes("--all-namespaces"))).toBe(true);
36
+ expect(calls.some((c) => c[0] === "get" && c[1] === "jobs" && c.includes("--all-namespaces"))).toBe(true);
37
+ });
38
+ it("tolerates forbidden errors on cluster-scoped node queries", async () => {
39
+ // Simulate a namespace-scoped service account: pods succeed, nodes 403
40
+ vi.spyOn(kubectl, "kubectlJson").mockImplementation(async (args) => {
41
+ if (args[1] === "nodes") {
42
+ throw new Error("Error from server (Forbidden): nodes is forbidden");
43
+ }
44
+ return { items: [] };
45
+ });
46
+ const result = await runChecks({}, true, { namespaces: ["foo"] });
47
+ expect(result.issues).toEqual([]);
48
+ expect(result.nodeCount).toBe(0);
49
+ });
50
+ it("reports namespaceCount = scope length when scoped (skips cluster ns list)", async () => {
51
+ const calls = [];
52
+ vi.spyOn(kubectl, "kubectlJson").mockImplementation(async (args) => {
53
+ calls.push(args);
54
+ return { items: [] };
55
+ });
56
+ const result = await runChecks({}, true, { namespaces: ["foo", "bar", "baz"] });
57
+ expect(result.namespaceCount).toBe(3);
58
+ // Should not have called get namespaces — that's cluster-scoped and may 403.
59
+ expect(calls.some((c) => c[0] === "get" && c[1] === "namespaces")).toBe(false);
60
+ });
61
+ it("accumulates pod counts across namespaces", async () => {
62
+ const okPod = (ns, name) => ({
63
+ metadata: { name, namespace: ns },
64
+ status: { phase: "Running", containerStatuses: [] },
65
+ });
66
+ vi.spyOn(kubectl, "kubectlJson").mockImplementation(async (args) => {
67
+ if (args[1] === "pods" && args[3] === "foo") {
68
+ return { items: [okPod("foo", "a"), okPod("foo", "b"), okPod("foo", "c")] };
69
+ }
70
+ if (args[1] === "pods" && args[3] === "bar") {
71
+ return { items: [okPod("bar", "x"), okPod("bar", "y")] };
72
+ }
73
+ return { items: [] };
74
+ });
75
+ const result = await runChecks({}, true, { namespaces: ["foo", "bar"] });
76
+ expect(result.podCount).toBe(5);
77
+ });
78
+ it("empty scope.namespaces is treated as no scope (all-namespaces)", async () => {
79
+ const calls = [];
80
+ vi.spyOn(kubectl, "kubectlJson").mockImplementation(async (args) => {
81
+ calls.push(args);
82
+ return { items: [] };
83
+ });
84
+ await runChecks({}, true, { namespaces: [] });
85
+ expect(calls.some((c) => c[0] === "get" && c[1] === "pods" && c.includes("--all-namespaces"))).toBe(true);
86
+ });
87
+ });
@@ -1,3 +1,4 @@
1
+ import { isSafeWebhookUrl } from "./ssrf-guard.js";
1
2
  const CRITICAL_COLOR = 0xe74c3c; // red
2
3
  const WARNING_COLOR = 0xf39c12; // yellow
3
4
  const INFO_COLOR = 0x3498db; // blue
@@ -32,15 +33,9 @@ function formatDiscordPayload(issues, clusterContext) {
32
33
  };
33
34
  }
34
35
  async function postToDiscord(webhookUrl, payload) {
35
- try {
36
- const url = new URL(webhookUrl);
37
- if (!["https:", "http:"].includes(url.protocol)) {
38
- console.error("Discord: invalid URL protocol");
39
- return;
40
- }
41
- }
42
- catch {
43
- console.error("Discord: invalid webhook URL");
36
+ // SSRF guard: reject invalid URLs and hosts in private/loopback/metadata ranges.
37
+ if (!(await isSafeWebhookUrl(webhookUrl))) {
38
+ console.error("Discord: invalid or unsafe webhook URL (must be a public host)");
44
39
  return;
45
40
  }
46
41
  try {
@@ -48,6 +43,8 @@ async function postToDiscord(webhookUrl, payload) {
48
43
  method: "POST",
49
44
  headers: { "Content-Type": "application/json" },
50
45
  body: JSON.stringify(payload),
46
+ // Don't follow redirects to internal addresses (see ssrf-guard).
47
+ redirect: "error",
51
48
  signal: AbortSignal.timeout(10_000),
52
49
  });
53
50
  if (!res.ok)
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Returns true if the URL is safe to fetch (public host, valid scheme).
3
+ * Returns false for invalid URLs or hosts in any blocked range.
4
+ */
5
+ export declare function isSafeWebhookUrl(rawUrl: string): Promise<boolean>;
@@ -0,0 +1,150 @@
1
+ import { promises as dns } from "node:dns";
2
+ import { isIP } from "node:net";
3
+ /**
4
+ * Minimal SSRF guard for the CLI. The CLI is a separate package from the server
5
+ * (they do not share runtime code — see CLAUDE.md), so this duplicates the core
6
+ * of `server/src/lib/ssrf-guard.ts`: reject user-supplied webhook URLs that point
7
+ * at private/loopback/link-local/ULA/metadata/CGNAT ranges, resolving hostnames
8
+ * (all addresses) to defeat the common DNS-rebinding case.
9
+ */
10
+ function ipv4ToInt(ip) {
11
+ const parts = ip.split(".");
12
+ if (parts.length !== 4)
13
+ return null;
14
+ let value = 0;
15
+ for (const part of parts) {
16
+ if (!/^\d{1,3}$/.test(part))
17
+ return null;
18
+ const n = Number(part);
19
+ if (n > 255)
20
+ return null;
21
+ value = value * 256 + n;
22
+ }
23
+ return value >>> 0;
24
+ }
25
+ function inV4Range(ipInt, cidrBase, prefix) {
26
+ const base = ipv4ToInt(cidrBase);
27
+ if (base === null)
28
+ return false;
29
+ const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
30
+ return (ipInt & mask) === (base & mask);
31
+ }
32
+ function isBlockedIPv4(ip) {
33
+ const n = ipv4ToInt(ip);
34
+ if (n === null)
35
+ return true;
36
+ return (inV4Range(n, "10.0.0.0", 8) ||
37
+ inV4Range(n, "172.16.0.0", 12) ||
38
+ inV4Range(n, "192.168.0.0", 16) ||
39
+ inV4Range(n, "127.0.0.0", 8) ||
40
+ inV4Range(n, "169.254.0.0", 16) ||
41
+ inV4Range(n, "100.64.0.0", 10) ||
42
+ inV4Range(n, "0.0.0.0", 8));
43
+ }
44
+ function ipv6ToGroups(ipRaw) {
45
+ let ip = ipRaw.toLowerCase();
46
+ const pct = ip.indexOf("%");
47
+ if (pct !== -1)
48
+ ip = ip.slice(0, pct);
49
+ let v4Tail = null;
50
+ const lastColon = ip.lastIndexOf(":");
51
+ const tail = lastColon === -1 ? "" : ip.slice(lastColon + 1);
52
+ if (tail.includes(".")) {
53
+ const v4 = ipv4ToInt(tail);
54
+ if (v4 === null)
55
+ return null;
56
+ v4Tail = [(v4 >>> 16) & 0xffff, v4 & 0xffff];
57
+ ip = ip.slice(0, lastColon + 1);
58
+ }
59
+ const halves = ip.split("::");
60
+ if (halves.length > 2)
61
+ return null;
62
+ const parseSide = (side) => {
63
+ if (side === "")
64
+ return [];
65
+ const out = [];
66
+ for (const h of side.split(":")) {
67
+ if (h === "" || !/^[0-9a-f]{1,4}$/.test(h))
68
+ return null;
69
+ out.push(parseInt(h, 16));
70
+ }
71
+ return out;
72
+ };
73
+ const left = parseSide(halves[0]);
74
+ if (left === null)
75
+ return null;
76
+ let groups;
77
+ if (halves.length === 2) {
78
+ const right = parseSide(halves[1]);
79
+ if (right === null)
80
+ return null;
81
+ const rightAll = v4Tail ? [...right, ...v4Tail] : right;
82
+ const fill = 8 - left.length - rightAll.length;
83
+ if (fill < 0)
84
+ return null;
85
+ groups = [...left, ...Array(fill).fill(0), ...rightAll];
86
+ }
87
+ else {
88
+ groups = v4Tail ? [...left, ...v4Tail] : left;
89
+ }
90
+ if (groups.length !== 8)
91
+ return null;
92
+ return groups;
93
+ }
94
+ function isBlockedIPv6(ip) {
95
+ const g = ipv6ToGroups(ip);
96
+ if (g === null)
97
+ return true;
98
+ if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1)
99
+ return true; // ::1
100
+ if (g.every((x) => x === 0))
101
+ return true; // ::
102
+ if ((g[0] & 0xfe00) === 0xfc00)
103
+ return true; // fc00::/7
104
+ if ((g[0] & 0xffc0) === 0xfe80)
105
+ return true; // fe80::/10
106
+ const isMapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 0xffff;
107
+ const isCompat = g.slice(0, 6).every((x) => x === 0) && (g[6] !== 0 || g[7] > 1);
108
+ if (isMapped || isCompat) {
109
+ const v4 = `${(g[6] >> 8) & 0xff}.${g[6] & 0xff}.${(g[7] >> 8) & 0xff}.${g[7] & 0xff}`;
110
+ return isBlockedIPv4(v4);
111
+ }
112
+ return false;
113
+ }
114
+ function isBlockedIP(ip) {
115
+ const kind = isIP(ip);
116
+ if (kind === 4)
117
+ return isBlockedIPv4(ip);
118
+ if (kind === 6)
119
+ return isBlockedIPv6(ip);
120
+ return false;
121
+ }
122
+ /**
123
+ * Returns true if the URL is safe to fetch (public host, valid scheme).
124
+ * Returns false for invalid URLs or hosts in any blocked range.
125
+ */
126
+ export async function isSafeWebhookUrl(rawUrl) {
127
+ let url;
128
+ try {
129
+ url = new URL(rawUrl);
130
+ }
131
+ catch {
132
+ return false;
133
+ }
134
+ if (!["https:", "http:"].includes(url.protocol))
135
+ return false;
136
+ let host = url.hostname;
137
+ if (host.startsWith("[") && host.endsWith("]"))
138
+ host = host.slice(1, -1);
139
+ if (isIP(host) !== 0)
140
+ return !isBlockedIP(host);
141
+ try {
142
+ const records = await dns.lookup(host, { all: true });
143
+ if (records.length === 0)
144
+ return false;
145
+ return records.every((r) => !isBlockedIP(r.address));
146
+ }
147
+ catch {
148
+ return false;
149
+ }
150
+ }
@@ -1,3 +1,4 @@
1
+ import { isSafeWebhookUrl } from "./ssrf-guard.js";
1
2
  function formatTeamsPayload(issues, clusterContext) {
2
3
  const critical = issues.filter((i) => i.severity === "critical");
3
4
  const warning = issues.filter((i) => i.severity === "warning");
@@ -25,15 +26,9 @@ function formatTeamsPayload(issues, clusterContext) {
25
26
  };
26
27
  }
27
28
  async function postToTeams(webhookUrl, payload) {
28
- try {
29
- const url = new URL(webhookUrl);
30
- if (!["https:", "http:"].includes(url.protocol)) {
31
- console.error("Teams: invalid URL protocol");
32
- return;
33
- }
34
- }
35
- catch {
36
- console.error("Teams: invalid webhook URL");
29
+ // SSRF guard: reject invalid URLs and hosts in private/loopback/metadata ranges.
30
+ if (!(await isSafeWebhookUrl(webhookUrl))) {
31
+ console.error("Teams: invalid or unsafe webhook URL (must be a public host)");
37
32
  return;
38
33
  }
39
34
  try {
@@ -41,6 +36,8 @@ async function postToTeams(webhookUrl, payload) {
41
36
  method: "POST",
42
37
  headers: { "Content-Type": "application/json" },
43
38
  body: JSON.stringify(payload),
39
+ // Don't follow redirects to internal addresses (see ssrf-guard).
40
+ redirect: "error",
44
41
  signal: AbortSignal.timeout(10_000),
45
42
  });
46
43
  if (!res.ok)
@@ -1,13 +1,8 @@
1
+ import { isSafeWebhookUrl } from "./ssrf-guard.js";
1
2
  export async function sendWebhook(issues, channel, clusterContext) {
2
- try {
3
- const url = new URL(channel.url);
4
- if (!["https:", "http:"].includes(url.protocol)) {
5
- console.error("Webhook: invalid URL protocol");
6
- return;
7
- }
8
- }
9
- catch {
10
- console.error("Webhook: invalid URL");
3
+ // SSRF guard: reject invalid URLs and hosts in private/loopback/metadata ranges.
4
+ if (!(await isSafeWebhookUrl(channel.url))) {
5
+ console.error("Webhook: invalid or unsafe URL (must be a public https/http host)");
11
6
  return;
12
7
  }
13
8
  const payload = {
@@ -31,6 +26,9 @@ export async function sendWebhook(issues, channel, clusterContext) {
31
26
  method: "POST",
32
27
  headers,
33
28
  body: JSON.stringify(payload),
29
+ // Don't follow redirects: a public host could 30x to an internal address
30
+ // (metadata/localhost/RFC-1918) that the guard above never saw.
31
+ redirect: "error",
34
32
  signal: AbortSignal.timeout(10_000),
35
33
  });
36
34
  if (!res.ok)
@@ -1,12 +1,20 @@
1
1
  import { describe, it, expect, vi, beforeEach } from "vitest";
2
2
  const mockFetch = vi.fn();
3
3
  vi.stubGlobal("fetch", mockFetch);
4
+ // Mock DNS so the SSRF guard resolves example.com to a public address without
5
+ // real network access, keeping tests hermetic.
6
+ const mockLookup = vi.fn();
7
+ vi.mock("node:dns", () => ({
8
+ promises: { lookup: (...args) => mockLookup(...args) },
9
+ }));
4
10
  const channel = { type: "webhook", url: "https://example.com/hook", secret: "testsecret", severity: "warning" };
5
11
  const issue = { kind: "pod_crashloop", severity: "critical", namespace: "prod", resource: "api-web", message: "Pod crash-looping", details: { restartCount: 3 }, timestamp: new Date("2026-01-01T00:00:00Z") };
6
12
  describe("sendWebhook", () => {
7
13
  beforeEach(() => {
8
14
  mockFetch.mockReset();
9
15
  mockFetch.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
16
+ mockLookup.mockReset();
17
+ mockLookup.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
10
18
  });
11
19
  it("POSTs to the configured URL", async () => {
12
20
  const { sendWebhook } = await import("./webhook.js");
@@ -50,4 +58,17 @@ describe("sendWebhook", () => {
50
58
  await expect(sendWebhook([issue], badChannel)).resolves.not.toThrow();
51
59
  expect(mockFetch).not.toHaveBeenCalled();
52
60
  });
61
+ it("blocks SSRF: does not fetch a literal private/metadata IP", async () => {
62
+ const metaChannel = { ...channel, url: "http://169.254.169.254/latest/meta-data/" };
63
+ const { sendWebhook } = await import("./webhook.js");
64
+ await expect(sendWebhook([issue], metaChannel)).resolves.not.toThrow();
65
+ expect(mockFetch).not.toHaveBeenCalled();
66
+ });
67
+ it("blocks SSRF: does not fetch a hostname resolving to loopback", async () => {
68
+ mockLookup.mockResolvedValue([{ address: "127.0.0.1", family: 4 }]);
69
+ const rebindChannel = { ...channel, url: "https://internal.evil.example/hook" };
70
+ const { sendWebhook } = await import("./webhook.js");
71
+ await expect(sendWebhook([issue], rebindChannel)).resolves.not.toThrow();
72
+ expect(mockFetch).not.toHaveBeenCalled();
73
+ });
53
74
  });
@@ -4,7 +4,8 @@ import { scanCluster, formatClusterMarkdown } from "./cluster-scan.js";
4
4
  import { detectTechStack, formatProjectMarkdown } from "./code-scan.js";
5
5
  import { scanProjectDirectory, matchProjectsToWorkloads, bestMatches } from "./project-matcher.js";
6
6
  import { runInterview } from "./interview.js";
7
- import { writeClusterKb, writeProjectKb, ensureKbDir } from "../kb/writer.js";
7
+ import { writeClusterKb, writeProjectKb, writeApplicationsKb, ensureKbDir } from "../kb/writer.js";
8
+ import { detectApplications, formatApplicationsMarkdown } from "../detector/index.js";
8
9
  import { saveConfig, loadConfig, configDir, ALL_ACTIONS, DEFAULT_SAFE_ACTIONS } from "../config.js";
9
10
  import { interactiveAddChannel } from "../notify/setup.js";
10
11
  import { pickContext } from "../kubectl-config.js";
@@ -25,6 +26,25 @@ function pingOnboardComplete() {
25
26
  function expandPath(p) {
26
27
  return p.startsWith("~/") ? homedir() + p.slice(1) : p;
27
28
  }
29
+ async function runAppDetection(kubectlOpts, kbDir) {
30
+ const spinner = ora("Detecting OSS applications...").start();
31
+ try {
32
+ const result = await detectApplications({
33
+ context: kubectlOpts.context,
34
+ onProgress: (step) => {
35
+ spinner.text = `Detecting OSS applications — ${step}...`;
36
+ },
37
+ });
38
+ writeApplicationsKb(kbDir, formatApplicationsMarkdown(result));
39
+ const eolCount = result.apps.filter((a) => a.eol?.eol).length;
40
+ const cveCount = result.apps.filter((a) => a.cves.length > 0).length;
41
+ spinner.succeed(`Inventoried ${result.apps.length} OSS app${result.apps.length === 1 ? "" : "s"} ` +
42
+ `(${eolCount} EOL, ${cveCount} with CVEs)`);
43
+ }
44
+ catch (err) {
45
+ spinner.warn(`OSS detection skipped: ${err.message}`);
46
+ }
47
+ }
28
48
  async function ask(question) {
29
49
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
30
50
  return new Promise((resolve) => {
@@ -130,6 +150,7 @@ export async function onboard(opts = {}) {
130
150
  writeProjectKb(kbDir, "_notes", `# Onboarding Notes\n\n${Array.from(notes.entries()).map(([q, a]) => `**${q}**\n${a}`).join("\n\n")}\n`);
131
151
  }
132
152
  earlyKbSpinner.succeed(`Knowledge base written (${totalItems} file${totalItems !== 1 ? "s" : ""})`);
153
+ await runAppDetection(kubectlOpts, kbDir);
133
154
  const currentSafeActions = existingConfig.remediation?.safe_actions ?? DEFAULT_SAFE_ACTIONS;
134
155
  const safeActions = await configureSafeActions(currentSafeActions);
135
156
  const existingChannels = existingConfig.notifications?.channels ?? [];
@@ -312,6 +333,7 @@ export async function onboard(opts = {}) {
312
333
  writeProjectKb(kbDir, "_notes", `# Onboarding Notes\n\n${generalNotes}\n`);
313
334
  }
314
335
  kbSpinner.succeed(`Knowledge base written (${totalKbItems} file${totalKbItems !== 1 ? "s" : ""})`);
336
+ await runAppDetection(kubectlOpts, kbDir);
315
337
  // Step 6b: Configure safe actions
316
338
  const currentSafeActions = existingConfig.remediation?.safe_actions ?? DEFAULT_SAFE_ACTIONS;
317
339
  const safeActions = await configureSafeActions(currentSafeActions);
@@ -140,7 +140,8 @@ export async function handleIssues(issues, config, clusterContext, noInteractive
140
140
  autoFix: config.remediation.auto_fix,
141
141
  safeActions: config.remediation.safe_actions,
142
142
  noInteractive,
143
- onApproval: noInteractive ? undefined : askApproval,
143
+ // Non-interactive mode must auto-deny every approval-gated (non-safe) action.
144
+ onApproval: noInteractive ? (async () => false) : askApproval,
144
145
  onQuestion: (question, choices) => broadcastQuestion(question, choices, config, clusterContext),
145
146
  });
146
147
  // ── Analysis ──────────────────────────────────────────────
package/dist/telemetry.js CHANGED
@@ -1,9 +1,34 @@
1
- import { existsSync, writeFileSync, mkdirSync } from "node:fs";
1
+ import { existsSync, writeFileSync, mkdirSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { homedir, platform, arch } from "node:os";
4
+ import { randomUUID } from "node:crypto";
4
5
  const TELEMETRY_DIR = join(homedir(), ".kubeagent");
5
6
  const SENT_FILE = join(TELEMETRY_DIR, ".telemetry-sent");
7
+ const MACHINE_ID_FILE = join(TELEMETRY_DIR, ".machine-id");
6
8
  const SERVER = "https://api.kubeagent.net";
9
+ /**
10
+ * Stable, anonymous per-machine ID. Used as the PostHog distinct_id for CLI
11
+ * events so install→auth→first-run funnels stitch together across invocations.
12
+ * Not tied to any user identity until the CLI logs in (server-side alias).
13
+ */
14
+ function getOrCreateMachineId() {
15
+ try {
16
+ if (!existsSync(TELEMETRY_DIR))
17
+ mkdirSync(TELEMETRY_DIR, { recursive: true });
18
+ if (existsSync(MACHINE_ID_FILE)) {
19
+ const id = readFileSync(MACHINE_ID_FILE, "utf8").trim();
20
+ if (id)
21
+ return id;
22
+ }
23
+ const id = randomUUID();
24
+ writeFileSync(MACHINE_ID_FILE, id);
25
+ return id;
26
+ }
27
+ catch {
28
+ // If we cannot persist, return an ephemeral id — better than nothing.
29
+ return randomUUID();
30
+ }
31
+ }
7
32
  export function sendTelemetry(cliVersion) {
8
33
  // Only fire once per machine
9
34
  if (existsSync(SENT_FILE))
@@ -24,6 +49,7 @@ export function sendTelemetry(cliVersion) {
24
49
  os: platform(),
25
50
  arch: arch(),
26
51
  nodeVersion: process.version,
52
+ machineId: getOrCreateMachineId(),
27
53
  });
28
54
  fetch(`${SERVER}/telemetry`, {
29
55
  method: "POST",
package/package.json CHANGED
@@ -1,8 +1,16 @@
1
1
  {
2
2
  "name": "kubeagent",
3
- "version": "0.1.35",
3
+ "version": "0.1.43",
4
4
  "description": "AI-powered Kubernetes management CLI",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/kubeagent-net/KubeAgent.git"
9
+ },
10
+ "homepage": "https://kubeagent.net",
11
+ "bugs": {
12
+ "url": "https://github.com/kubeagent-net/KubeAgent/issues"
13
+ },
6
14
  "type": "module",
7
15
  "bin": {
8
16
  "kubeagent": "./dist/cli.js"
@@ -24,6 +32,7 @@
24
32
  "engines": {
25
33
  "node": ">=22"
26
34
  },
35
+ "packageManager": "npm@11.16.0",
27
36
  "dependencies": {
28
37
  "@anthropic-ai/sdk": "^0.81.0",
29
38
  "chalk": "^5.4.0",