talon-agent 5.20.1 → 5.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -493,6 +493,7 @@ Config file: `~/.talon/config.json`
493
493
  | `dreamModel` | --- | Model for dream / memory consolidation (falls back to `model`) |
494
494
  | `dreamEffort` | --- | Reasoning effort for the dream agent — same levels as `heartbeatEffort` |
495
495
  | `braveApiKey` | --- | Brave Search API key |
496
+ | `fetchUrl` | --- | `fetch_url` reaches every address by default, LAN and loopback included. `{ "allowPrivateNetworks": false }` opts into the SSRF guard (refuses private, loopback, link-local and metadata addresses on every redirect hop) — worth it on a cloud VM |
496
497
  | `timezone` | --- | IANA timezone (e.g. `"Europe/London"`) |
497
498
  | `plugins` | `[]` | External plugin packages |
498
499
  | `disabledToolTags` | --- | Hide whole tool groups from the model (e.g. `["stickers", "web"]`) — each registered tool costs context tokens per session |
@@ -505,7 +506,7 @@ Config file: `~/.talon/config.json`
505
506
  | `apiId` / `apiHash` | --- | Telegram API credentials for full message history |
506
507
  | `whatsapp` | --- | WhatsApp frontend: pairing, allowlists, group policy ([above](#whatsapp)) |
507
508
  | `discord` | --- | Discord frontend: bot token, application ID, guild / channel allowlists |
508
- | `native` | --- | Client bridge: host, port, token, TLS ([above](#desktop--mobile-app)) |
509
+ | `native` | --- | Client bridge: host, port, token, TLS ([above](#desktop--mobile-app)). `companionScopes` (default all three) narrows what a paired companion's credential may do ([docs/mesh-credentials.md](docs/mesh-credentials.md)) |
509
510
  | `nativeTools` | `false` | Swap the SDK's built-in Read/Write/Edit/Bash/Glob/Grep for Talon's own — these also route to a teleported device |
510
511
  | `fuse` | `"auto"` | Mount the `talon://` namespace with FUSE live views; falls back to a symlink farm where the host can't |
511
512
  | `github` | --- | GitHub plugin config (see above) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "5.20.1",
3
+ "version": "5.22.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "The Falconry",
6
6
  "license": "Apache-2.0",
@@ -101,7 +101,7 @@
101
101
  "build:fusefs": "node native/talon-fusefs/build.mjs"
102
102
  },
103
103
  "dependencies": {
104
- "@anthropic-ai/claude-agent-sdk": "^0.3.283",
104
+ "@anthropic-ai/claude-agent-sdk": "^0.3.284",
105
105
  "@anthropic-ai/sdk": "^0.104.1",
106
106
  "@brave/brave-search-mcp-server": "^2.0.75",
107
107
  "@clack/prompts": "^1.2.0",
@@ -0,0 +1,89 @@
1
+ /**
2
+ * `talon mesh audit [--limit N] [--device X]` — the daemon's record of
3
+ * every command it sent to a mesh device (core/mesh/audit.ts), read
4
+ * through the loopback gateway. Arguments were never recorded, only their
5
+ * hash; the short form here is its first 12 hex characters.
6
+ */
7
+
8
+ import pc from "picocolors";
9
+ import { fetchGateway } from "../daemon-api.js";
10
+ import type { MeshAuditEntry } from "../../core/mesh/audit.js";
11
+
12
+ type AuditQuery = { limit?: number; device?: string };
13
+
14
+ /** Parse `--limit N` / `--device X` (either `--flag value` or `--flag=value`). */
15
+ export function parseAuditArgs(args: readonly string[]): AuditQuery | string {
16
+ const query: AuditQuery = {};
17
+ for (let i = 0; i < args.length; i++) {
18
+ const arg = args[i]!;
19
+ const eq = arg.indexOf("=");
20
+ const flag = eq === -1 ? arg : arg.slice(0, eq);
21
+ if (flag !== "--limit" && flag !== "--device") {
22
+ return `Unknown option: ${arg}`;
23
+ }
24
+ const value = eq === -1 ? args[++i] : arg.slice(eq + 1);
25
+ if (value === undefined || value === "") return `${flag} needs a value`;
26
+ if (flag === "--device") {
27
+ query.device = value;
28
+ continue;
29
+ }
30
+ const limit = Number(value);
31
+ if (!Number.isInteger(limit) || limit < 1) {
32
+ return `--limit must be a positive integer, got "${value}"`;
33
+ }
34
+ query.limit = limit;
35
+ }
36
+ return query;
37
+ }
38
+
39
+ function issuerText(entry: MeshAuditEntry): string {
40
+ const i = entry.issuer;
41
+ if (!i) return pc.dim("no turn");
42
+ return [
43
+ i.source && i.source !== "message" ? i.source : null,
44
+ i.sender ? `by ${i.sender}` : null,
45
+ `chat ${i.chatId}`,
46
+ i.turnId,
47
+ ]
48
+ .filter(Boolean)
49
+ .join(" · ");
50
+ }
51
+
52
+ function renderEntry(entry: MeshAuditEntry): void {
53
+ const mark = entry.ok ? pc.green("✔") : pc.red("✖");
54
+ console.log(
55
+ ` ${pc.dim(entry.time)} ${mark} ${pc.cyan(entry.command)} → ${entry.deviceName} ${pc.dim(`(${entry.deviceId})`)} ${entry.durationMs}ms`,
56
+ );
57
+ console.log(
58
+ pc.dim(
59
+ ` ${issuerText(entry)} · args ${entry.argsHash.slice(0, 12)}` +
60
+ (entry.error ? ` · ${entry.error}` : ""),
61
+ ),
62
+ );
63
+ }
64
+
65
+ export async function runMeshAudit(
66
+ port: number,
67
+ query: AuditQuery,
68
+ ): Promise<void> {
69
+ const params = new URLSearchParams();
70
+ if (query.limit !== undefined) params.set("limit", String(query.limit));
71
+ if (query.device) params.set("device", query.device);
72
+ const qs = params.size > 0 ? `?${params.toString()}` : "";
73
+ const reply = (await fetchGateway(port, `/mesh/audit${qs}`)) as {
74
+ ok: boolean;
75
+ entries?: MeshAuditEntry[];
76
+ error?: string;
77
+ };
78
+ if (!reply.ok) {
79
+ console.error(`\n ${pc.red("✖")} ${reply.error}\n`);
80
+ process.exitCode = 1;
81
+ return;
82
+ }
83
+ const entries = reply.entries ?? [];
84
+ console.log(`\n ${pc.bold("Mesh command audit")}\n`);
85
+ if (entries.length === 0)
86
+ console.log(` ${pc.dim("(no commands recorded)")}`);
87
+ for (const entry of entries) renderEntry(entry);
88
+ console.log("");
89
+ }
@@ -7,6 +7,9 @@
7
7
  * talon mesh rotate <device> the device swaps credentials on its
8
8
  * next heartbeat (old one: 7-day grace)
9
9
  * talon mesh scopes <device> <list> e.g. device,client,operator
10
+ * talon mesh audit [--limit N] [--device X]
11
+ * commands the daemon sent to devices
12
+ * (core/mesh/audit.ts)
10
13
  *
11
14
  * Everything goes through the running daemon's loopback gateway
12
15
  * (core/engine/gateway-routes.ts → core/mesh/credentials/admin.ts): the
@@ -17,14 +20,16 @@
17
20
  import pc from "picocolors";
18
21
  import { fetchGateway, requireGatewayPort } from "../daemon-api.js";
19
22
  import type { DeviceCredential } from "../../core/mesh/credentials/index.js";
23
+ import { parseAuditArgs, runMeshAudit } from "./mesh-audit.js";
20
24
 
21
25
  const USAGE = `
22
- ${pc.bold("talon mesh")} — per-device mesh credentials
26
+ ${pc.bold("talon mesh")} — per-device mesh credentials and command audit
23
27
 
24
28
  ${pc.cyan("list")} credentials, scopes, last use (default)
25
29
  ${pc.cyan("revoke")} <device|credential-id> revoke now; drops its live sessions
26
30
  ${pc.cyan("rotate")} <device> re-issue on the device's next heartbeat
27
31
  ${pc.cyan("scopes")} <device> <scope,scope> set scopes: device, client, operator
32
+ ${pc.cyan("audit")} [--limit N] [--device X] commands sent to devices (newest last)
28
33
  `;
29
34
 
30
35
  type Overview = {
@@ -101,12 +106,28 @@ async function adminOp(
101
106
  }
102
107
  }
103
108
 
109
+ async function runAudit(args: readonly string[]): Promise<void> {
110
+ const query = parseAuditArgs(args);
111
+ if (typeof query === "string") {
112
+ console.error(` ${pc.red("✖")} ${query}\n${USAGE}`);
113
+ process.exitCode = 1;
114
+ return;
115
+ }
116
+ const port = await requireGatewayPort();
117
+ if (port === null) {
118
+ process.exitCode = 1;
119
+ return;
120
+ }
121
+ await runMeshAudit(port, query);
122
+ }
123
+
104
124
  export async function runMeshCommand(args: string[]): Promise<void> {
105
125
  const [sub = "list", target, scopes] = args;
106
126
  if (sub === "help" || sub === "--help" || sub === "-h") {
107
127
  console.log(USAGE);
108
128
  return;
109
129
  }
130
+ if (sub === "audit") return runAudit(args.slice(1));
110
131
  const known = ["list", "revoke", "rotate", "scopes"];
111
132
  if (!known.includes(sub)) {
112
133
  console.error(` Unknown mesh command: ${sub}\n${USAGE}`);
package/src/cli/index.ts CHANGED
@@ -113,7 +113,7 @@ function printHelp(): void {
113
113
  ` ${pc.cyan("backup")} Snapshots and checkpoints (now/list/show/pin/restore)`,
114
114
  );
115
115
  console.log(
116
- ` ${pc.cyan("mesh")} Device credentials (list/revoke/rotate/scopes)`,
116
+ ` ${pc.cyan("mesh")} Device credentials + command audit (list/revoke/rotate/scopes/audit)`,
117
117
  );
118
118
  console.log(` ${pc.cyan("config")} View/edit configuration`);
119
119
  console.log(
package/src/cli/status.ts CHANGED
@@ -4,8 +4,9 @@
4
4
  */
5
5
 
6
6
  import pc from "picocolors";
7
- import { existsSync } from "node:fs";
7
+ import { existsSync, readFileSync } from "node:fs";
8
8
  import { findRunningInstance } from "../core/daemon/discovery.js";
9
+ import { files } from "../util/paths.js";
9
10
  import { printBanner, loadConfig } from "./config.js";
10
11
  import { CONFIG_FILE } from "./context.js";
11
12
 
@@ -38,6 +39,26 @@ export function formatAlertLines(health: Record<string, unknown>): string[] {
38
39
  });
39
40
  }
40
41
 
42
+ /**
43
+ * The native bridge's TLS certificate fingerprint, from its discovery file —
44
+ * what a talon-node pins on first use, shown so an operator can compare the
45
+ * two. Null when the bridge is off, serves plain http, or never ran.
46
+ */
47
+ export function bridgeFingerprint(
48
+ path: string = files.nativeBridge,
49
+ ): string | null {
50
+ try {
51
+ const raw = JSON.parse(readFileSync(path, "utf-8")) as {
52
+ fingerprint?: unknown;
53
+ };
54
+ return typeof raw.fingerprint === "string" && raw.fingerprint
55
+ ? raw.fingerprint
56
+ : null;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
41
62
  export async function showStatus(): Promise<void> {
42
63
  printBanner();
43
64
  const instance = await findRunningInstance();
@@ -60,6 +81,8 @@ export async function showStatus(): Promise<void> {
60
81
  console.log(` ${pc.dim("Messages")} ${h.messages}`);
61
82
  console.log(` ${pc.dim("Queue")} ${h.queue} pending`);
62
83
  console.log(` ${pc.dim("Errors")} ${h.errors}`);
84
+ const fingerprint = bridgeFingerprint();
85
+ if (fingerprint) console.log(` ${pc.dim("Bridge TLS")} ${fingerprint}`);
63
86
  console.log(` ${pc.dim("Last active")} ${h.lastActivity}\n`);
64
87
  const alerts = formatAlertLines(h);
65
88
  if (alerts.length > 0) {
@@ -234,11 +234,11 @@ const nativeConfigSchema = z
234
234
  legacySharedToken: z.boolean().optional(),
235
235
  /**
236
236
  * Scopes a companion's per-device credential carries — at pairing and
237
- * on the in-band upgrade. Default ["device", "client"]: the mesh plus
238
- * the chat UI. Adding "operator" lets every paired phone change config,
239
- * toggle plugins and read logs; prefer granting it to one device with
240
- * `talon mesh scopes <device> device,client,operator`. Nodes always get
241
- * ["device"].
237
+ * on the in-band upgrade. Default ["device", "client", "operator"]:
238
+ * the mesh, the chat UI and its settings (config writes, plugin
239
+ * toggles, logs) — what the shared token allowed. Set ["device",
240
+ * "client"] to keep paired phones out of config; `talon mesh scopes
241
+ * <device> <list>` changes one device. Nodes always get ["device"].
242
242
  */
243
243
  companionScopes: z
244
244
  .array(z.enum(["device", "client", "operator"]))
@@ -677,14 +677,15 @@ const configSchema = z.object({
677
677
  .optional(),
678
678
  braveApiKey: z.string().optional(),
679
679
  /**
680
- * `fetch_url` refuses hosts that resolve to loopback, private (RFC 1918,
681
- * CGNAT, ULA), link-local (incl. the 169.254.169.254 metadata endpoint)
682
- * or reserved addresses, re-checking every redirect hop. Set
683
- * `allowPrivateNetworks: true` only on a host where the agent should
684
- * read local services (a home lab, a dev server).
680
+ * `fetch_url` reaches any address by default, local services included (a
681
+ * home lab, a dev server, the LAN). Set `allowPrivateNetworks: false` to
682
+ * opt into the SSRF guard: it then refuses hosts that resolve to
683
+ * loopback, private (RFC 1918, CGNAT, ULA), link-local (incl. the
684
+ * 169.254.169.254 metadata endpoint) or reserved addresses, re-checking
685
+ * every redirect hop — worth it on a cloud VM.
685
686
  */
686
687
  fetchUrl: z
687
- .object({ allowPrivateNetworks: z.boolean().default(false) })
688
+ .object({ allowPrivateNetworks: z.boolean().default(true) })
688
689
  .strict()
689
690
  .optional(),
690
691
  /**
@@ -17,9 +17,9 @@
17
17
  * direct, redirect and static-DNS paths; rebinding needs a hostile DNS
18
18
  * server and a lucky race.
19
19
  *
20
- * Operators who run Talon next to services they WANT it to read (a home
21
- * lab, a local dev server) can opt out with
22
- * `fetchUrl.allowPrivateNetworks: true`.
20
+ * The guard is opt-in: `fetch_url` applies it only when the operator sets
21
+ * `fetchUrl.allowPrivateNetworks: false`. By default the agent can read
22
+ * local services (a home lab, a local dev server).
23
23
  */
24
24
 
25
25
  import { lookup } from "node:dns/promises";
@@ -163,7 +163,7 @@ export async function assertPublicUrl(
163
163
  if (blocked) {
164
164
  throw new BlockedUrlError(
165
165
  `Refusing to fetch ${host}: it resolves to a private, loopback or link-local address (${blocked}). ` +
166
- `Set fetchUrl.allowPrivateNetworks in config.json to allow local addresses.`,
166
+ `Remove fetchUrl.allowPrivateNetworks: false from config.json (or set it to true) to allow local addresses.`,
167
167
  );
168
168
  }
169
169
  }
@@ -75,8 +75,9 @@ export const fetchUrlHandlers: SharedActionHandlers = {
75
75
  const invalid = urlError(url);
76
76
  if (invalid) return { ok: false, error: invalid };
77
77
  try {
78
- // Every hop is checked against private/loopback/link-local ranges
79
- // (see guard.ts) unless the operator opted out for local use.
78
+ // Local addresses are reachable by default; with
79
+ // `fetchUrl.allowPrivateNetworks: false` every hop is checked against
80
+ // private/loopback/link-local ranges (see guard.ts).
80
81
  const resp = await guardedFetch(
81
82
  url,
82
83
  {
@@ -85,7 +86,7 @@ export const fetchUrlHandlers: SharedActionHandlers = {
85
86
  },
86
87
  {
87
88
  allowPrivateNetworks:
88
- getPoolConfig()?.fetchUrl?.allowPrivateNetworks === true,
89
+ getPoolConfig()?.fetchUrl?.allowPrivateNetworks !== false,
89
90
  },
90
91
  );
91
92
  if (!resp.ok) return { ok: false, error: `HTTP ${resp.status}` };
@@ -68,6 +68,7 @@ export const meshHandlers: SharedActionHandlers = {
68
68
  body.device ?? body.deviceId,
69
69
  body.apk_path,
70
70
  body.remote_path,
71
+ body.allow_downgrade,
71
72
  ),
72
73
  // Remote self-update for a headless talon-node: push a new binary and have
73
74
  // the node verify, swap, and restart into it. binary_path is optional —
@@ -78,6 +79,7 @@ export const meshHandlers: SharedActionHandlers = {
78
79
  body.device ?? body.deviceId,
79
80
  body.binary_path,
80
81
  body.remote_path,
82
+ body.allow_downgrade,
81
83
  ),
82
84
  // Node provisioning: materialize a talon-node binary for any arch, and
83
85
  // mint single-use bridge-served install links for fresh hosts.
@@ -204,6 +204,22 @@ const ROUTES: readonly GatewayRoute[] = [
204
204
  sendJson(res, 200, await credentialAdmin(ctx, body));
205
205
  },
206
206
  },
207
+ {
208
+ // The mesh command audit — the transport for `talon mesh audit`.
209
+ // Prefix match: the query (?limit=&device=) follows the path.
210
+ method: "GET",
211
+ path: "/mesh/audit",
212
+ match: "prefix",
213
+ handle: async ({ res, url }) => {
214
+ const limit = Number(url.searchParams.get("limit") ?? "");
215
+ const device = url.searchParams.get("device") ?? "";
216
+ const entries = await getMeshService().readAudit({
217
+ ...(Number.isInteger(limit) && limit > 0 ? { limit } : {}),
218
+ ...(device ? { device } : {}),
219
+ });
220
+ sendJson(res, 200, { ok: true, entries });
221
+ },
222
+ },
207
223
  {
208
224
  // MCP hub — daemon-hosted MCP-over-HTTP endpoints for every backend
209
225
  // (see core/mcp-hub).
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Mesh command audit — one line per device command the daemon dispatched:
3
+ * when, who asked (chat / turn / sender, when a turn issued it), which
4
+ * device, which command, a hash of its arguments, how it ended, and how
5
+ * long it took.
6
+ *
7
+ * Arguments are never written, only `argsHash`: the SHA-256 of their
8
+ * canonical JSON (keys sorted at every depth). Command lines and file
9
+ * bodies stay out of the file, but an operator holding a suspect command
10
+ * can still prove whether it is the one that ran.
11
+ *
12
+ * The log is a bounded ring on disk: JSON lines appended to
13
+ * ~/.talon/data/mesh-audit.jsonl (0600). When the next line would take the
14
+ * file past `maxBytes` it rotates to `mesh-audit.jsonl.1`, replacing the
15
+ * previous generation, so the pair never holds more than about twice the
16
+ * cap. Appends are serialized, so lines never interleave.
17
+ *
18
+ * Recording is fire-and-forget and can never fail a command: every error
19
+ * is caught here and logged (once per failure streak), and the command
20
+ * path carries on.
21
+ */
22
+
23
+ import { createHash } from "node:crypto";
24
+ import {
25
+ appendFile,
26
+ chmod,
27
+ mkdir,
28
+ readFile,
29
+ rename,
30
+ stat,
31
+ } from "node:fs/promises";
32
+ import { dirname, resolve } from "node:path";
33
+ import { dirs } from "../../util/paths.js";
34
+ import { logWarn } from "../../util/log.js";
35
+ import { currentTurn } from "../../util/logging/turn-scope.js";
36
+
37
+ const DEFAULT_FILE = resolve(dirs.data, "mesh-audit.jsonl");
38
+ /** One generation's cap; the file plus its `.1` stay under twice this. */
39
+ const DEFAULT_MAX_BYTES = 1024 * 1024;
40
+ const DEFAULT_READ_LIMIT = 50;
41
+ const MAX_READ_LIMIT = 5_000;
42
+ /** Longest failure reason kept (the device's own one-line summary). */
43
+ const MAX_ERROR_CHARS = 200;
44
+
45
+ /** Who issued a command. Present only when a turn issued it. */
46
+ export type MeshAuditIssuer = {
47
+ chatId: string;
48
+ turnId: string;
49
+ /** The sender's operator key or display name, when the turn had one. */
50
+ sender?: string;
51
+ /** What started the turn: message, cron, trigger, pulse, agent. */
52
+ source?: string;
53
+ };
54
+
55
+ export type MeshAuditEntry = {
56
+ /** ISO-8601 dispatch time. */
57
+ time: string;
58
+ issuer: MeshAuditIssuer | null;
59
+ deviceId: string;
60
+ deviceName: string;
61
+ command: string;
62
+ /** SHA-256 (hex) of the canonical JSON of the command's params. */
63
+ argsHash: string;
64
+ ok: boolean;
65
+ /** Why it failed (one line, truncated); absent on success. */
66
+ error?: string;
67
+ durationMs: number;
68
+ };
69
+
70
+ export type MeshAuditQuery = {
71
+ /** Newest entries to return (default 50). */
72
+ limit?: number;
73
+ /** Device id (exact) or name (case-insensitive, substring). */
74
+ device?: string;
75
+ };
76
+
77
+ /** Sort object keys at every depth, so equal params hash equally. */
78
+ function canonical(value: unknown): unknown {
79
+ if (Array.isArray(value)) return value.map(canonical);
80
+ if (value !== null && typeof value === "object") {
81
+ const record = value as Record<string, unknown>;
82
+ // fromEntries defines own properties, so a `__proto__` key stays data.
83
+ return Object.fromEntries(
84
+ Object.keys(record)
85
+ .sort()
86
+ .map((key) => [key, canonical(record[key])]),
87
+ );
88
+ }
89
+ return value;
90
+ }
91
+
92
+ /** SHA-256 (hex) of the canonical JSON of a command's params. */
93
+ export function hashCommandArgs(params: unknown): string {
94
+ const json = JSON.stringify(canonical(params ?? {})) ?? "null";
95
+ return createHash("sha256").update(json).digest("hex");
96
+ }
97
+
98
+ /**
99
+ * Who is issuing a command right now: the running turn of the current
100
+ * async chain (tool actions re-enter it through the gateway), or null for
101
+ * a dispatch no turn made (a `/mesh` command, a bridge UI request).
102
+ */
103
+ export function auditIssuer(): MeshAuditIssuer | null {
104
+ const turn = currentTurn();
105
+ if (!turn) return null;
106
+ const { sender, source } = turn.issuer;
107
+ return {
108
+ chatId: turn.chatId,
109
+ turnId: turn.turnId,
110
+ ...(sender ? { sender } : {}),
111
+ ...(source ? { source } : {}),
112
+ };
113
+ }
114
+
115
+ /** A device's failure message, kept to one short line. */
116
+ export function auditErrorText(message: string | undefined): string {
117
+ const line = (message ?? "failed").split(/\r?\n/, 1)[0]!.trim();
118
+ return line.length > MAX_ERROR_CHARS
119
+ ? `${line.slice(0, MAX_ERROR_CHARS - 1)}…`
120
+ : line || "failed";
121
+ }
122
+
123
+ function matchesDevice(entry: MeshAuditEntry, device: string): boolean {
124
+ const q = device.toLowerCase();
125
+ return (
126
+ entry.deviceId === device ||
127
+ (typeof entry.deviceName === "string" &&
128
+ entry.deviceName.toLowerCase().includes(q))
129
+ );
130
+ }
131
+
132
+ /** Parse JSON lines, skipping any torn or foreign line. */
133
+ function parseLines(raw: string): MeshAuditEntry[] {
134
+ const out: MeshAuditEntry[] = [];
135
+ for (const line of raw.split("\n")) {
136
+ if (!line.trim()) continue;
137
+ try {
138
+ const entry = JSON.parse(line) as MeshAuditEntry;
139
+ if (entry && typeof entry.command === "string") out.push(entry);
140
+ } catch {
141
+ // A half-written line from a crash mid-append: skip it.
142
+ }
143
+ }
144
+ return out;
145
+ }
146
+
147
+ export class MeshAuditLog {
148
+ private chain: Promise<void> = Promise.resolve();
149
+ /** Bytes in the current generation; null until first checked on disk. */
150
+ private size: number | null = null;
151
+ private failing = false;
152
+
153
+ constructor(
154
+ private readonly file: string = DEFAULT_FILE,
155
+ private readonly maxBytes: number = DEFAULT_MAX_BYTES,
156
+ ) {}
157
+
158
+ /** Queue one entry. Never throws and never rejects. */
159
+ record(entry: MeshAuditEntry): void {
160
+ let line: string;
161
+ try {
162
+ line = `${JSON.stringify(entry)}\n`;
163
+ } catch (err) {
164
+ this.warn(err);
165
+ return;
166
+ }
167
+ this.chain = this.chain
168
+ .then(() => this.append(line))
169
+ .then(() => {
170
+ this.failing = false;
171
+ })
172
+ .catch((err: unknown) => this.warn(err));
173
+ }
174
+
175
+ /** Resolves once every queued entry has been written (or given up on). */
176
+ flush(): Promise<void> {
177
+ return this.chain;
178
+ }
179
+
180
+ /** The newest matching entries, oldest first. */
181
+ async read(query: MeshAuditQuery = {}): Promise<MeshAuditEntry[]> {
182
+ await this.flush();
183
+ const limit = Math.min(
184
+ MAX_READ_LIMIT,
185
+ Math.max(1, Math.floor(query.limit ?? DEFAULT_READ_LIMIT)),
186
+ );
187
+ const [older, current] = await Promise.all([
188
+ readFile(`${this.file}.1`, "utf8").catch(() => ""),
189
+ readFile(this.file, "utf8").catch(() => ""),
190
+ ]);
191
+ let entries = [...parseLines(older), ...parseLines(current)];
192
+ if (query.device) {
193
+ const device = query.device;
194
+ entries = entries.filter((e) => matchesDevice(e, device));
195
+ }
196
+ return entries.slice(-limit);
197
+ }
198
+
199
+ private async append(line: string): Promise<void> {
200
+ if (this.size === null) this.size = await this.open();
201
+ const bytes = Buffer.byteLength(line);
202
+ if (this.size > 0 && this.size + bytes > this.maxBytes) {
203
+ await rename(this.file, `${this.file}.1`);
204
+ this.size = 0;
205
+ }
206
+ await appendFile(this.file, line, { mode: 0o600 });
207
+ this.size += bytes;
208
+ }
209
+
210
+ /** First write this process: make the directory, tighten an old file. */
211
+ private async open(): Promise<number> {
212
+ await mkdir(dirname(this.file), { recursive: true, mode: 0o700 });
213
+ let size: number;
214
+ try {
215
+ size = (await stat(this.file)).size;
216
+ } catch {
217
+ return 0; // no file yet: appendFile creates it 0600
218
+ }
219
+ // A file from an older build or a copied home may be wider than 0600.
220
+ await chmod(this.file, 0o600).catch(() => {});
221
+ return size;
222
+ }
223
+
224
+ private warn(err: unknown): void {
225
+ if (this.failing) return;
226
+ this.failing = true;
227
+ logWarn(
228
+ "mesh",
229
+ `mesh.audit event=write_failed file=${this.file} err=${err instanceof Error ? err.message : String(err)}`,
230
+ );
231
+ }
232
+ }
@@ -7,6 +7,7 @@ export { DeviceCredentialStore } from "./store.js";
7
7
  export { isDeviceCredentialToken } from "./token.js";
8
8
  export {
9
9
  DEFAULT_COMPANION_SCOPES,
10
+ FORMER_COMPANION_SCOPES,
10
11
  NODE_SCOPES,
11
12
  type CredentialOrigin,
12
13
  type DeviceCredential,