@astrofoundry/pi-astro 0.26.1 → 0.26.3
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/package.json +1 -1
- package/skills/security/SKILL.md +1 -1
- package/specialists/AGENTS.md +2 -1
- package/specialists/edge/run.ts +25 -14
- package/specialists/lib/lock.test.ts +42 -0
- package/specialists/lib/lock.ts +49 -0
- package/specialists/security/run.ts +2 -2
- package/specialists/wrappers.test.ts +22 -5
package/package.json
CHANGED
package/skills/security/SKILL.md
CHANGED
|
@@ -19,7 +19,7 @@ The `security` tool is read-only. Every call is a fixed command on the guest; `[
|
|
|
19
19
|
| `bouncers` | bouncers JSON (`name`, `last_pull`, `revoked`) |
|
|
20
20
|
| `metrics` | `cscli metrics -o json` |
|
|
21
21
|
| `wazuh-alerts <days> <minLevel>` | summary: count, groups by rule and agent, latest 20 (log line truncated to 200 chars) |
|
|
22
|
-
| `wazuh-vulns <days> [agent]` | vulnerability alerts only: `open` (latest event per agent, package and CVE that is still Active), `openWithoutFixVersion`, `active` and `solved` counts, and `items` (every event, newest first) with `cve`, `severity`, `score`, `status`, `package`, installed `version`, `condition`, `references` |
|
|
22
|
+
| `wazuh-vulns <days> [agent]` | vulnerability alerts only: `open` (latest event per agent, package, installed version and CVE that is still Active), `openWithoutFixVersion`, `active` and `solved` counts, and `items` (every event, newest first) with `cve`, `severity`, `score`, `status`, `package`, installed `version`, `condition`, `references` |
|
|
23
23
|
| `wazuh-log <lines>` | manager container log |
|
|
24
24
|
| `remote-hosts` | `host/program.log` names in the archive |
|
|
25
25
|
| `remote-log <host> <program> <lines>` | tail of one archived log, e.g. `remote-log frontdoor-1337 nginx-stream 200` |
|
package/specialists/AGENTS.md
CHANGED
|
@@ -41,7 +41,8 @@ Wrappers ship as TypeScript and run under Node 24 type stripping: erasable synta
|
|
|
41
41
|
- Fixed remote commands: `DMZ_COMMANDS`, `PULSAR_COMMANDS`, `VPS_COMMANDS`, `OBS_COMMANDS`, `BACKUP_COMMANDS`, `HOST_COMMANDS`, and `INFERENCE_COMMANDS` must match the entry scripts in `entry/remote/` and their tracked copies in the homelab repository (`02-pulsar-proxmox/dmz/system/`, `02-pulsar-proxmox/pulsar/system/`, `00-frontdoor-vps/system/`, `02-pulsar-proxmox/observability/system/`, `02-pulsar-proxmox/hermes/system/`, `04-nexus-macstudio/system/`, and `host/` of the `astronaute77/arcane` repository). Pulsar's root `authorized_keys` carries one forced key per specialist (network, backup, proxmox, inference), each pinned to its own entry script. Change both sides in the same commit and redeploy the remote script. A new privileged step on the VPS also needs its exact line in `spc-edge-frontdoor-sudoers`.
|
|
42
42
|
- Repository access is `lib/repo.ts` (`repoCommand`, `RepoSpec`, `trackedFile`): one clone per specialist under `~/.specialists/work/<checkoutName>` (`homelab-edge`, `homelab-identity`; never share a checkout between specialists), a write deploy key, and an optional `writeRoot` that limits `git write`. `edge` (`deploy-nginx`) and `identity` (`dmz deploy-config`) deploy only what origin has: `trackedFile` refuses a dirty, ahead, or behind checkout. The DMZ entry keeps the previous template and restores it when render or restart fails.
|
|
43
43
|
- `network` firewall policy writes are `policyWrite`: JSON body from the agent, checked for the documented required fields, sent as `POST`, `PUT`, or `DELETE` through the same pinned request; UniFi validates the rest.
|
|
44
|
-
-
|
|
44
|
+
- Pi executes sibling tool calls in parallel, so two wrapper processes of the same specialist can run at once. `lib/lock.ts` (`withLock`, a directory lock under `~/.specialists/work/locks`, stale after 10 minutes) serialises shared resources; `edge` holds `edge-tunnel` around the fixed IAP port and the gcloud configuration directory. `repo.ts` checkouts are not locked yet.
|
|
45
|
+
- `edge` retries one SSH call through the IAP tunnel when the first connection is closed during setup (`isTransientSshFailure`: exit 255 with "closed by remote host" or a reset), except `reboot`. The IAP tunnel is `lib/tunnel.ts`: `gcloud compute start-iap-tunnel <instance> 22 --local-host-port=localhost:<port>` (a documented flag; the hidden `--listen-on-stdin` is not used), ready when the port accepts a connection, closed after the ssh call. `gcloud auth activate-service-account` runs before every tunnel with `CLOUDSDK_CONFIG` under `~/.specialists/work/gcloud` and `CLOUDSDK_PYTHON` from the config, because the Homebrew cask ships no interpreter.
|
|
45
46
|
- `dns` builds every Technitium call from `TECHNITIUM_READS` and `TECHNITIUM_WRITES`; `key=value` record parameters pass through by name (`token` and `node` refused) because the API documents dozens of type-specific parameters. Writes are refused on the secondary in code: the catalog zone is the only replication path.
|
|
46
47
|
- `security` parses Wazuh alert lines locally (`summariseWazuh`, `summariseVulns`) so the guest never needs `jq`; `wazuh-vulns` is a local view over the same `wazuh-alerts` dump, not a guest command.
|
|
47
48
|
- TLS pinning for self-signed consoles is `lib/pinned.ts` (`pinnedRequest`); `network` and `proxmox` use it with a `*CertSha256` config value obtained once through the wrapper's `fingerprint` command.
|
package/specialists/edge/run.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { readConfig } from "../lib/config.ts";
|
|
3
3
|
import { ServiceError, UsageError } from "../lib/errors.ts";
|
|
4
|
+
import { withLock } from "../lib/lock.ts";
|
|
4
5
|
import { main } from "../lib/main.ts";
|
|
5
6
|
import { printJson, printRaw } from "../lib/output.ts";
|
|
6
7
|
import { specialistsHome, workDir } from "../lib/paths.ts";
|
|
@@ -85,6 +86,11 @@ ${REPO_HELP}
|
|
|
85
86
|
|
|
86
87
|
deploy-nginx refuses when the checkout is dirty or differs from origin: commit and push first.`;
|
|
87
88
|
|
|
89
|
+
/** The first connection through a fresh IAP tunnel is sometimes closed before sshd answers; that failure is safe to retry once. */
|
|
90
|
+
export function isTransientSshFailure(result: { code: number; stderr: string }): boolean {
|
|
91
|
+
return result.code === 255 && /closed by remote host|connection reset|connection refused|kex_exchange_identification/i.test(result.stderr);
|
|
92
|
+
}
|
|
93
|
+
|
|
88
94
|
export function buildVpsRemote(args: string[]): { remote: string; stdin: boolean } {
|
|
89
95
|
const [name, ...rest] = args;
|
|
90
96
|
const spec = name === undefined ? undefined : VPS_COMMANDS[name];
|
|
@@ -120,7 +126,12 @@ async function activateServiceAccount(config: EdgeConfig): Promise<void> {
|
|
|
120
126
|
}
|
|
121
127
|
|
|
122
128
|
/** Opens the IAP tunnel, runs one fixed command over it, closes the tunnel. */
|
|
123
|
-
|
|
129
|
+
/** One tunnel at a time: the local port and the gcloud configuration directory are shared by every edge process. */
|
|
130
|
+
function overTunnel(config: EdgeConfig, remote: string, timeoutMs: number, input?: string): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
131
|
+
return withLock("edge-tunnel", () => tunnelCall(config, remote, timeoutMs, input), { timeoutMs: timeoutMs + 60_000 });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function tunnelCall(config: EdgeConfig, remote: string, timeoutMs: number, input?: string): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
124
135
|
await activateServiceAccount(config);
|
|
125
136
|
const tunnel = await openTunnel(
|
|
126
137
|
config.gcloud,
|
|
@@ -138,20 +149,20 @@ async function overTunnel(config: EdgeConfig, remote: string, timeoutMs: number,
|
|
|
138
149
|
],
|
|
139
150
|
{ env: gcloudEnv(config), port: config.iapLocalPort, readyTimeoutMs: 45_000 },
|
|
140
151
|
);
|
|
152
|
+
const target = {
|
|
153
|
+
host: "127.0.0.1",
|
|
154
|
+
port: config.iapLocalPort,
|
|
155
|
+
hostKeyAlias: config.instance,
|
|
156
|
+
user: config.sshUser,
|
|
157
|
+
keyFile: secretPath(SERVICE, "SSH_KEY_FRONTDOOR"),
|
|
158
|
+
knownHostsFile: join(specialistsHome(), "config", "known_hosts"),
|
|
159
|
+
};
|
|
141
160
|
try {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
user: config.sshUser,
|
|
148
|
-
keyFile: secretPath(SERVICE, "SSH_KEY_FRONTDOOR"),
|
|
149
|
-
knownHostsFile: join(specialistsHome(), "config", "known_hosts"),
|
|
150
|
-
},
|
|
151
|
-
remote,
|
|
152
|
-
timeoutMs,
|
|
153
|
-
input,
|
|
154
|
-
);
|
|
161
|
+
const first = await sshFixed(target, remote, timeoutMs, input);
|
|
162
|
+
// A connection closed during setup never ran the command; a reboot is the one command not to repeat.
|
|
163
|
+
if (!isTransientSshFailure(first) || remote === "reboot") return first;
|
|
164
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
165
|
+
return await sshFixed(target, remote, timeoutMs, input);
|
|
155
166
|
} finally {
|
|
156
167
|
await tunnel.close();
|
|
157
168
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, utimesSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { withLock } from "./lock.ts";
|
|
6
|
+
|
|
7
|
+
describe("withLock", () => {
|
|
8
|
+
let dir: string;
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
dir = mkdtempSync(join(tmpdir(), "astro-lock-"));
|
|
11
|
+
});
|
|
12
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
13
|
+
|
|
14
|
+
it("serialises concurrent holders and releases the lock afterwards", async () => {
|
|
15
|
+
const order: string[] = [];
|
|
16
|
+
const hold = (tag: string, ms: number) =>
|
|
17
|
+
withLock("edge", async () => {
|
|
18
|
+
order.push(`${tag} start`);
|
|
19
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
20
|
+
order.push(`${tag} end`);
|
|
21
|
+
}, { dir });
|
|
22
|
+
await Promise.all([hold("a", 120), hold("b", 10)]);
|
|
23
|
+
expect(order).toEqual(["a start", "a end", "b start", "b end"]);
|
|
24
|
+
expect(existsSync(join(dir, "edge.lock"))).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("removes a stale lock and reports a busy one", async () => {
|
|
28
|
+
const lock = join(dir, "edge.lock");
|
|
29
|
+
mkdirSync(lock);
|
|
30
|
+
const old = new Date(Date.now() - 20 * 60_000);
|
|
31
|
+
utimesSync(lock, old, old);
|
|
32
|
+
expect(await withLock("edge", async () => "ran", { dir })).toBe("ran");
|
|
33
|
+
mkdirSync(lock);
|
|
34
|
+
await expect(withLock("edge", async () => "never", { dir, timeoutMs: 300 })).rejects.toThrow(/busy/);
|
|
35
|
+
expect(existsSync(lock)).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("releases the lock when the work throws", async () => {
|
|
39
|
+
await expect(withLock("edge", async () => { throw new Error("boom"); }, { dir })).rejects.toThrow("boom");
|
|
40
|
+
expect(existsSync(join(dir, "edge.lock"))).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { mkdirSync, rmSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { ServiceError } from "./errors.ts";
|
|
4
|
+
import { workDir } from "./paths.ts";
|
|
5
|
+
|
|
6
|
+
/** A lock older than this belongs to a process that died without releasing it. */
|
|
7
|
+
const STALE_MS = 10 * 60_000;
|
|
8
|
+
|
|
9
|
+
function sleep(ms: number): Promise<void> {
|
|
10
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Runs `fn` while holding an exclusive directory lock, so concurrent wrapper
|
|
15
|
+
* processes (Pi runs sibling tool calls in parallel) take turns on a shared
|
|
16
|
+
* resource such as a fixed tunnel port or a gcloud configuration directory.
|
|
17
|
+
*/
|
|
18
|
+
export async function withLock<T>(name: string, fn: () => Promise<T>, options: { dir?: string; timeoutMs?: number; staleMs?: number } = {}): Promise<T> {
|
|
19
|
+
const root = options.dir ?? workDir("locks");
|
|
20
|
+
const lock = join(root, `${name}.lock`);
|
|
21
|
+
const deadline = Date.now() + (options.timeoutMs ?? 120_000);
|
|
22
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
23
|
+
for (;;) {
|
|
24
|
+
try {
|
|
25
|
+
mkdirSync(lock);
|
|
26
|
+
break;
|
|
27
|
+
} catch (err) {
|
|
28
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
|
29
|
+
}
|
|
30
|
+
let age: number;
|
|
31
|
+
try {
|
|
32
|
+
age = Date.now() - statSync(lock).mtimeMs;
|
|
33
|
+
} catch {
|
|
34
|
+
// The holder released it between our attempt and the check; try again at once.
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (age > (options.staleMs ?? STALE_MS)) {
|
|
38
|
+
rmSync(lock, { recursive: true, force: true });
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (Date.now() > deadline) throw new ServiceError(`${name} is busy: another call has held it for ${Math.round(age / 1000)} s`);
|
|
42
|
+
await sleep(200);
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
return await fn();
|
|
46
|
+
} finally {
|
|
47
|
+
rmSync(lock, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -265,7 +265,7 @@ export interface VulnerabilitySummary {
|
|
|
265
265
|
count: number;
|
|
266
266
|
active: number;
|
|
267
267
|
solved: number;
|
|
268
|
-
/** Latest event per agent, package and CVE whose status is still Active: the findings that are open now. */
|
|
268
|
+
/** Latest event per agent, package, installed version and CVE whose status is still Active: the findings that are open now. */
|
|
269
269
|
open: VulnerabilityItem[];
|
|
270
270
|
/** Open findings whose condition is "Package default status": the feed knows no fixed version, so no version clears them. */
|
|
271
271
|
openWithoutFixVersion: number;
|
|
@@ -381,7 +381,7 @@ export function summariseVulns(text: string, days: number, agent: string | null,
|
|
|
381
381
|
items.sort((x, y) => y.timestamp.localeCompare(x.timestamp));
|
|
382
382
|
const latest = new Map<string, VulnerabilityItem>();
|
|
383
383
|
for (const item of items) {
|
|
384
|
-
const key = `${item.agent}|${item.package}|${item.cve}`;
|
|
384
|
+
const key = `${item.agent}|${item.package}|${item.version}|${item.cve}`;
|
|
385
385
|
if (!latest.has(key)) latest.set(key, item);
|
|
386
386
|
}
|
|
387
387
|
const open = [...latest.values()].filter((i) => i.status.toLowerCase() === "active");
|
|
@@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
5
5
|
import { refusedPrefix, command as arcaneCommand } from "./arcane/run.ts";
|
|
6
6
|
import { buildBackupRemote, command as backupCommand } from "./backup/run.ts";
|
|
7
7
|
import { parseParams, parseRecordFilters, technitiumRequest, command as dnsCommand } from "./dns/run.ts";
|
|
8
|
-
import { buildVpsRemote, command as edgeCommand } from "./edge/run.ts";
|
|
8
|
+
import { buildVpsRemote, command as edgeCommand, isTransientSshFailure } from "./edge/run.ts";
|
|
9
9
|
import { API_PREFIXES, buildDmzRemote, validateApiPath, command as identityCommand } from "./identity/run.ts";
|
|
10
10
|
import { buildInferenceRemote, compactReleases, command as inferenceCommand } from "./inference/run.ts";
|
|
11
11
|
import { UsageError } from "./lib/errors.ts";
|
|
@@ -196,6 +196,9 @@ describe("edge wrapper", () => {
|
|
|
196
196
|
expect(buildVpsRemote(["auto-upgrades"])).toEqual({ remote: "auto-upgrades", stdin: false });
|
|
197
197
|
expect(buildVpsRemote(["changelog", "unbound"])).toEqual({ remote: "changelog unbound", stdin: false });
|
|
198
198
|
expect(() => buildVpsRemote(["changelog", "../x"])).toThrow(/changelog/);
|
|
199
|
+
expect(isTransientSshFailure({ code: 255, stderr: "Connection to 127.0.0.1 closed by remote host.\r\n" })).toBe(true);
|
|
200
|
+
expect(isTransientSshFailure({ code: 255, stderr: "Permission denied (publickey)." })).toBe(false);
|
|
201
|
+
expect(isTransientSshFailure({ code: 2, stderr: "refused: shell" })).toBe(false);
|
|
199
202
|
expect(() => buildVpsRemote(["package", "Lib;rm"])).toThrow(/package/);
|
|
200
203
|
expect(() => buildVpsRemote(["package"])).toThrow(/1 argument/);
|
|
201
204
|
expect(() => buildVpsRemote(["shell"])).toThrow(/unknown vps command/);
|
|
@@ -277,10 +280,24 @@ describe("security wrapper", () => {
|
|
|
277
280
|
agent: { name: "frontdoor-1337" },
|
|
278
281
|
data: { vulnerability: { cve: "CVE-2026-55990", severity: "Medium", status: "Active", package: { name: "libunbound8", version: "1.26.1-0+deb13u1", condition: "Package default status" } } },
|
|
279
282
|
});
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
283
|
+
// After an upgrade Wazuh closes the CVE on the old version and may reopen it on the new one; the version keeps them apart.
|
|
284
|
+
const activeNew = JSON.stringify({
|
|
285
|
+
timestamp: "2026-09-20T07:07:47.889+0000",
|
|
286
|
+
rule: { id: "23506", level: 13, description: "CVE-2026-50252 affects libunbound8" },
|
|
287
|
+
agent: { name: "frontdoor-1337" },
|
|
288
|
+
data: { vulnerability: { cve: "CVE-2026-50252", severity: "Critical", status: "Active", package: { name: "libunbound8", version: "1.26.1-0+deb13u1", condition: "Package default status" }, score: { base: 9.3 } } },
|
|
289
|
+
});
|
|
290
|
+
const solvedOld = JSON.stringify({
|
|
291
|
+
timestamp: "2026-09-20T07:07:48.433+0000",
|
|
292
|
+
rule: { id: "23502", level: 3, description: "CVE-2026-50252 affecting libunbound8 was solved" },
|
|
293
|
+
agent: { name: "frontdoor-1337" },
|
|
294
|
+
data: { vulnerability: { cve: "CVE-2026-50252", status: "Solved", package: { name: "libunbound8", version: "1.22.0-2+deb13u3" } } },
|
|
295
|
+
});
|
|
296
|
+
const paired = summariseVulns([modern, solvedLater, unfixed, activeNew, solvedOld].join("\n"), 3, null, now);
|
|
297
|
+
expect(paired.open.map((i) => `${i.cve}@${i.version}`)).toEqual(["CVE-2026-50252@1.26.1-0+deb13u1", "CVE-2026-55990@1.26.1-0+deb13u1"]);
|
|
298
|
+
expect(paired.openWithoutFixVersion).toBe(2);
|
|
299
|
+
expect(paired.active).toBe(3);
|
|
300
|
+
expect(paired.solved).toBe(2);
|
|
284
301
|
expect(summariseVulns(modern, 1, null, now + 3 * 86_400_000).count).toBe(0);
|
|
285
302
|
});
|
|
286
303
|
|