privateer-agent 0.3.6 → 0.4.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.
Files changed (41) hide show
  1. package/bin/privateer-daemon.mjs +30 -0
  2. package/bin/privateer-subagent.mjs +68 -0
  3. package/bin/privateer-tui +19 -0
  4. package/extensions/privateer-brand.ts +61 -20
  5. package/extensions/privateer-gate.ts +290 -3
  6. package/package.json +4 -1
  7. package/src/auth/privateer.ts +45 -6
  8. package/src/channels/bridge.ts +293 -0
  9. package/src/channels/discord.ts +210 -0
  10. package/src/channels/run.ts +383 -0
  11. package/src/channels/slack.ts +176 -0
  12. package/src/channels/status.ts +54 -0
  13. package/src/channels/telegram.ts +139 -0
  14. package/src/channels/types.ts +36 -0
  15. package/src/channels/whatsapp.ts +178 -0
  16. package/src/cli/chat.ts +389 -30
  17. package/src/cli/daemonCli.ts +67 -0
  18. package/src/crypto/accountTrust.ts +113 -0
  19. package/src/crypto/accountVerify.ts +138 -0
  20. package/src/crypto/terminalKey.ts +95 -0
  21. package/src/crypto/terminalUnseal.ts +62 -0
  22. package/src/daemon/index.ts +511 -46
  23. package/src/daemon/service.ts +232 -0
  24. package/src/ext/permissionGate.ts +38 -0
  25. package/src/permissions/classify.ts +49 -5
  26. package/src/remote/channelsControl.ts +192 -0
  27. package/src/remote/controlAuth.ts +67 -0
  28. package/src/remote/extensionsControl.ts +140 -0
  29. package/src/remote/liveTaskSession.ts +218 -0
  30. package/src/remote/relayClient.ts +512 -1
  31. package/src/remote/remoteBridge.ts +172 -0
  32. package/src/remote/routinesControl.ts +216 -0
  33. package/src/remote/skillsControl.ts +205 -0
  34. package/src/remote/subagentChannel.ts +261 -0
  35. package/src/remote/subagentRelay.ts +126 -0
  36. package/src/remote/workflowsControl.ts +132 -0
  37. package/src/routines/store.ts +5 -1
  38. package/src/workflows/expr.ts +4 -0
  39. package/src/workflows/runner.ts +8 -0
  40. package/src/workflows/schema.ts +5 -0
  41. package/src/workflows/store.ts +108 -0
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Pinned account signing key — TERMINAL side (Phase 4 TOFU, mirror of the app's
3
+ * terminalTrustService).
4
+ *
5
+ * At link time the app hands this terminal the account's Ed25519 signing public key
6
+ * (bound into the device-code grant, delivered in the /auth/device/token response). We
7
+ * pin it here. Every app channel-save is then verified against this key
8
+ * (accountVerify.ts): only the account master-key holder can sign, so a hostile relay
9
+ * can neither forge a channel config nor inject an admin.
10
+ *
11
+ * The pinned value is a PUBLIC key, but its INTEGRITY is the security property — a
12
+ * local attacker who could swap it would defeat verification — so it's written 0600
13
+ * beside the other machine trust roots (terminal-key.json, config.json). A server
14
+ * malicious at the single link moment could substitute it (the accepted TOFU limit,
15
+ * symmetric with the app-side pin); a server that turns malicious later cannot.
16
+ */
17
+
18
+ import { readFileSync, writeFileSync, chmodSync, rmSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { globalDir } from "../config/paths.ts";
21
+
22
+ interface AccountTrustFile {
23
+ v: 1;
24
+ accountSignPub: string; // base64 Ed25519 public key
25
+ }
26
+
27
+ function trustPath(): string {
28
+ return join(globalDir(), "account-trust.json");
29
+ }
30
+
31
+ /** Pin the account signing public key (idempotent). A blank/absent value is ignored
32
+ * (an older app or a locked vault simply doesn't establish the pin). */
33
+ export function pinAccountSignKey(pub: string | undefined | null): void {
34
+ const value = (pub ?? "").trim();
35
+ if (!value) return;
36
+ try {
37
+ const file: AccountTrustFile = { v: 1, accountSignPub: value };
38
+ writeFileSync(trustPath(), JSON.stringify(file), { mode: 0o600 });
39
+ try { chmodSync(trustPath(), 0o600); } catch { /* non-POSIX FS */ }
40
+ } catch {
41
+ /* best effort — a missing pin just means channel-saves fail-closed until re-link */
42
+ }
43
+ }
44
+
45
+ /** The pinned account signing public key (base64), or undefined if none is pinned. */
46
+ export function loadAccountSignKey(): string | undefined {
47
+ try {
48
+ const parsed = JSON.parse(readFileSync(trustPath(), "utf8")) as AccountTrustFile;
49
+ return parsed?.v === 1 && typeof parsed.accountSignPub === "string" ? parsed.accountSignPub : undefined;
50
+ } catch {
51
+ return undefined;
52
+ }
53
+ }
54
+
55
+ /** Drop the pin — called when local credentials are cleared (logout / session revoke),
56
+ * since it belongs to the signed-in account.
57
+ *
58
+ * We deliberately do NOT clear the anti-replay watermark (control-sig.json) here.
59
+ * Resetting it to 0 on logout would let a hostile relay replay a previously-captured,
60
+ * validly-signed channel-save after a re-link (its ts > 0 ≥ 0), rolling config back to
61
+ * an earlier account-authored state — e.g. re-adding a removed admin (M1). The
62
+ * watermark is safe to persist across a re-link: it's monotonic wall-clock ms, and a
63
+ * DIFFERENT account that links later gets a different signing key, so its saves are
64
+ * gated by signature (not by ts) and its own ts values only ever move forward. */
65
+ export function clearAccountSignKey(): void {
66
+ try { rmSync(trustPath(), { force: true }); } catch { /* nothing to remove */ }
67
+ }
68
+
69
+ // ── Anti-replay watermark for signed control frames (per terminal) ───────────────
70
+ // The highest `ts` we've applied, keyed by termId. A signed control frame (channel
71
+ // save, routine save/run, extension add, skill create, …) whose ts is BELOW the
72
+ // watermark for its terminal is a replay/rollback of an older signed envelope and is
73
+ // refused; at-or-above is accepted (an idempotent replay of the latest is harmless).
74
+ //
75
+ // Keyed by termId — NOT global — so the always-on daemon (stable routines-… id) and
76
+ // each interactive terminal (its own id) don't cross-reject each other's frames when
77
+ // the app drives them near-simultaneously with independent ts streams. Each signed
78
+ // frame binds its termId (see accountVerify.controlMessage), so a per-terminal
79
+ // watermark is the matching granularity. Persisted so it survives a daemon restart;
80
+ // deliberately NOT cleared on logout (see clearAccountSignKey — M1).
81
+ function tsPath(): string {
82
+ return join(globalDir(), "control-sig.json");
83
+ }
84
+
85
+ interface ControlSigFile {
86
+ v: 1;
87
+ byTerm: Record<string, number>;
88
+ }
89
+
90
+ function loadControlSig(): ControlSigFile {
91
+ try {
92
+ const parsed = JSON.parse(readFileSync(tsPath(), "utf8")) as ControlSigFile;
93
+ if (parsed?.v === 1 && parsed.byTerm && typeof parsed.byTerm === "object") return parsed;
94
+ } catch {
95
+ /* missing/malformed → fresh */
96
+ }
97
+ return { v: 1, byTerm: {} };
98
+ }
99
+
100
+ export function loadLastControlTs(termId: string): number {
101
+ const ts = loadControlSig().byTerm[termId];
102
+ return typeof ts === "number" ? ts : 0;
103
+ }
104
+
105
+ export function saveLastControlTs(termId: string, ts: number): void {
106
+ try {
107
+ const file = loadControlSig();
108
+ file.byTerm[termId] = Math.max(file.byTerm[termId] ?? 0, ts);
109
+ writeFileSync(tsPath(), JSON.stringify(file), { mode: 0o600 });
110
+ } catch {
111
+ /* best effort */
112
+ }
113
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Account signature verification — TERMINAL side.
3
+ *
4
+ * Phase 4 closes the authenticity gap in app→terminal channel config (review finding
5
+ * F7/F8): a sealed box gives confidentiality but NOT sender authenticity, so a hostile
6
+ * relay/server — which knows this terminal's public key — could forge a channel-save
7
+ * (attacker's token + injected admins). To stop that, the app SIGNS every channel-save
8
+ * with an Ed25519 key derived from the account master key, and the terminal verifies
9
+ * that signature against the account's signing public key it PINNED at link time
10
+ * (accountTrust.ts). Only the master-key holder can produce a valid signature, so the
11
+ * server can neither forge config nor alter the admin list undetected.
12
+ *
13
+ * The canonical message construction MUST stay byte-for-byte in sync with the signer:
14
+ * treeview/client/services/accountSign.ts
15
+ * Domain prefix "privateer-channel-cfg-v1" + canonical JSON (recursively key-sorted).
16
+ */
17
+
18
+ import { ed25519 } from "@noble/curves/ed25519";
19
+
20
+ const enc = new TextEncoder();
21
+ const DOMAIN = "privateer-channel-cfg-v1";
22
+
23
+ // Deterministic JSON: object keys sorted recursively, arrays kept in order. MUST match
24
+ // treeview/client/services/accountSign.ts canonicalize() exactly.
25
+ function canonicalize(value: unknown): string {
26
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
27
+ if (Array.isArray(value)) return "[" + value.map(canonicalize).join(",") + "]";
28
+ const obj = value as Record<string, unknown>;
29
+ const keys = Object.keys(obj).sort();
30
+ return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalize(obj[k])).join(",") + "}";
31
+ }
32
+
33
+ // The signed envelope. `termId` binds the intended recipient terminal (so a signature
34
+ // for terminal X can't be replayed against Y — verification uses OUR termId). `ts`
35
+ // binds freshness (the daemon rejects a ts it has already applied → no replay/rollback).
36
+ export interface ChannelSaveEnvelope {
37
+ termId: string;
38
+ ts: number;
39
+ draft: Record<string, unknown>;
40
+ sealedSecrets?: string;
41
+ }
42
+
43
+ export function channelSaveMessage(env: ChannelSaveEnvelope): Uint8Array {
44
+ return enc.encode(
45
+ DOMAIN +
46
+ canonicalize({
47
+ termId: env.termId,
48
+ ts: env.ts,
49
+ draft: env.draft,
50
+ sealedSecrets: env.sealedSecrets ?? null,
51
+ }),
52
+ );
53
+ }
54
+
55
+ /** Verify an Ed25519 signature (base64) over the canonical envelope against the pinned
56
+ * account signing public key (base64). Returns false on ANY malformation — the caller
57
+ * fail-closes (rejects the save) on false. */
58
+ export function verifyChannelSave(accountSignPubB64: string, env: ChannelSaveEnvelope, sigB64: string): boolean {
59
+ try {
60
+ const pub = new Uint8Array(Buffer.from(accountSignPubB64, "base64"));
61
+ const sig = new Uint8Array(Buffer.from(sigB64, "base64"));
62
+ if (pub.length !== 32) return false;
63
+ return ed25519.verify(sig, channelSaveMessage(env), pub);
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ // The outbox recipient key (X25519) is fetched live from the UNTRUSTED server, so a
70
+ // malicious server could otherwise substitute a key it controls and read every sealed
71
+ // result (the terminal holds no master key and can't derive the real one itself). To
72
+ // stop that, the app signs the published outbox public key with the same account
73
+ // Ed25519 key the terminal already pinned at link, and the terminal verifies that
74
+ // signature here before sealing to the key. A server that turns malicious AFTER link
75
+ // can no longer swap the key — it can't forge this signature. Residual: the F1
76
+ // link-moment window, identical to the channel-config path.
77
+ //
78
+ // Message construction MUST stay byte-for-byte in sync with the signer:
79
+ // treeview/client/services/accountSign.ts signOutboxKey()
80
+ const OUTBOX_KEY_DOMAIN = "privateer-outbox-key-v1";
81
+
82
+ /** Verify the account's Ed25519 signature (base64) over the base64 outbox public key
83
+ * against the pinned account signing public key (base64). Returns false on ANY
84
+ * malformation — the caller fail-closes (refuses to seal) on false. */
85
+ export function verifyOutboxKey(accountSignPubB64: string, outboxPubB64: string, sigB64: string): boolean {
86
+ try {
87
+ const pub = new Uint8Array(Buffer.from(accountSignPubB64, "base64"));
88
+ const sig = new Uint8Array(Buffer.from(sigB64, "base64"));
89
+ if (pub.length !== 32) return false;
90
+ return ed25519.verify(sig, enc.encode(OUTBOX_KEY_DOMAIN + outboxPubB64), pub);
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+
96
+ // ── Generic signed control frames (H2) ──────────────────────────────────────────
97
+ // channels_save is signed (F7/F8), but every OTHER app→terminal mutation
98
+ // (routines_*, extensions_*, skills_*, channels_remove) was sent over the untrusted
99
+ // relay UNSIGNED — so a malicious server could forge them, and several have severe
100
+ // local side effects (a forged routine runs a headless bypass-mode session → RCE; a
101
+ // forged extensions_add installs an npm package → RCE; a forged skills_create injects
102
+ // an auto-invoked system-prompt skill). This closes that: the app signs every mutating
103
+ // control frame with the account key the terminal pinned at link, and the terminal
104
+ // verifies it here (fail-closed) before acting. `termId` binds the recipient (a
105
+ // signature for terminal X won't verify against Y) and `ts` binds freshness (the caller
106
+ // rejects a ts at/below the last it applied → no replay).
107
+ //
108
+ // The canonical message construction MUST stay byte-for-byte in sync with the signer:
109
+ // treeview/client/services/accountSign.ts signControl()
110
+ const CONTROL_DOMAIN = "privateer-control-v1";
111
+
112
+ export interface ControlEnvelope {
113
+ termId: string;
114
+ ts: number;
115
+ action: string; // the frame type, e.g. "routines_save", "extensions_add"
116
+ args: Record<string, unknown>; // the operation's parameters
117
+ }
118
+
119
+ export function controlMessage(env: ControlEnvelope): Uint8Array {
120
+ return enc.encode(
121
+ CONTROL_DOMAIN +
122
+ canonicalize({ action: env.action, args: env.args, termId: env.termId, ts: env.ts }),
123
+ );
124
+ }
125
+
126
+ /** Verify an Ed25519 signature (base64) over a control envelope against the pinned
127
+ * account signing public key (base64). Returns false on ANY malformation — the caller
128
+ * fail-closes (refuses the mutation) on false. */
129
+ export function verifyControl(accountSignPubB64: string, env: ControlEnvelope, sigB64: string): boolean {
130
+ try {
131
+ const pub = new Uint8Array(Buffer.from(accountSignPubB64, "base64"));
132
+ const sig = new Uint8Array(Buffer.from(sigB64, "base64"));
133
+ if (pub.length !== 32) return false;
134
+ return ed25519.verify(sig, controlMessage(env), pub);
135
+ } catch {
136
+ return false;
137
+ }
138
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Terminal identity keypair — the RECIPIENT side of app→terminal sealing.
3
+ *
4
+ * The outbox (crypto/outboxSeal.ts) is one-directional: a terminal seals results TO
5
+ * the account and holds no openable key. This is the mirror: a persistent X25519
6
+ * keypair whose PUBLIC half the app pins at link time (device-code approval) and
7
+ * whose PRIVATE half never leaves this machine — so the app can seal secrets (Phase
8
+ * 3: channel bot tokens) that ONLY this terminal can open, with the server unable to
9
+ * read them even though it forwards the ciphertext.
10
+ *
11
+ * Trust model (TOFU, SSH known_hosts style): the pubkey is delivered to the app once,
12
+ * bound into the device-authorization grant, and pinned on approval. A later key swap
13
+ * over the relay is rejected because it isn't in the pinned set. This does NOT defend
14
+ * against a malicious server at the single link moment — that narrow window is the
15
+ * accepted TOFU limitation (fingerprint verification is the future hardening).
16
+ *
17
+ * Construction matches outboxSeal.ts so Phase 3's open() is the exact inverse of the
18
+ * app's seal(): X25519 → HKDF-SHA256 → AES-256-GCM.
19
+ */
20
+
21
+ import { readFileSync, writeFileSync, chmodSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { x25519 } from "@noble/curves/ed25519";
24
+ import { globalDir } from "../config/paths.ts";
25
+
26
+ interface TerminalKeyFile {
27
+ v: 1;
28
+ publicKey: string; // base64, 32 raw bytes
29
+ secretKey: string; // base64, 32 raw bytes — never leaves this machine
30
+ }
31
+
32
+ function keyPath(): string {
33
+ return join(globalDir(), "terminal-key.json");
34
+ }
35
+
36
+ // Cache the loaded/created keypair for the process lifetime so we don't re-read the
37
+ // file (or, worse, regenerate) on every device-code request.
38
+ let cached: { publicKey: Uint8Array; secretKey: Uint8Array } | undefined;
39
+
40
+ function b64(bytes: Uint8Array): string {
41
+ return Buffer.from(bytes).toString("base64");
42
+ }
43
+
44
+ function fromB64(s: string, label: string): Uint8Array {
45
+ const buf = Buffer.from(s, "base64");
46
+ if (buf.length !== 32) throw new Error(`${label} must be 32 bytes`);
47
+ return new Uint8Array(buf);
48
+ }
49
+
50
+ // Load the persisted keypair, or mint + persist a fresh one on first use. The file is
51
+ // written 0600 (owner-only) — this machine's private key protects every future sealed
52
+ // message to this terminal, so it's as sensitive as the config.json tokens beside it.
53
+ function loadOrCreate(): { publicKey: Uint8Array; secretKey: Uint8Array } {
54
+ if (cached) return cached;
55
+ try {
56
+ const parsed = JSON.parse(readFileSync(keyPath(), "utf8")) as TerminalKeyFile;
57
+ if (parsed?.v === 1 && parsed.publicKey && parsed.secretKey) {
58
+ cached = {
59
+ publicKey: fromB64(parsed.publicKey, "terminal public key"),
60
+ secretKey: fromB64(parsed.secretKey, "terminal secret key"),
61
+ };
62
+ return cached;
63
+ }
64
+ } catch {
65
+ /* missing or malformed → mint a fresh keypair below */
66
+ }
67
+ const secretKey = x25519.utils.randomPrivateKey();
68
+ const publicKey = x25519.getPublicKey(secretKey);
69
+ const file: TerminalKeyFile = { v: 1, publicKey: b64(publicKey), secretKey: b64(secretKey) };
70
+ // Create 0600 from the start — passing `mode` to writeFileSync avoids the TOCTOU
71
+ // window where a fresh file briefly carries umask perms (group/world-readable)
72
+ // before a follow-up chmod. `mode` only applies on CREATE, so also chmod to fix an
73
+ // OVERWRITTEN pre-existing (malformed) file, whose perms writeFileSync leaves as-is.
74
+ writeFileSync(keyPath(), JSON.stringify(file), { mode: 0o600 });
75
+ try {
76
+ chmodSync(keyPath(), 0o600);
77
+ } catch {
78
+ /* best effort — e.g. non-POSIX FS */
79
+ }
80
+ cached = { publicKey, secretKey };
81
+ return cached;
82
+ }
83
+
84
+ /** This terminal's public key, base64 (32 raw bytes). Sent in the device-code grant
85
+ * so the app can pin it; safe to expose (it's public). Mints the keypair on first
86
+ * call. */
87
+ export function terminalPublicKeyBase64(): string {
88
+ return b64(loadOrCreate().publicKey);
89
+ }
90
+
91
+ /** This terminal's private key (raw 32 bytes) — for Phase 3's unseal(). Never send,
92
+ * log, or persist anywhere but the 0600 key file. */
93
+ export function terminalSecretKey(): Uint8Array {
94
+ return loadOrCreate().secretKey;
95
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * App→terminal sealed-box crypto — RECIPIENT (terminal) side.
3
+ *
4
+ * The inverse of the outbox: here the APP is the sender and this terminal is the
5
+ * recipient. The app seals a secret (a channel bot token) to this terminal's pinned
6
+ * public key (see terminalKey.ts); only this terminal — holder of the matching
7
+ * private key — can open it. The server forwards the ciphertext over the relay and
8
+ * cannot read it, which is the whole point of Phase 3.
9
+ *
10
+ * Construction MUST stay byte-for-byte in sync with the sender:
11
+ * treeview/client/services/terminalSeal.ts
12
+ * X25519 → HKDF-SHA256 → AES-256-GCM. Wire: epk(32) ‖ iv(12) ‖ ct‖tag, base64.
13
+ * salt = epk ‖ recipientPub ; HKDF info = "privateer-channel-seal-v1".
14
+ *
15
+ * Domain separation: the "channel-seal" HKDF label is DISTINCT from the outbox's
16
+ * "privateer-outbox-seal-v1", so a blob from one protocol can never be opened as the
17
+ * other even though both are X25519 sealed boxes to the same curve.
18
+ */
19
+
20
+ import { x25519 } from "@noble/curves/ed25519";
21
+ import { hkdf } from "@noble/hashes/hkdf";
22
+ import { sha256 } from "@noble/hashes/sha256";
23
+ import { gcm } from "@noble/ciphers/aes.js";
24
+ import { terminalSecretKey } from "./terminalKey.ts";
25
+
26
+ const enc = new TextEncoder();
27
+ const dec = new TextDecoder();
28
+
29
+ // MUST match treeview/client/services/terminalSeal.ts.
30
+ const KDF_SEAL = enc.encode("privateer-channel-seal-v1");
31
+
32
+ function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
33
+ const out = new Uint8Array(a.length + b.length);
34
+ out.set(a, 0);
35
+ out.set(b, a.length);
36
+ return out;
37
+ }
38
+
39
+ /**
40
+ * Open a base64 wire string the app sealed to THIS terminal's public key. Returns the
41
+ * plaintext bytes; throws on tamper, a wrong recipient, or a malformed blob (the GCM
42
+ * tag check is the integrity guarantee). Uses this terminal's private key — which
43
+ * never leaves the 0600 key file.
44
+ */
45
+ export function openFromApp(wireB64: string): Uint8Array {
46
+ const sk = terminalSecretKey();
47
+ const recipientPub = x25519.getPublicKey(sk);
48
+ const wire = new Uint8Array(Buffer.from(wireB64, "base64"));
49
+ if (wire.length < 32 + 12 + 16) throw new Error("sealed blob too short");
50
+ const epk = wire.subarray(0, 32);
51
+ const iv = wire.subarray(32, 44);
52
+ const ct = wire.subarray(44);
53
+ const shared = x25519.getSharedSecret(sk, epk);
54
+ const salt = concat(epk, recipientPub); // binds ephemeral + this recipient
55
+ const key = hkdf(sha256, shared, salt, KDF_SEAL, 32);
56
+ return gcm(key, iv).decrypt(ct); // throws if the tag doesn't verify
57
+ }
58
+
59
+ /** Open + JSON-parse. Throws on tamper/wrong-key/invalid JSON. */
60
+ export function openJsonFromApp<T = unknown>(wireB64: string): T {
61
+ return JSON.parse(dec.decode(openFromApp(wireB64))) as T;
62
+ }