talon-agent 3.1.0 → 3.2.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/package.json +4 -6
- package/src/cli/index.ts +1 -1
- package/src/core/engine/gateway-actions/mesh.ts +8 -0
- package/src/core/mesh/service.ts +81 -0
- package/src/core/tools/mesh.ts +21 -0
- package/src/frontend/native/auth.ts +52 -0
- package/src/frontend/native/index.ts +8 -2
- package/src/frontend/native/server.ts +96 -15
- package/src/frontend/telegram/commands/admin.ts +1 -1
- package/src/frontend/telegram/formatting.ts +1 -1
- package/src/native/htmlents-wasm-bytes.ts +4 -4
- package/src/native/htmlents.ts +4 -4
- package/src/native/registry.ts +9 -9
- package/src/native/runtime.ts +2 -2
- package/src/native/sqlguard-wasm-bytes.ts +4 -4
- package/src/native/sqlguard.ts +2 -2
- package/src/native/strsim-wasm-bytes.ts +4 -4
- package/src/native/strsim.ts +3 -3
- package/src/storage/history.ts +1 -1
- package/src/util/config.ts +9 -2
- package/src/util/harden.ts +50 -0
- package/src/util/log.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talon-agent",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
|
|
5
5
|
"author": "Dylan Neve",
|
|
6
6
|
"license": "MIT",
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"test:openai-agents:backend": "vitest run --reporter=verbose --reporter=json --outputFile=openai-agents-backend-results.json src/__tests__/integration/openai-agents-live-discovery.test.ts",
|
|
72
72
|
"build:sql": "tsx scripts/embed-sql.ts",
|
|
73
73
|
"build:prompts": "tsx scripts/embed-prompts.ts",
|
|
74
|
-
"build:wasm": "node native/blake3-wasm/build.mjs",
|
|
74
|
+
"build:wasm": "node native/blake3-wasm/build.mjs && node native/strsim-wasm/build.mjs && node native/sqlguard-wasm/build.mjs && node native/htmlents-wasm/build.mjs",
|
|
75
75
|
"tarball:check": "node .github/scripts/tarball-check.mjs",
|
|
76
76
|
"build:stub-sea": "node src/__tests__/integration/stub-claude/build-sea.mjs",
|
|
77
77
|
"test:watch": "vitest",
|
|
@@ -86,9 +86,7 @@
|
|
|
86
86
|
"ci:protect": "node .github/scripts/enforce-ci-gate.mjs",
|
|
87
87
|
"build:gleam": "cd native/scheduler-core && gleam build --target javascript && node embed.mjs",
|
|
88
88
|
"build:zig": "node native/textops-wasm/build.mjs",
|
|
89
|
-
"build:
|
|
90
|
-
"build:cpp": "node native/htmlents-cpp/build.mjs",
|
|
91
|
-
"build:native": "npm run build:wasm && npm run build:zig && npm run build:c && npm run build:cpp && npm run build:gleam",
|
|
89
|
+
"build:native": "npm run build:wasm && npm run build:zig && npm run build:gleam",
|
|
92
90
|
"build:driver": "node native/talon-driver/build.mjs",
|
|
93
91
|
"build:driver:all": "node native/talon-driver/build.mjs --all",
|
|
94
92
|
"build:warden": "node native/talon-warden/build.mjs",
|
|
@@ -105,7 +103,7 @@
|
|
|
105
103
|
"@kilocode/sdk": "^7.2.22",
|
|
106
104
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
107
105
|
"@openai/agents": "^0.13.0",
|
|
108
|
-
"@openai/codex-sdk": "^0.
|
|
106
|
+
"@openai/codex-sdk": "^0.145.0",
|
|
109
107
|
"@opencode-ai/sdk": "^1.17.4",
|
|
110
108
|
"@playwright/mcp": "^0.0.78",
|
|
111
109
|
"@types/cross-spawn": "^6.0.6",
|
package/src/cli/index.ts
CHANGED
|
@@ -172,7 +172,7 @@ export async function runCli(): Promise<void> {
|
|
|
172
172
|
mainMenu();
|
|
173
173
|
break;
|
|
174
174
|
default: {
|
|
175
|
-
// "did you mean ...?" via the
|
|
175
|
+
// "did you mean ...?" via the native similarity core (native/strsim-wasm).
|
|
176
176
|
const { closestMatch } = await import("../native/strsim.js");
|
|
177
177
|
const suggestion = closestMatch(command, CLI_COMMANDS);
|
|
178
178
|
const hint = suggestion
|
|
@@ -57,4 +57,12 @@ export const meshHandlers: SharedActionHandlers = {
|
|
|
57
57
|
body.apk_path,
|
|
58
58
|
body.remote_path,
|
|
59
59
|
),
|
|
60
|
+
// Remote self-update for a headless talon-node: push a new binary and have
|
|
61
|
+
// the node verify, swap, and restart into it.
|
|
62
|
+
update_node: (body) =>
|
|
63
|
+
getMeshService().updateNodeBinary(
|
|
64
|
+
body.device,
|
|
65
|
+
body.binary_path,
|
|
66
|
+
body.remote_path,
|
|
67
|
+
),
|
|
60
68
|
};
|
package/src/core/mesh/service.ts
CHANGED
|
@@ -873,6 +873,87 @@ export class MeshService {
|
|
|
873
873
|
};
|
|
874
874
|
}
|
|
875
875
|
|
|
876
|
+
/**
|
|
877
|
+
* `update_node`: remote self-update for a headless talon-node. Streams a
|
|
878
|
+
* replacement binary to the node, then sends `update_node` so the node
|
|
879
|
+
* verifies the digest, atomically swaps its own binary, and restarts into
|
|
880
|
+
* it (an in-place execve under systemd/launchd, so the mesh connection
|
|
881
|
+
* returns on its own within seconds — the same UX as the Android path).
|
|
882
|
+
*
|
|
883
|
+
* The binary is hashed here and the digest travels with the command; the
|
|
884
|
+
* node re-hashes the pushed file and refuses to swap on a mismatch, so a
|
|
885
|
+
* truncated transfer can never be installed. Build the replacement for the
|
|
886
|
+
* node's platform/arch (talon-node-<os>-<arch>) before calling.
|
|
887
|
+
*/
|
|
888
|
+
async updateNodeBinary(
|
|
889
|
+
query: unknown,
|
|
890
|
+
localBinaryPath: unknown,
|
|
891
|
+
remotePath?: unknown,
|
|
892
|
+
): Promise<MeshToolResult> {
|
|
893
|
+
const local =
|
|
894
|
+
typeof localBinaryPath === "string" && localBinaryPath.trim()
|
|
895
|
+
? resolve(dirs.workspace, localBinaryPath.trim())
|
|
896
|
+
: "";
|
|
897
|
+
if (!local) return { ok: false, text: "A local binary path is required." };
|
|
898
|
+
await this.load();
|
|
899
|
+
const resolved = this.resolveDevice(query);
|
|
900
|
+
if ("error" in resolved) return { ok: false, text: resolved.error };
|
|
901
|
+
const target = resolved.target;
|
|
902
|
+
if (target.capabilities && !target.capabilities.includes("update_node")) {
|
|
903
|
+
return {
|
|
904
|
+
ok: false,
|
|
905
|
+
text: `${target.name} can't self-update — it needs the update_node capability (a talon-node headless device).`,
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
let sha256: string;
|
|
910
|
+
let size: number;
|
|
911
|
+
try {
|
|
912
|
+
({ sha256, size } = await hashFile(local));
|
|
913
|
+
} catch (err) {
|
|
914
|
+
return {
|
|
915
|
+
ok: false,
|
|
916
|
+
text: `Cannot read binary ${local}: ${(err as Error).message}`,
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
if (size === 0) return { ok: false, text: `Binary ${local} is empty.` };
|
|
920
|
+
|
|
921
|
+
// Default staging path is /tmp on unix nodes (the node re-stages next to
|
|
922
|
+
// its own executable before the atomic swap, so this is only transient).
|
|
923
|
+
const remote =
|
|
924
|
+
typeof remotePath === "string" && remotePath.trim()
|
|
925
|
+
? remotePath.trim()
|
|
926
|
+
: "/tmp/talon-node.update";
|
|
927
|
+
|
|
928
|
+
// 1. Stream the new binary to the node.
|
|
929
|
+
const push = await this.pushFileToDevice(target.id, local, remote);
|
|
930
|
+
if (!push.ok) {
|
|
931
|
+
return { ok: false, text: `Update aborted — push failed: ${push.text}` };
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// 2. Trigger the swap + restart (node verifies the digest first).
|
|
935
|
+
const dispatched = await this.dispatchCommand(
|
|
936
|
+
target.id,
|
|
937
|
+
"update_node",
|
|
938
|
+
{ path: remote, sha256 },
|
|
939
|
+
this.commandTimeoutMs,
|
|
940
|
+
);
|
|
941
|
+
if ("error" in dispatched) return { ok: false, text: dispatched.error };
|
|
942
|
+
if (!dispatched.result.ok) {
|
|
943
|
+
return {
|
|
944
|
+
ok: false,
|
|
945
|
+
text: dispatched.result.message ?? `${target.name} refused the update.`,
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
return {
|
|
949
|
+
ok: true,
|
|
950
|
+
text:
|
|
951
|
+
`Pushed ${formatBytes(size)} and staged the update on ${target.name}. ` +
|
|
952
|
+
`${dispatched.result.message ?? "Restarting now."} ` +
|
|
953
|
+
`Confirm with get_device_status once it reconnects (appVersion should change).`,
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
|
|
876
957
|
/**
|
|
877
958
|
* Chunked read of a remote file into a Buffer. Loops `read_file` with
|
|
878
959
|
* increasing offsets until the device reports EOF.
|
package/src/core/tools/mesh.ts
CHANGED
|
@@ -185,4 +185,25 @@ export const meshTools: ToolDefinition[] = [
|
|
|
185
185
|
execute: (params, bridge) => bridge("update_device", params),
|
|
186
186
|
tag: "mesh",
|
|
187
187
|
},
|
|
188
|
+
{
|
|
189
|
+
name: "update_node",
|
|
190
|
+
description:
|
|
191
|
+
"Remotely update a headless talon-node (Linux/macOS/Windows server on the mesh): stream a new node binary from the daemon and have the node verify its hash, atomically swap its own binary, and restart into it. On Linux/macOS this is an in-place execve so the mesh connection returns within seconds; the node re-hashes the pushed file and refuses a truncated or mismatched binary. Build the replacement for the node's platform/arch first (talon-node-<os>-<arch>). Confirm success with get_device_status afterwards (appVersion should change). This is the node counterpart of update_device — use update_device for Android companions.",
|
|
192
|
+
schema: {
|
|
193
|
+
device: deviceParam,
|
|
194
|
+
binary_path: z
|
|
195
|
+
.string()
|
|
196
|
+
.describe(
|
|
197
|
+
"Path to the new talon-node binary, relative to the workspace (or absolute), built for the node's OS/arch.",
|
|
198
|
+
),
|
|
199
|
+
remote_path: z
|
|
200
|
+
.string()
|
|
201
|
+
.optional()
|
|
202
|
+
.describe(
|
|
203
|
+
"Where to stage the binary on the node (default /tmp/talon-node.update; the node re-stages next to its own executable before the atomic swap).",
|
|
204
|
+
),
|
|
205
|
+
},
|
|
206
|
+
execute: (params, bridge) => bridge("update_node", params),
|
|
207
|
+
tag: "mesh",
|
|
208
|
+
},
|
|
188
209
|
];
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge auth token — the shared secret behind every non-/health route.
|
|
3
|
+
*
|
|
4
|
+
* The operator can set one explicitly (`native.token`); this module covers
|
|
5
|
+
* the case where they didn't but the bridge is about to bind a non-loopback
|
|
6
|
+
* host. Serving the full agent API unauthenticated to the LAN is never an
|
|
7
|
+
* acceptable default, so a token is minted once and persisted under
|
|
8
|
+
* ~/.talon/keys/ — stable across restarts so paired clients keep working.
|
|
9
|
+
*
|
|
10
|
+
* The token value itself never goes to the log (SECURITY.md treats
|
|
11
|
+
* credentials in logs as a vulnerability); same-machine clients pick it up
|
|
12
|
+
* from the 0600 discovery file, remote clients read it from the key file.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { randomBytes } from "node:crypto";
|
|
16
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { resolve } from "node:path";
|
|
18
|
+
import { dirs } from "../../util/paths.js";
|
|
19
|
+
import { log } from "../../util/log.js";
|
|
20
|
+
|
|
21
|
+
const TOKEN_FILE = "bridge-token";
|
|
22
|
+
/** 32 random bytes → 43 base64url chars; comfortably beyond brute force. */
|
|
23
|
+
const TOKEN_BYTES = 32;
|
|
24
|
+
|
|
25
|
+
/** Path of the persisted token, for operator-facing messages. */
|
|
26
|
+
export function bridgeTokenPath(dir: string = dirs.keys): string {
|
|
27
|
+
return resolve(dir, TOKEN_FILE);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Load the persisted auto-generated bridge token, minting it on first use.
|
|
32
|
+
* Lives under ~/.talon/keys/ with owner-only permissions, like the TLS
|
|
33
|
+
* identity next to it.
|
|
34
|
+
*/
|
|
35
|
+
export function loadOrCreateBridgeToken(dir: string = dirs.keys): string {
|
|
36
|
+
const path = bridgeTokenPath(dir);
|
|
37
|
+
try {
|
|
38
|
+
const existing = readFileSync(path, "utf-8").trim();
|
|
39
|
+
if (existing) return existing;
|
|
40
|
+
} catch {
|
|
41
|
+
// first boot — nothing persisted yet
|
|
42
|
+
}
|
|
43
|
+
const token = randomBytes(TOKEN_BYTES).toString("base64url");
|
|
44
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
45
|
+
writeFileSync(path, `${token}\n`, { mode: 0o600 });
|
|
46
|
+
chmodSync(path, 0o600); // mode above is ignored when the file exists
|
|
47
|
+
log(
|
|
48
|
+
"native",
|
|
49
|
+
`Minted bridge auth token (non-loopback bind with no native.token) — pair remote clients with the value in ${path}`,
|
|
50
|
+
);
|
|
51
|
+
return token;
|
|
52
|
+
}
|
|
@@ -75,6 +75,7 @@ import { createNativeActionHandler } from "./actions.js";
|
|
|
75
75
|
import { getMeshService } from "../../core/mesh/index.js";
|
|
76
76
|
import { removeBridgeDiscovery, writeBridgeDiscovery } from "./discovery.js";
|
|
77
77
|
import { isLoopbackHost, loadOrCreateBridgeTlsIdentity } from "./tls.js";
|
|
78
|
+
import { loadOrCreateBridgeToken } from "./auth.js";
|
|
78
79
|
import { readLogEntries } from "./logs.js";
|
|
79
80
|
import {
|
|
80
81
|
BRIDGE_PROTOCOL_VERSION,
|
|
@@ -1200,11 +1201,16 @@ export function createNativeFrontend(
|
|
|
1200
1201
|
// Encrypted by default the moment the bridge leaves the machine; loopback
|
|
1201
1202
|
// stays plain HTTP unless explicitly opted in (`native.tls`).
|
|
1202
1203
|
const bridgeTls = nativeCfg.tls ?? !isLoopbackHost(bridgeHost);
|
|
1204
|
+
// Never serve the agent API to the network unauthenticated: a non-loopback
|
|
1205
|
+
// bind with no configured token gets a persistent auto-minted one instead.
|
|
1206
|
+
const bridgeToken =
|
|
1207
|
+
nativeCfg.token ??
|
|
1208
|
+
(isLoopbackHost(bridgeHost) ? undefined : loadOrCreateBridgeToken());
|
|
1203
1209
|
const server = new BridgeServer(
|
|
1204
1210
|
{
|
|
1205
1211
|
host: bridgeHost,
|
|
1206
1212
|
port: nativeCfg.port ?? 19880,
|
|
1207
|
-
token:
|
|
1213
|
+
token: bridgeToken,
|
|
1208
1214
|
startedAt,
|
|
1209
1215
|
...(bridgeTls ? { tls: () => loadOrCreateBridgeTlsIdentity() } : {}),
|
|
1210
1216
|
},
|
|
@@ -1274,7 +1280,7 @@ export function createNativeFrontend(
|
|
|
1274
1280
|
const fingerprint = server.getFingerprint();
|
|
1275
1281
|
await writeBridgeDiscovery({
|
|
1276
1282
|
port: server.getPort(),
|
|
1277
|
-
token:
|
|
1283
|
+
token: bridgeToken,
|
|
1278
1284
|
scheme: server.getScheme(),
|
|
1279
1285
|
...(fingerprint ? { fingerprint } : {}),
|
|
1280
1286
|
startedAt: Date.parse(startedAt),
|
|
@@ -25,7 +25,7 @@ import { createHash, timingSafeEqual } from "node:crypto";
|
|
|
25
25
|
import { createReadStream } from "node:fs";
|
|
26
26
|
import { stat } from "node:fs/promises";
|
|
27
27
|
import { extname } from "node:path";
|
|
28
|
-
import { log, logError, logDebug } from "../../util/log.js";
|
|
28
|
+
import { log, logError, logDebug, logWarn } from "../../util/log.js";
|
|
29
29
|
import { formatFingerprint, type BridgeTlsIdentity } from "./tls.js";
|
|
30
30
|
import {
|
|
31
31
|
BRIDGE_PROTOCOL_VERSION,
|
|
@@ -149,12 +149,32 @@ const MAX_BODY_BYTES = 256 * 1024;
|
|
|
149
149
|
const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
|
150
150
|
const PORT_FALLBACKS = 5;
|
|
151
151
|
|
|
152
|
+
// Failed-auth lockout: after this many wrong tokens from one address inside
|
|
153
|
+
// the window, that address gets 429s until the window lapses. The token's
|
|
154
|
+
// 256 bits make brute force hopeless anyway — this is about not letting an
|
|
155
|
+
// internet-facing bridge be hammered for free (and giving fail2ban-style
|
|
156
|
+
// tooling a clean signal in the log). Only *presented-and-wrong* secrets
|
|
157
|
+
// count: tokenless probes are just scanners finding a locked door.
|
|
158
|
+
const AUTH_LOCKOUT_MAX_FAILURES = 20;
|
|
159
|
+
const AUTH_LOCKOUT_WINDOW_MS = 15 * 60 * 1000;
|
|
160
|
+
/** Hard cap on tracked addresses so the map can't become a memory lever. */
|
|
161
|
+
const AUTH_LOCKOUT_MAX_TRACKED = 10_000;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* ok: request carries the right token (or none is required).
|
|
165
|
+
* anonymous: no credential presented — a pre-pairing probe, not an attack.
|
|
166
|
+
* bad: a credential was presented and it is wrong.
|
|
167
|
+
*/
|
|
168
|
+
type AuthState = "ok" | "anonymous" | "bad";
|
|
169
|
+
|
|
152
170
|
export class BridgeServer {
|
|
153
171
|
private server: Server | null = null;
|
|
154
172
|
private clients = new Set<ServerResponse>();
|
|
155
173
|
private pingTimer: ReturnType<typeof setInterval> | undefined;
|
|
156
174
|
private port = 0;
|
|
157
175
|
private tlsIdentity: BridgeTlsIdentity | null = null;
|
|
176
|
+
/** Wrong-token counts per remote address (behind a proxy: per proxy). */
|
|
177
|
+
private authFailures = new Map<string, { count: number; resetAt: number }>();
|
|
158
178
|
|
|
159
179
|
constructor(
|
|
160
180
|
private readonly opts: {
|
|
@@ -311,21 +331,43 @@ export class BridgeServer {
|
|
|
311
331
|
return;
|
|
312
332
|
}
|
|
313
333
|
|
|
334
|
+
const remote = req.socket.remoteAddress ?? "unknown";
|
|
335
|
+
if (this.authLockedOut(remote)) {
|
|
336
|
+
res.writeHead(429, {
|
|
337
|
+
...this.jsonHeaders(),
|
|
338
|
+
"Retry-After": String(Math.ceil(AUTH_LOCKOUT_WINDOW_MS / 1000)),
|
|
339
|
+
});
|
|
340
|
+
res.end(
|
|
341
|
+
JSON.stringify({ ok: false, error: "Too many failed auth attempts" }),
|
|
342
|
+
);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const auth = this.authState(req, url);
|
|
347
|
+
if (auth === "bad") this.recordAuthFailure(remote);
|
|
348
|
+
else if (auth === "ok") this.authFailures.delete(remote);
|
|
349
|
+
|
|
314
350
|
// /health is unauthenticated so clients can discover/ping the bridge
|
|
315
|
-
// before they hold a token.
|
|
351
|
+
// before they hold a token. Pre-auth it serves only what pairing needs
|
|
352
|
+
// (identity, protocol, fingerprint) — operational details like bot name,
|
|
353
|
+
// backend, and chat count are not for internet scanners to enumerate.
|
|
316
354
|
if (method === "GET" && path === "/health") {
|
|
317
|
-
const
|
|
318
|
-
return this.json(res, 200, {
|
|
355
|
+
const base = {
|
|
319
356
|
app: "talon-bridge",
|
|
320
357
|
ok: true,
|
|
321
358
|
protocol: BRIDGE_PROTOCOL_VERSION,
|
|
322
|
-
host: this.opts.host,
|
|
323
359
|
port: this.port,
|
|
324
360
|
scheme: this.getScheme(),
|
|
325
361
|
// The certificate's own hash — public by definition (any TLS client
|
|
326
362
|
// sees the certificate), surfaced so pairing UIs can display it.
|
|
327
363
|
fingerprint: this.getFingerprint(),
|
|
328
364
|
authRequired: Boolean(this.opts.token),
|
|
365
|
+
};
|
|
366
|
+
if (auth !== "ok") return this.json(res, 200, base);
|
|
367
|
+
const s = this.handlers.status();
|
|
368
|
+
return this.json(res, 200, {
|
|
369
|
+
...base,
|
|
370
|
+
host: this.opts.host,
|
|
329
371
|
startedAt: this.opts.startedAt,
|
|
330
372
|
botName: s.botName,
|
|
331
373
|
backend: s.backend,
|
|
@@ -335,7 +377,7 @@ export class BridgeServer {
|
|
|
335
377
|
});
|
|
336
378
|
}
|
|
337
379
|
|
|
338
|
-
if (
|
|
380
|
+
if (auth !== "ok") {
|
|
339
381
|
return this.json(res, 401, { ok: false, error: "Unauthorized" });
|
|
340
382
|
}
|
|
341
383
|
|
|
@@ -677,17 +719,54 @@ export class BridgeServer {
|
|
|
677
719
|
|
|
678
720
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
679
721
|
|
|
680
|
-
private
|
|
681
|
-
if (!this.opts.token) return
|
|
722
|
+
private authState(req: IncomingMessage, url: URL): AuthState {
|
|
723
|
+
if (!this.opts.token) return "ok";
|
|
682
724
|
const header = req.headers["authorization"];
|
|
683
|
-
|
|
684
|
-
typeof header === "string" &&
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
)
|
|
688
|
-
return true;
|
|
725
|
+
const fromHeader =
|
|
726
|
+
typeof header === "string" && header.startsWith("Bearer ")
|
|
727
|
+
? header.slice("Bearer ".length)
|
|
728
|
+
: null;
|
|
689
729
|
// EventSource can't set headers, so SSE clients pass ?token=… instead.
|
|
690
|
-
|
|
730
|
+
const candidate = fromHeader ?? url.searchParams.get("token");
|
|
731
|
+
if (candidate === null) return "anonymous";
|
|
732
|
+
return this.tokenMatches(candidate) ? "ok" : "bad";
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
private authLockedOut(remote: string): boolean {
|
|
736
|
+
const entry = this.authFailures.get(remote);
|
|
737
|
+
if (!entry) return false;
|
|
738
|
+
if (Date.now() >= entry.resetAt) {
|
|
739
|
+
this.authFailures.delete(remote);
|
|
740
|
+
return false;
|
|
741
|
+
}
|
|
742
|
+
return entry.count >= AUTH_LOCKOUT_MAX_FAILURES;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
private recordAuthFailure(remote: string): void {
|
|
746
|
+
const now = Date.now();
|
|
747
|
+
const entry = this.authFailures.get(remote);
|
|
748
|
+
if (!entry || now >= entry.resetAt) {
|
|
749
|
+
if (this.authFailures.size >= AUTH_LOCKOUT_MAX_TRACKED) {
|
|
750
|
+
for (const [ip, e] of this.authFailures) {
|
|
751
|
+
if (now >= e.resetAt) this.authFailures.delete(ip);
|
|
752
|
+
}
|
|
753
|
+
// Still saturated after pruning live entries — under that much churn
|
|
754
|
+
// dropping the newest attacker beats unbounded growth.
|
|
755
|
+
if (this.authFailures.size >= AUTH_LOCKOUT_MAX_TRACKED) return;
|
|
756
|
+
}
|
|
757
|
+
this.authFailures.set(remote, {
|
|
758
|
+
count: 1,
|
|
759
|
+
resetAt: now + AUTH_LOCKOUT_WINDOW_MS,
|
|
760
|
+
});
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
entry.count++;
|
|
764
|
+
if (entry.count === AUTH_LOCKOUT_MAX_FAILURES) {
|
|
765
|
+
logWarn(
|
|
766
|
+
"native",
|
|
767
|
+
`Bridge auth lockout for ${remote} (${AUTH_LOCKOUT_MAX_FAILURES} wrong tokens in ${AUTH_LOCKOUT_WINDOW_MS / 60_000}m)`,
|
|
768
|
+
);
|
|
769
|
+
}
|
|
691
770
|
}
|
|
692
771
|
|
|
693
772
|
/**
|
|
@@ -708,6 +787,8 @@ export class BridgeServer {
|
|
|
708
787
|
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
709
788
|
"Access-Control-Allow-Headers": "Authorization, Content-Type",
|
|
710
789
|
"Access-Control-Max-Age": "86400",
|
|
790
|
+
// Every response states its type; never let a browser guess one.
|
|
791
|
+
"X-Content-Type-Options": "nosniff",
|
|
711
792
|
};
|
|
712
793
|
}
|
|
713
794
|
|
|
@@ -211,7 +211,7 @@ export function registerAdminCommands(
|
|
|
211
211
|
}
|
|
212
212
|
|
|
213
213
|
// Unknown /command → "did you mean ...?" via the C similarity core
|
|
214
|
-
// (native/strsim-
|
|
214
|
+
// (native/strsim-wasm). Registered after every real command, so grammY
|
|
215
215
|
// only reaches this when nothing above matched. Only bare commands
|
|
216
216
|
// are intercepted — a close miss gets a suggestion, anything else
|
|
217
217
|
// keeps flowing to the agent as a normal message.
|
|
@@ -18,7 +18,7 @@ export function splitMessage(text: string, max: number): string[] {
|
|
|
18
18
|
/**
|
|
19
19
|
* Escape HTML special characters for Telegram HTML parse mode.
|
|
20
20
|
* Must be applied to all text that is NOT inside an HTML tag.
|
|
21
|
-
* Delegates to the
|
|
21
|
+
* Delegates to the Rust core (native/htmlents-wasm): one pass over the
|
|
22
22
|
* bytes instead of the five chained regex passes this replaces.
|
|
23
23
|
*/
|
|
24
24
|
export function escapeHtml(text: string): string {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Generated by native/shared/build-lib.mjs — do not edit by hand.
|
|
2
|
-
// Rebuild with `npm run build:
|
|
3
|
-
// Source: native/htmlents-
|
|
2
|
+
// Rebuild with `npm run build:wasm` after changing the source.
|
|
3
|
+
// Source: native/htmlents-wasm (1195 bytes of wasm32-unknown-unknown).
|
|
4
4
|
|
|
5
|
-
/** native/htmlents-
|
|
5
|
+
/** native/htmlents-wasm artifact, base64-encoded. Decoded + instantiated lazily by src/native/htmlents.ts. */
|
|
6
6
|
export const HTMLENTS_WASM_BASE64 =
|
|
7
|
-
"
|
|
7
|
+
"AGFzbQEAAAABKQdgBX9/f39/AGADf39/AGACf38AYAR/f39/AGABfwF/YAJ/fwF/YAAAAwsKAAECAwIEAgUCBgUDAQARBhkDfwFBgIDAAAt/AEGggMAAC38AQaCAwAALB0UGBm1lbW9yeQIABWFsbG9jAAULX19oZWFwX2Jhc2UDAQdkZWFsbG9jAAYLZXNjYXBlX2h0bWwABwpfX2RhdGFfZW5kAwIK3gcKNQACQCACIAFJDQAgAiAESw0AIAAgAiABazYCBCAAIAMgAWo2AgAPCyABIAIgBBCBgICAAAALCQAQiYCAgAAAC40BAQJ/QQAhAgJAAkACQAJAAkACQAJAAkAgAUH/AXEiAUFeag4GBAcHBwEFAAsgAUFEag4DAQYCBgtBgIDAgAAhAgwEC0GFgMCAACECQQQhAwwEC0GJgMCAACECQQQhAwwDC0GNgMCAACECQQYhAwwCC0GTgMCAACECC0EFIQMLIAAgAzYCBCAAIAI2AgALKgACQCABIANHDQACQCABRQ0AIAAgAiAB/AoAAAsPCyABIAMQhICAgAAACwkAEImAgIAAAAuiAQEDfwJAIAANAEEADwtBACEBAkBBACgCmIDAgAAiAg0AQaCAwIAAIQJBAEGggMCAADYCmIDAgAALAkACQCACQQdqQXhxIgIgAGoiACACSQ0AIAA/AEEQdCIDTQ0BQQAhASAAIANrQRB2IABB//8DcUEAR2pAAEF/Rw0BCyABDwtBACAANgKYgMCAAEEAQQAoApyAwIAAQQFqNgKcgMCAACACC0AAAkAgAEUNACABRQ0AQQAoApyAwIAAIgBFDQBBACAAQX9qIgA2ApyAwIAAIAANAEEAQaCAwIAANgKYgMCAAAsL5AMBB38jgICAgABBwABrIgIkgICAgABBACEDQQAhBAJAIAFFDQBBACEEIAAhBSABIQYDQCACQTBqIAUtAAAQgoCAgAAgAigCNEEBIAIoAjAbIARqIQQgBUEBaiEFIAZBf2oiBg0ACwtBDCEFAkAgBEEMaiIHEIWAgIAAIghFDQAgAEEBIAEbIQYgAkEoakEAQQQgCCAHEICAgIAAIAIoAiwhAyACKAIoIQAgAiAHNgI8IAAgAyACQTxqQQQQg4CAgAAgAkEgakEEQQggCCAHEICAgIAAIAIoAiQhAyACKAIgIQAgAkEBNgI8IAAgAyACQTxqQQQQg4CAgAAgAkEYakEIQQwgCCAHEICAgIAAIAIoAhwhAyACKAIYIQAgAiAENgI8IAAgAyACQTxqQQQQg4CAgAADQAJAAkACQCABRQ0AIAJBEGogBi0AACIDEIKAgIAAIAIoAhAiBEUNASACQQhqIAUgAigCFCIDIAVqIgAgCCAHEICAgIAAIAIoAgggAigCDCAEIAMQg4CAgAAgACEFDAILIAghAwwDCwJAIAUgB08NACAIIAVqIAM6AAAgBUEBaiEFDAELIAUgBxCIgICAAAALIAZBAWohBiABQX9qIQEMAAsLIAJBwABqJICAgIAAIAMLCQAQiYCAgAAACwMAAAsLIQEAQYCAwAALGCZhbXA7Jmx0OyZndDsmcXVvdDsmIzM5Ow==";
|
package/src/native/htmlents.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* HTML entity escaping — TypeScript boundary over the
|
|
2
|
+
* HTML entity escaping — TypeScript boundary over the Rust wasm module.
|
|
3
3
|
*
|
|
4
4
|
* One single-pass escaper for Telegram HTML parse mode, replacing the
|
|
5
5
|
* five chained regex passes it supersedes. Every outbound Telegram
|
|
6
|
-
* render flows through here (frontend/telegram/formatting.ts). The
|
|
7
|
-
* source lives in native/htmlents-
|
|
6
|
+
* render flows through here (frontend/telegram/formatting.ts). The Rust
|
|
7
|
+
* source lives in native/htmlents-wasm and exports a three-function
|
|
8
8
|
* C ABI (alloc / dealloc / escape_html) — see that module's README for
|
|
9
9
|
* the contract, and src/native/runtime.ts for the embedding and memory
|
|
10
10
|
* conventions shared by every native module.
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
type WasmCoreExports,
|
|
25
25
|
} from "./runtime.js";
|
|
26
26
|
|
|
27
|
-
/** The C-ABI surface exported by native/htmlents-
|
|
27
|
+
/** The C-ABI surface exported by native/htmlents-wasm. */
|
|
28
28
|
interface HtmlentsExports extends WasmCoreExports {
|
|
29
29
|
escape_html(inputPtr: number, len: number): number;
|
|
30
30
|
}
|
package/src/native/registry.ts
CHANGED
|
@@ -75,9 +75,9 @@ export const NATIVE_MODULES: readonly NativeModuleSpec[] = [
|
|
|
75
75
|
},
|
|
76
76
|
{
|
|
77
77
|
name: "strsim",
|
|
78
|
-
language: "
|
|
79
|
-
target: "wasm32-
|
|
80
|
-
sourceDir: "native/strsim-
|
|
78
|
+
language: "Rust",
|
|
79
|
+
target: "wasm32-unknown-unknown",
|
|
80
|
+
sourceDir: "native/strsim-wasm",
|
|
81
81
|
sizeBytes: async () =>
|
|
82
82
|
base64ByteLength(
|
|
83
83
|
(await import("./strsim-wasm-bytes.js")).STRSIM_WASM_BASE64,
|
|
@@ -90,9 +90,9 @@ export const NATIVE_MODULES: readonly NativeModuleSpec[] = [
|
|
|
90
90
|
},
|
|
91
91
|
{
|
|
92
92
|
name: "sqlguard",
|
|
93
|
-
language: "
|
|
94
|
-
target: "wasm32-
|
|
95
|
-
sourceDir: "native/sqlguard-
|
|
93
|
+
language: "Rust",
|
|
94
|
+
target: "wasm32-unknown-unknown",
|
|
95
|
+
sourceDir: "native/sqlguard-wasm",
|
|
96
96
|
sizeBytes: async () =>
|
|
97
97
|
base64ByteLength(
|
|
98
98
|
(await import("./sqlguard-wasm-bytes.js")).SQLGUARD_WASM_BASE64,
|
|
@@ -107,9 +107,9 @@ export const NATIVE_MODULES: readonly NativeModuleSpec[] = [
|
|
|
107
107
|
},
|
|
108
108
|
{
|
|
109
109
|
name: "htmlents",
|
|
110
|
-
language: "
|
|
111
|
-
target: "wasm32-
|
|
112
|
-
sourceDir: "native/htmlents-
|
|
110
|
+
language: "Rust",
|
|
111
|
+
target: "wasm32-unknown-unknown",
|
|
112
|
+
sourceDir: "native/htmlents-wasm",
|
|
113
113
|
sizeBytes: async () =>
|
|
114
114
|
base64ByteLength(
|
|
115
115
|
(await import("./htmlents-wasm-bytes.js")).HTMLENTS_WASM_BASE64,
|
package/src/native/runtime.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Embedded-wasm runtime — the one place that knows how Talon's native
|
|
3
3
|
* modules cross the JS boundary.
|
|
4
4
|
*
|
|
5
|
-
* Every wasm module in the native plane (Rust blake3,
|
|
6
|
-
*
|
|
5
|
+
* Every wasm module in the native plane (Rust blake3, strsim,
|
|
6
|
+
* sqlguard, htmlents; Zig textops) follows the same contract:
|
|
7
7
|
*
|
|
8
8
|
* - The artifact ships as base64 inside a generated `*-bytes.ts`
|
|
9
9
|
* module, so it survives `bun build --compile` single binaries —
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Generated by native/shared/build-lib.mjs — do not edit by hand.
|
|
2
|
-
// Rebuild with `npm run build:
|
|
3
|
-
// Source: native/sqlguard-
|
|
2
|
+
// Rebuild with `npm run build:wasm` after changing the source.
|
|
3
|
+
// Source: native/sqlguard-wasm (1805 bytes of wasm32-unknown-unknown).
|
|
4
4
|
|
|
5
|
-
/** native/sqlguard-
|
|
5
|
+
/** native/sqlguard-wasm artifact, base64-encoded. Decoded + instantiated lazily by src/native/sqlguard.ts. */
|
|
6
6
|
export const SQLGUARD_WASM_BASE64 =
|
|
7
|
-
"
|
|
7
|
+
"AGFzbQEAAAABJQdgAn9/AGABfwF/YAN/f38AYAN/f38Bf2ABfwBgAn9/AX9gAAADCwoAAQIDAAQABQUGBQMBABEGGQN/AUGAgMAAC38AQZCAwAALfwBBiIDAAAsHUQcGbWVtb3J5AgAFYWxsb2MAAQtfX2hlYXBfYmFzZQMBB2RlYWxsb2MABgtlc2NhcGVfbGlrZQAHCWZ0c19xdW90ZQAICl9fZGF0YV9lbmQDAgrbDApnAQJ/AkACQCABQQxqIgIQgYCAgAAiAw0AQQAhAgwBCyADQQQgAhCCgICAAEEBIQIgA0EEakEEQQEQgoCAgAAgA0EIakEEIAEQgoCAgAAgACADQQxqNgIIIAAgAzYCBAsgACACNgIAC6IBAQN/AkAgAA0AQQAPC0EAIQECQEEAKAKAgMCAACICDQBBkIDAgAAhAkEAQZCAwIAANgKAgMCAAAsCQAJAIAJBB2pBeHEiAiAAaiIAIAJJDQAgAD8AQRB0IgNNDQFBACEBIAAgA2tBEHYgAEH//wNxQQBHakAAQX9HDQELIAEPC0EAIAA2AoCAwIAAQQBBACgChIDAgABBAWo2AoSAwIAAIAILHAACQCABQQRGDQAgARCFgICAAAALIAAgAjYAAAvsAgEDfwJAAkAgAiABTw0AQQEhAwJAIAAgAmoiBC0AACIFQXdqQf8BcUEFSQ0AAkACQAJAAkACQAJAIAVBn35qDgMBAgMACyAFQSBGDQUgBUHvAUYNAyAFQcIBRw0HIAJBAWoiAiABTw0HIAAgAmotAABBoAFHDQdBAg8LIAJBAmoiAiABTw0GIAQtAAFBmgFHDQYgACACai0AAEGAAUcNBgwDCyACQQJqIgIgAU8NBQJAAkAgBC0AAUGAf2oOAgABBwtBAyEDIAAgAmosAAAiAkGLf0gNBCACQf8BcUHYfmoiAkEHSw0GQQEgAnRBgwFxRQ0GDAQLIAAgAmotAABBnwFHDQUMAgsgAkECaiICIAFPDQQgBC0AAUGAAUcNBCAAIAJqLQAAQYABRg0BDAQLIAJBAmoiAiABTw0DIAQtAAFBuwFHDQMgACACai0AAEG/AUcNAwtBAyEDCyADDwsgAiABEISAgIAAAAtBAAsJABCJgICAAAALCQAQiYCAgAAAC0AAAkAgAEUNACABRQ0AQQAoAoSAwIAAIgBFDQBBACAAQX9qIgA2AoSAwIAAIAANAEEAQZCAwIAANgKAgMCAAAsLrwIBBn8jgICAgABBEGsiAiSAgICAACAAQQEgARshA0EAIQQCQCABRQ0AIAMhACABIQUDQEECIQYCQAJAAkAgAC0AACIHQaR/ag4EAgEBAgALIAdBJUYNAQtBASEGCyAAQQFqIQAgBiAEaiEEIAVBf2oiBQ0ACwsgAkEEaiAEEICAgIAAAkACQCACKAIEQQFHDQAgAigCDCEFIAIoAgghB0EAIQADQCABRQ0CAkACQAJAAkAgAy0AACIGQaR/ag4EAQICAQALIAZBJUcNAQsgACAETw0BIAUgAGpB3AA6AAAgAEEBaiEACyAAIARPDQAgA0EBaiEDIAUgAGogBjoAACABQX9qIQEgAEEBaiEADAELCyAAIAQQhICAgAAAC0EAIQcLIAJBEGokgICAgAAgBwu3BAELfyOAgICAAEEQayICJICAgIAAIABBASABGyEDQQAhBEEAIQVBACEGAkACQAJAA0ACQCAEIgcgAUkNACACQQRqIAYQgICAgAAgAigCBEUNAyACKAIMIQggAigCCCEJQQAhCkEAIQVBACELA0AgCyIHIAFPDQUgACABIAcQg4CAgAAiBCAHaiELIAQNAAJAIApFDQAgBSAGTw0EIAggBWpBIDoAACAFQQFqIQULIAUgBk8NAyAKQQFqIQogCCAFakEiOgAAIAVBAWohBANAAkACQAJAAkACQAJAIAEgB0cNACABIQsMAQsgAyABIAcQg4CAgABFDQEgByELCyAEIAZPDQEgCCAEakEiOgAAIARBAWohBQwFCwJAIAMgB2otAAAiC0EiRg0AIAQgBk8NAUEBIQwgBCEFDAMLIAQgBkkNAQsgBCAGEISAgIAAAAtBIiELIAggBGpBIjoAAAJAIARBAWoiBSAGTw0AQQIhDAwBCyAFIAYQhICAgAAACyAIIAVqIAs6AAAgB0EBaiEHIAQgDGohBAwACwsLIAAgASAHEIOAgIAAIgsgB2ohBCALDQAgBUEBaiELIAYgBUEAR2pBAmohBgNAAkAgASAHRw0AIAEhBCALIQUMAgsCQCADIAEgBxCDgICAAEUNACAHIQQgCyEFDAILQQJBASADIAdqLQAAQSJGGyAGaiEGIAdBAWohBwwACwsLIAUgBhCEgICAAAALQQAhCQsgAkEQaiSAgICAACAJCwMAAAs=";
|
package/src/native/sqlguard.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* pattern for a LIKE search (get_user_messages), and `ftsQuote` turns
|
|
7
7
|
* free-form text into a literal FTS5 MATCH expression (search_history).
|
|
8
8
|
*
|
|
9
|
-
* The
|
|
9
|
+
* The Rust source lives in native/sqlguard-wasm and exports a four-function
|
|
10
10
|
* C ABI (alloc / dealloc / escape_like / fts_quote) — see that module's
|
|
11
11
|
* README for the contract, and src/native/runtime.ts for the embedding
|
|
12
12
|
* and memory conventions shared by every native module.
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
} from "./runtime.js";
|
|
28
28
|
import { SQLGUARD_WASM_BASE64 } from "./sqlguard-wasm-bytes.js";
|
|
29
29
|
|
|
30
|
-
/** The C-ABI surface exported by native/sqlguard-
|
|
30
|
+
/** The C-ABI surface exported by native/sqlguard-wasm. */
|
|
31
31
|
interface SqlguardExports extends WasmCoreExports {
|
|
32
32
|
escape_like(inputPtr: number, len: number): number;
|
|
33
33
|
fts_quote(inputPtr: number, len: number): number;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Generated by native/shared/build-lib.mjs — do not edit by hand.
|
|
2
|
-
// Rebuild with `npm run build:
|
|
3
|
-
// Source: native/strsim-
|
|
2
|
+
// Rebuild with `npm run build:wasm` after changing the source.
|
|
3
|
+
// Source: native/strsim-wasm (725 bytes of wasm32-unknown-unknown).
|
|
4
4
|
|
|
5
|
-
/** native/strsim-
|
|
5
|
+
/** native/strsim-wasm artifact, base64-encoded. Decoded + instantiated lazily by src/native/strsim.ts. */
|
|
6
6
|
export const STRSIM_WASM_BASE64 =
|
|
7
|
-
"
|
|
7
|
+
"AGFzbQEAAAABFgRgAX8Bf2ACf38AYAR/f39/AX9gAAADBgUAAQIBAwUDAQARBhkDfwFBgIDAAAt/AEGQoMAAC38AQYygwAALB0UGBm1lbW9yeQIABWFsbG9jAAALX19oZWFwX2Jhc2UDAQdkZWFsbG9jAAELbGV2ZW5zaHRlaW4AAgpfX2RhdGFfZW5kAwIKwwQFogEBA38CQCAADQBBAA8LQQAhAQJAQQAoAoSgwIAAIgINAEGQoMCAACECQQBBkKDAgAA2AoSgwIAACwJAAkAgAkEHakF4cSICIABqIgAgAkkNACAAPwBBEHQiA00NAUEAIQEgACADa0EQdiAAQf//A3FBAEdqQABBf0cNAQsgAQ8LQQAgADYChKDAgABBAEEAKAKIoMCAAEEBajYCiKDAgAAgAgtAAAJAIABFDQAgAUUNAEEAKAKIoMCAACIARQ0AQQAgAEF/aiIANgKIoMCAACAADQBBAEGQoMCAADYChKDAgAALC80CAQl/QX8hBAJAIAFBgAhLDQAgA0GACEsNAAJAIAENACADDwsCQCADDQAgAQ8LIANBAWohBUEAIQRBgIDAgAAhBgJAA0AgBSAERg0BIAYgBDYCACAGQQRqIQYgBEEBaiEEDAALCyAAIAFqIQdBACEIQQAoAoCAwIAAIQUCQANAIAAgB0YNASAALQAAIQFBACEEQQAgCEEBaiIINgKAgMCAACAAQQFqIQBBhIDAgAAhBiABQf8BcSEJIAghAQNAIAIgBGohCgJAIAMgBEcNACAIIQUMAgsgBEEBaiELAkAgBEGACEcNACALQYEIEIOAgIAAAAsgBiABQQFqIgQgBigCACIMQQFqIgEgBSAJIAotAABHaiIFIAEgBUkbIgUgBCAFSRsiATYCACAGQQRqIQYgCyEEIAwhBQwACwsLIANBAnQoAoCAwIAAIQQLIAQLCQAQhICAgAAACwMAAAs=";
|
package/src/native/strsim.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* String similarity — TypeScript boundary over the C wasm module.
|
|
3
3
|
*
|
|
4
4
|
* Powers "did you mean ...?" suggestions for unknown Telegram slash
|
|
5
|
-
* commands and unknown CLI subcommands. The
|
|
6
|
-
* native/strsim-
|
|
5
|
+
* commands and unknown CLI subcommands. The Rust source lives in
|
|
6
|
+
* native/strsim-wasm and exports a three-function C ABI (alloc / dealloc /
|
|
7
7
|
* levenshtein) — see that module's README for the contract, and
|
|
8
8
|
* src/native/runtime.ts for the embedding and memory conventions
|
|
9
9
|
* shared by every native module.
|
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
/** Returned by the wasm side when either input exceeds its DP buffer. */
|
|
30
30
|
const OVERFLOW = 0xffffffff;
|
|
31
31
|
|
|
32
|
-
/** The C-ABI surface exported by native/strsim-
|
|
32
|
+
/** The C-ABI surface exported by native/strsim-wasm. */
|
|
33
33
|
interface StrsimExports extends WasmCoreExports {
|
|
34
34
|
levenshtein(aPtr: number, aLen: number, bPtr: number, bLen: number): number;
|
|
35
35
|
}
|
package/src/storage/history.ts
CHANGED
|
@@ -155,7 +155,7 @@ export function getRecentFormatted(chatId: string, limit = 20): string {
|
|
|
155
155
|
* Build an FTS5 MATCH expression from free-form user input. Every
|
|
156
156
|
* token is double-quoted so FTS operators (AND, NEAR, *, ^) in user
|
|
157
157
|
* text are treated as literals, not syntax. Delegates to the C core
|
|
158
|
-
* (native/sqlguard-
|
|
158
|
+
* (native/sqlguard-wasm) for byte-identical output.
|
|
159
159
|
*/
|
|
160
160
|
function ftsQuery(query: string): string {
|
|
161
161
|
return ftsQuote(query);
|
package/src/util/config.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, mkdirSync } from "node:fs";
|
|
|
2
2
|
import writeFileAtomic from "write-file-atomic";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { dirs, files as pathFiles } from "./paths.js";
|
|
5
|
+
import { hardenTalonPermissions } from "./harden.js";
|
|
5
6
|
import { setTimezone } from "./time.js";
|
|
6
7
|
import { BACKEND_IDS } from "../core/agent-runtime/model-ref.js";
|
|
7
8
|
import {
|
|
@@ -137,6 +138,8 @@ const frontendEnum = z.enum([
|
|
|
137
138
|
* Defaults are loopback-only and unauthenticated (single-machine use). To
|
|
138
139
|
* reach Talon remotely, set `host: "0.0.0.0"` and a `token` — the bridge
|
|
139
140
|
* then requires `Authorization: Bearer <token>` (or `?token=` for SSE).
|
|
141
|
+
* A non-loopback bind with no token auto-mints a persistent one
|
|
142
|
+
* (~/.talon/keys/bridge-token) rather than serving the LAN open.
|
|
140
143
|
*/
|
|
141
144
|
const nativeConfigSchema = z
|
|
142
145
|
.object({
|
|
@@ -150,8 +153,9 @@ const nativeConfigSchema = z
|
|
|
150
153
|
host: z.string().default("127.0.0.1"),
|
|
151
154
|
/**
|
|
152
155
|
* Optional shared secret. When set, every request must present it as a
|
|
153
|
-
* bearer token (header) or `?token=` query param (SSE).
|
|
154
|
-
*
|
|
156
|
+
* bearer token (header) or `?token=` query param (SSE). When unset on a
|
|
157
|
+
* non-loopback `host`, the bridge mints and persists one automatically
|
|
158
|
+
* (~/.talon/keys/bridge-token) — the network never gets an open bridge.
|
|
155
159
|
*/
|
|
156
160
|
token: z.string().optional(),
|
|
157
161
|
/**
|
|
@@ -596,9 +600,11 @@ function ensureConfigFile(): boolean {
|
|
|
596
600
|
if (!existsSync(dirs.root)) mkdirSync(dirs.root, { recursive: true });
|
|
597
601
|
if (!existsSync(dirs.data)) mkdirSync(dirs.data, { recursive: true });
|
|
598
602
|
if (!existsSync(CONFIG_FILE)) {
|
|
603
|
+
// Owner-only from birth: the config accumulates bot tokens and API keys.
|
|
599
604
|
writeFileAtomic.sync(
|
|
600
605
|
CONFIG_FILE,
|
|
601
606
|
JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n",
|
|
607
|
+
{ mode: 0o600 },
|
|
602
608
|
);
|
|
603
609
|
return true;
|
|
604
610
|
}
|
|
@@ -613,6 +619,7 @@ function ensureConfigFile(): boolean {
|
|
|
613
619
|
|
|
614
620
|
export function loadConfig(): TalonConfig {
|
|
615
621
|
ensureConfigFile();
|
|
622
|
+
hardenTalonPermissions();
|
|
616
623
|
const fileConfig = normalizeDeprecatedFrontendConfig(loadConfigFile());
|
|
617
624
|
|
|
618
625
|
// Runtime frontend override (TALON_FRONTEND_OVERRIDE). Lets the native
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* At-rest permission hardening for ~/.talon/.
|
|
3
|
+
*
|
|
4
|
+
* The tree holds credentials (config.json carries bot tokens, API keys and
|
|
5
|
+
* the bridge token; .user-session is a full Telegram login) plus chat
|
|
6
|
+
* history and logs. Node creates most of it with umask defaults, so on a
|
|
7
|
+
* multi-user machine everything is group/world readable. This pass clamps
|
|
8
|
+
* the sensitive surface to owner-only on every boot — idempotent, cheap,
|
|
9
|
+
* and it repairs installs created before the tightened modes existed.
|
|
10
|
+
*
|
|
11
|
+
* Best-effort by design: a missing path just hasn't been created yet, and
|
|
12
|
+
* a chmod failure (read-only mount, foreign owner) must never block boot.
|
|
13
|
+
* Windows has no POSIX modes worth mapping, so the pass is a no-op there.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { chmodSync } from "node:fs";
|
|
17
|
+
import { dirs, files } from "./paths.js";
|
|
18
|
+
|
|
19
|
+
const OWNER_DIR = 0o700;
|
|
20
|
+
const OWNER_FILE = 0o600;
|
|
21
|
+
|
|
22
|
+
/** Owner-only directories: everything under them inherits the protection. */
|
|
23
|
+
const HARDENED_DIRS: readonly string[] = [dirs.root, dirs.data, dirs.keys];
|
|
24
|
+
|
|
25
|
+
/** Belt-and-braces owner-only files, should they ever move out of the tree. */
|
|
26
|
+
const HARDENED_FILES: readonly string[] = [
|
|
27
|
+
files.config,
|
|
28
|
+
files.log,
|
|
29
|
+
files.database,
|
|
30
|
+
files.userSession,
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/** Clamp permissions on the sensitive parts of ~/.talon/. */
|
|
34
|
+
export function hardenTalonPermissions(): void {
|
|
35
|
+
if (process.platform === "win32") return;
|
|
36
|
+
for (const dir of HARDENED_DIRS) {
|
|
37
|
+
try {
|
|
38
|
+
chmodSync(dir, OWNER_DIR);
|
|
39
|
+
} catch {
|
|
40
|
+
// absent or not ours — nothing to protect yet
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
for (const file of HARDENED_FILES) {
|
|
44
|
+
try {
|
|
45
|
+
chmodSync(file, OWNER_FILE);
|
|
46
|
+
} catch {
|
|
47
|
+
// absent or not ours — nothing to protect yet
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/util/log.ts
CHANGED
|
@@ -137,7 +137,8 @@ if (!quiet) {
|
|
|
137
137
|
if (!IS_VITEST) {
|
|
138
138
|
streams.push({
|
|
139
139
|
level: "trace",
|
|
140
|
-
|
|
140
|
+
// 0600: turns and tool output land here — same sensitivity as history.
|
|
141
|
+
stream: createWriteStream(LOG_FILE, { flags: "a", mode: 0o600 }),
|
|
141
142
|
});
|
|
142
143
|
}
|
|
143
144
|
|