shadok-ai 0.15.56 → 0.15.58

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
@@ -906,7 +906,12 @@ mode a neutral near-black.
906
906
  Light/dark **mode** is an orthogonal, **per-browser** choice in the same menu
907
907
  (default dark; stored in `localStorage`, applied before first paint) and needs no
908
908
  endpoint — the accent is the instance's shared identity, the mode is personal),
909
- `/profiles`, `/secrets`, `/telegram`, `/defaults`, `/first-agent` (GET —
909
+ `/profiles`, `/secrets`, `/secrets/audit` (GET, **admin only** — the secret
910
+ audit log: who created/updated/deleted a secret, which profile it was attached
911
+ to, and which agent **received** it at spawn or in a cron guard, with the person
912
+ or door that caused it; names only, never a value; filters `secret`, `action`,
913
+ `actor`, `agent`, `since`, `limit`. Also in the vault panel → *Audit log*, and
914
+ 🕘 on each secret), `/telegram`, `/defaults`, `/first-agent` (GET —
910
915
  `{pending, reason}`: whether this instance's lead agent is being started
911
916
  right now), `/version`, `/autoupdate`,
912
917
  `/update-channel` (POST `{channel}` — `alpha` or `beta`), `/permission-mode`, `/tweak/prepare` (POST — clone/refresh shadok-ai's own source,
@@ -0,0 +1,80 @@
1
+ export type SecretAuditAction = "created" | "updated" | "deleted" | "attached" | "detached" | "injected" | "cron-check";
2
+ export declare const SECRET_AUDIT_ACTIONS: SecretAuditAction[];
3
+ /** Through which door the action came. */
4
+ export type SecretAuditVia = "web" | "agent" | "telegram" | "cron" | "peer" | "api" | "system";
5
+ export interface SecretAuditRow {
6
+ at: number;
7
+ action: SecretAuditAction;
8
+ /** Vault NAMES concerned — never values. */
9
+ secrets: string[];
10
+ /** Who did it: an account name, `agent <id8>`, a Telegram sender, `cron <id8>`… */
11
+ actor: string;
12
+ via: SecretAuditVia;
13
+ /** The agent concerned (the one that received it, or the one that acted). */
14
+ agent?: string;
15
+ agentName?: string;
16
+ profile?: string;
17
+ /** Launch dir of the instance that wrote the row. */
18
+ instance: string;
19
+ detail?: string;
20
+ }
21
+ /** Rotate past this size, keeping exactly one previous file. */
22
+ export declare const AUDIT_ROTATE_BYTES: number;
23
+ /** Build a row, or null when there is nothing to record (no secret named). An
24
+ * agent spawned with no secret received none, and a row saying so is noise. */
25
+ export declare function auditRow(r: Omit<SecretAuditRow, "at" | "secrets"> & {
26
+ secrets: (string | undefined | null)[];
27
+ }, now: number): SecretAuditRow | null;
28
+ /**
29
+ * Who started (or restarted) an agent, from what the WebSocket connection
30
+ * knows. The ORDER is the point: the most specific principal wins. A peer
31
+ * names a remote instance; a parent names the agent that delegated (pilotctl
32
+ * connects as the bootstrap admin, so the account alone would credit a person
33
+ * who did nothing); a cron or Telegram origin names a door with no account of
34
+ * its own. Only a plain web client is credited to its signed-in account.
35
+ */
36
+ export declare function spawnActor(c: {
37
+ account?: string | null;
38
+ origin?: string | null;
39
+ peer?: string | null;
40
+ parent?: string | null;
41
+ from?: string | null;
42
+ }): {
43
+ actor: string;
44
+ via: SecretAuditVia;
45
+ };
46
+ /** Who made an HTTP call: an agent by its session key beats the account it
47
+ * authenticates as (which is the bootstrap admin for every agent). */
48
+ export declare function httpActor(c: {
49
+ agentId?: string | null;
50
+ account?: string | null;
51
+ browser: boolean;
52
+ }): {
53
+ actor: string;
54
+ via: SecretAuditVia;
55
+ agent?: string;
56
+ };
57
+ /** Names a profile write adds and removes. */
58
+ export declare function profileSecretDiff(before: string[] | undefined, after: string[] | undefined): {
59
+ added: string[];
60
+ removed: string[];
61
+ };
62
+ /** Parse JSONL, skipping any line that is not a row (a torn write, a hand edit). */
63
+ export declare function parseAudit(text: string): SecretAuditRow[];
64
+ export interface AuditQuery {
65
+ secret?: string;
66
+ actor?: string;
67
+ agent?: string;
68
+ action?: string;
69
+ since?: number;
70
+ limit?: number;
71
+ }
72
+ /** Newest first, narrowed. `secret` is exact (it is a name); `actor`/`agent`
73
+ * are substrings, since a person searches by a fragment of a name or an id. */
74
+ export declare function filterAudit(rows: SecretAuditRow[], q: AuditQuery): SecretAuditRow[];
75
+ /** Append one row. Never throws: losing an audit line must not fail the
76
+ * action it describes — but it says so, since a silent gap is the failure an
77
+ * audit exists to rule out. */
78
+ export declare function recordSecretAudit(r: Parameters<typeof auditRow>[0], file?: string): SecretAuditRow | null;
79
+ /** Every row we still hold (the rotated file first, so order is preserved). */
80
+ export declare function readSecretAudit(file?: string): SecretAuditRow[];
@@ -0,0 +1,156 @@
1
+ /**
2
+ * The secret AUDIT log: who wrote, attached, and above all USED which vault
3
+ * secret — "which agent, started by which person, received which credential".
4
+ *
5
+ * Names only, never a value: the log is read by people who must not learn the
6
+ * credentials from it, and a value written here would be a second copy of the
7
+ * vault with none of its care.
8
+ *
9
+ * Global, like the vault it describes (`~/.shadok-ai/secret-audit.jsonl`, 600),
10
+ * with the launch dir on every row: several instances share one vault, and an
11
+ * audit split per instance would hide the one that touched it. Append-only
12
+ * JSONL — one `appendFileSync` per row, so two instances writing at once never
13
+ * rewrite each other's lines the way a read-modify-write JSON file would.
14
+ * Bounded by rotation (one `.1` kept), not by pruning rows in place: an audit
15
+ * trail that edits its own past is not one.
16
+ *
17
+ * "Use" means INJECTION, which is the only moment shadok hands a value to a
18
+ * process: an agent's spawn (env of `claude`) and a cron guard's run (env of
19
+ * `sh -c`). What the process does with the value afterwards is outside what
20
+ * shadok can see — the log says who RECEIVED a secret, and says so.
21
+ *
22
+ * Pure cores (`auditRow`, `spawnActor`, `httpActor`, `profileSecretDiff`,
23
+ * `parseAudit`, `filterAudit`) are tested; the file I/O is the thin part.
24
+ */
25
+ import fs from "node:fs";
26
+ import os from "node:os";
27
+ import path from "node:path";
28
+ export const SECRET_AUDIT_ACTIONS = [
29
+ "created", "updated", "deleted", "attached", "detached", "injected", "cron-check",
30
+ ];
31
+ const FILE = path.join(os.homedir(), ".shadok-ai", "secret-audit.jsonl");
32
+ /** Rotate past this size, keeping exactly one previous file. */
33
+ export const AUDIT_ROTATE_BYTES = 8 * 1024 * 1024;
34
+ // ── Pure core ────────────────────────────────────────────────────────────
35
+ /** Build a row, or null when there is nothing to record (no secret named). An
36
+ * agent spawned with no secret received none, and a row saying so is noise. */
37
+ export function auditRow(r, now) {
38
+ const secrets = [...new Set(r.secrets.filter((s) => typeof s === "string" && !!s.trim()).map((s) => s.trim()))].sort();
39
+ if (!secrets.length)
40
+ return null;
41
+ const row = { at: now, action: r.action, secrets, actor: r.actor || "unknown", via: r.via, instance: r.instance };
42
+ if (r.agent)
43
+ row.agent = r.agent;
44
+ if (r.agentName)
45
+ row.agentName = r.agentName;
46
+ if (r.profile)
47
+ row.profile = r.profile;
48
+ if (r.detail)
49
+ row.detail = r.detail;
50
+ return row;
51
+ }
52
+ /**
53
+ * Who started (or restarted) an agent, from what the WebSocket connection
54
+ * knows. The ORDER is the point: the most specific principal wins. A peer
55
+ * names a remote instance; a parent names the agent that delegated (pilotctl
56
+ * connects as the bootstrap admin, so the account alone would credit a person
57
+ * who did nothing); a cron or Telegram origin names a door with no account of
58
+ * its own. Only a plain web client is credited to its signed-in account.
59
+ */
60
+ export function spawnActor(c) {
61
+ if (c.peer)
62
+ return { actor: `peer ${c.peer}`, via: "peer" };
63
+ if (c.parent)
64
+ return { actor: `agent ${c.parent.slice(0, 8)}`, via: "agent" };
65
+ if (c.origin === "cron")
66
+ return { actor: "cron", via: "cron" };
67
+ if (c.origin === "telegram")
68
+ return { actor: c.from ? `telegram ${c.from}` : "telegram", via: "telegram" };
69
+ if (c.origin === "web")
70
+ return { actor: c.account || "unknown", via: "web" };
71
+ return { actor: c.account || c.origin || "unknown", via: c.origin ? "api" : "system" };
72
+ }
73
+ /** Who made an HTTP call: an agent by its session key beats the account it
74
+ * authenticates as (which is the bootstrap admin for every agent). */
75
+ export function httpActor(c) {
76
+ if (c.agentId)
77
+ return { actor: `agent ${c.agentId.slice(0, 8)}`, via: "agent", agent: c.agentId };
78
+ return { actor: c.account || "unknown", via: c.browser ? "web" : "api" };
79
+ }
80
+ /** Names a profile write adds and removes. */
81
+ export function profileSecretDiff(before, after) {
82
+ const b = new Set(before ?? []);
83
+ const a = new Set(after ?? []);
84
+ return { added: [...a].filter((n) => !b.has(n)).sort(), removed: [...b].filter((n) => !a.has(n)).sort() };
85
+ }
86
+ /** Parse JSONL, skipping any line that is not a row (a torn write, a hand edit). */
87
+ export function parseAudit(text) {
88
+ const out = [];
89
+ for (const line of text.split("\n")) {
90
+ if (!line.trim())
91
+ continue;
92
+ try {
93
+ const r = JSON.parse(line);
94
+ if (r && typeof r.at === "number" && typeof r.action === "string" && Array.isArray(r.secrets))
95
+ out.push(r);
96
+ }
97
+ catch {
98
+ /* torn line */
99
+ }
100
+ }
101
+ return out;
102
+ }
103
+ /** Newest first, narrowed. `secret` is exact (it is a name); `actor`/`agent`
104
+ * are substrings, since a person searches by a fragment of a name or an id. */
105
+ export function filterAudit(rows, q) {
106
+ const actor = q.actor?.toLowerCase();
107
+ const agent = q.agent?.toLowerCase();
108
+ const out = rows.filter((r) => (!q.secret || r.secrets.includes(q.secret)) &&
109
+ (!q.action || r.action === q.action) &&
110
+ (!q.since || r.at >= q.since) &&
111
+ (!actor || r.actor.toLowerCase().includes(actor)) &&
112
+ (!agent || (r.agent ?? "").toLowerCase().includes(agent) || (r.agentName ?? "").toLowerCase().includes(agent)));
113
+ out.sort((a, b) => b.at - a.at);
114
+ return q.limit && q.limit > 0 ? out.slice(0, q.limit) : out;
115
+ }
116
+ // ── Storage ──────────────────────────────────────────────────────────────
117
+ /** Append one row. Never throws: losing an audit line must not fail the
118
+ * action it describes — but it says so, since a silent gap is the failure an
119
+ * audit exists to rule out. */
120
+ export function recordSecretAudit(r, file = FILE) {
121
+ const row = auditRow(r, Date.now());
122
+ if (!row)
123
+ return null;
124
+ try {
125
+ fs.mkdirSync(path.dirname(file), { recursive: true });
126
+ try {
127
+ if (fs.statSync(file).size > AUDIT_ROTATE_BYTES)
128
+ fs.renameSync(file, file + ".1");
129
+ }
130
+ catch {
131
+ /* no file yet */
132
+ }
133
+ fs.appendFileSync(file, JSON.stringify(row) + "\n", { mode: 0o600 });
134
+ fs.chmodSync(file, 0o600);
135
+ console.log(`secret-audit: ${row.action} [${row.secrets.join(",")}] by ${row.actor}${row.agent ? ` → ${row.agent.slice(0, 8)}` : ""}`);
136
+ return row;
137
+ }
138
+ catch (err) {
139
+ console.log(`secret-audit: could not record ${row.action} [${row.secrets.join(",")}] — ${err.message}`);
140
+ return null;
141
+ }
142
+ }
143
+ /** Every row we still hold (the rotated file first, so order is preserved). */
144
+ export function readSecretAudit(file = FILE) {
145
+ let text = "";
146
+ for (const f of [file + ".1", file]) {
147
+ try {
148
+ text += fs.readFileSync(f, "utf8") + "\n";
149
+ }
150
+ catch {
151
+ /* absent */
152
+ }
153
+ }
154
+ return parseAudit(text);
155
+ }
156
+ //# sourceMappingURL=secret-audit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"secret-audit.js","sourceRoot":"","sources":["../src/secret-audit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,IAAI,MAAM,WAAW,CAAC;AAW7B,MAAM,CAAC,MAAM,oBAAoB,GAAwB;IACvD,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,YAAY;CAClF,CAAC;AAsBF,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,EAAE,YAAY,EAAE,oBAAoB,CAAC,CAAC;AACzE,gEAAgE;AAChE,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,GAAG,IAAI,GAAG,IAAI,CAAC;AAElD,4EAA4E;AAE5E;gFACgF;AAChF,MAAM,UAAU,QAAQ,CACtB,CAAsF,EACtF,GAAW;IAEX,MAAM,OAAO,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAe,EAAE,CAAC,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IACpI,IAAI,CAAC,OAAO,CAAC,MAAM;QAAE,OAAO,IAAI,CAAC;IACjC,MAAM,GAAG,GAAmB,EAAE,EAAE,EAAE,GAAG,EAAE,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC,KAAK,IAAI,SAAS,EAAE,GAAG,EAAE,CAAC,CAAC,GAAG,EAAE,QAAQ,EAAE,CAAC,CAAC,QAAQ,EAAE,CAAC;IAClI,IAAI,CAAC,CAAC,KAAK;QAAE,GAAG,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC;IACjC,IAAI,CAAC,CAAC,SAAS;QAAE,GAAG,CAAC,SAAS,GAAG,CAAC,CAAC,SAAS,CAAC;IAC7C,IAAI,CAAC,CAAC,OAAO;QAAE,GAAG,CAAC,OAAO,GAAG,CAAC,CAAC,OAAO,CAAC;IACvC,IAAI,CAAC,CAAC,MAAM;QAAE,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC,MAAM,CAAC;IACpC,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,UAAU,CAAC,CAM1B;IACC,IAAI,CAAC,CAAC,IAAI;QAAE,OAAO,EAAE,KAAK,EAAE,QAAQ,CAAC,CAAC,IAAI,EAAE,EAAE,GAAG,EAAE,MAAM,EAAE,CAAC;IAC5D,IAAI,CAAC,CAAC,MAAM;QAAE,OAAO,EAAE,KAAK,EAAE,SAAS,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,GAAG,EAAE,OAAO,EAAE,CAAC;IAC9E,IAAI,CAAC,CAAC,MAAM,KAAK,MAAM;QAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,CAAC;IAC/D,IAAI,CAAC,CAAC,MAAM,KAAK,UAAU;QAAE,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,UAAU,EAAE,GAAG,EAAE,UAAU,EAAE,CAAC;IAC3G,IAAI,CAAC,CAAC,MAAM,KAAK,KAAK;QAAE,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC,OAAO,IAAI,SAAS,EAAE,GAAG,EAAE,KAAK,EAAE,CAAC;IAC7E,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC,OAAO,IAAI,CAAC,CAAC,MAAM,IAAI,SAAS,EAAE,GAAG,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC;AACzF,CAAC;AAED;uEACuE;AACvE,MAAM,UAAU,SAAS,CAAC,CAIzB;IACC,IAAI,CAAC,CAAC,OAAO;QAAE,OAAO,EAAE,KAAK,EAAE,SAAS,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,GAAG,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC;IAClG,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC,OAAO,IAAI,SAAS,EAAE,GAAG,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,EAAE,CAAC;AAC3E,CAAC;AAED,8CAA8C;AAC9C,MAAM,UAAU,iBAAiB,CAAC,MAA4B,EAAE,KAA2B;IACzF,MAAM,CAAC,GAAG,IAAI,GAAG,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC;IAChC,MAAM,CAAC,GAAG,IAAI,GAAG,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC;IAC/B,OAAO,EAAE,KAAK,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,EAAE,OAAO,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC;AAC5G,CAAC;AAED,oFAAoF;AACpF,MAAM,UAAU,UAAU,CAAC,IAAY;IACrC,MAAM,GAAG,GAAqB,EAAE,CAAC;IACjC,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;QACpC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE;YAAE,SAAS;QAC3B,IAAI,CAAC;YACH,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;YAC3B,IAAI,CAAC,IAAI,OAAO,CAAC,CAAC,EAAE,KAAK,QAAQ,IAAI,OAAO,CAAC,CAAC,MAAM,KAAK,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC;gBAAE,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAC7G,CAAC;QAAC,MAAM,CAAC;YACP,eAAe;QACjB,CAAC;IACH,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAWD;gFACgF;AAChF,MAAM,UAAU,WAAW,CAAC,IAAsB,EAAE,CAAa;IAC/D,MAAM,KAAK,GAAG,CAAC,CAAC,KAAK,EAAE,WAAW,EAAE,CAAC;IACrC,MAAM,KAAK,GAAG,CAAC,CAAC,KAAK,EAAE,WAAW,EAAE,CAAC;IACrC,MAAM,GAAG,GAAG,IAAI,CAAC,MAAM,CACrB,CAAC,CAAC,EAAE,EAAE,CACJ,CAAC,CAAC,CAAC,CAAC,MAAM,IAAI,CAAC,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;QAC3C,CAAC,CAAC,CAAC,CAAC,MAAM,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,MAAM,CAAC;QACpC,CAAC,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,KAAK,CAAC;QAC7B,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;QACjD,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,IAAI,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CACjH,CAAC;IACF,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC;IAChC,OAAO,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC;AAC9D,CAAC;AAED,4EAA4E;AAE5E;;gCAEgC;AAChC,MAAM,UAAU,iBAAiB,CAC/B,CAAiC,EACjC,IAAI,GAAW,IAAI;IAEnB,MAAM,GAAG,GAAG,QAAQ,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;IACpC,IAAI,CAAC,GAAG;QAAE,OAAO,IAAI,CAAC;IACtB,IAAI,CAAC;QACH,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QACtD,IAAI,CAAC;YACH,IAAI,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,IAAI,GAAG,kBAAkB;gBAAE,EAAE,CAAC,UAAU,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI,CAAC,CAAC;QACpF,CAAC;QAAC,MAAM,CAAC;YACP,iBAAiB;QACnB,CAAC;QACD,EAAE,CAAC,cAAc,CAAC,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,GAAG,IAAI,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;QACrE,EAAE,CAAC,SAAS,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;QAC1B,OAAO,CAAC,GAAG,CAAC,iBAAiB,GAAG,CAAC,MAAM,KAAK,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,GAAG,CAAC,KAAK,GAAG,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;QACvI,OAAO,GAAG,CAAC;IACb,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,CAAC,GAAG,CAAC,kCAAkC,GAAG,CAAC,MAAM,KAAK,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,OAAQ,GAAa,CAAC,OAAO,EAAE,CAAC,CAAC;QACnH,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,+EAA+E;AAC/E,MAAM,UAAU,eAAe,CAAC,IAAI,GAAW,IAAI;IACjD,IAAI,IAAI,GAAG,EAAE,CAAC;IACd,KAAK,MAAM,CAAC,IAAI,CAAC,IAAI,GAAG,IAAI,EAAE,IAAI,CAAC,EAAE,CAAC;QACpC,IAAI,CAAC;YACH,IAAI,IAAI,EAAE,CAAC,YAAY,CAAC,CAAC,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC;QAC5C,CAAC;QAAC,MAAM,CAAC;YACP,YAAY;QACd,CAAC;IACH,CAAC;IACD,OAAO,UAAU,CAAC,IAAI,CAAC,CAAC;AAC1B,CAAC"}
package/dist/server.js CHANGED
@@ -51,6 +51,7 @@ import { rowKind } from "../context/ledger-skill/ledger-core.mjs";
51
51
  import { linkRefusal, markAgentPrompt, notificationText, } from "./kinship.js";
52
52
  import { loadConfig, saveConfig, telegramConfig, applyTelegramPatch, titleForCwd, setTitleForCwd, themeForCwd, setThemeForCwd, twoFactorForCwd, setTwoFactorForCwd, backendForCwd, setBackendForCwd, } from "./config.js";
53
53
  import { secretsFor, secretNames, setSecret, deleteSecret, secretWriteVerdict, recordOrigin, originProfile, } from "./secrets.js";
54
+ import { recordSecretAudit, readSecretAudit, filterAudit, spawnActor, httpActor, profileSecretDiff, } from "./secret-audit.js";
54
55
  import { getProfile, profileArgs, permissionModeArgs, isPermissionMode, isEffortLevel, envVarsNote, seedDefaultProfiles, migrateToTracking, seedTweakProfile, loadProfiles, upsertProfile, removeProfile, promptEditVerdict, secretAttachVerdict, READONLY_DENY, TWEAK_PROFILE_NAME, promptOrigin, effectiveProfile, shippedProfile, } from "./profiles.js";
55
56
  import { BOOTSTRAP_ADMIN, loadAccounts, verifyPassword, sessionSecret, signSession, readSessionFull, sessionAlive, saveAccounts, userWriteVerdict, newInvite, inviteVerdict, hashPassword, promptAuthor, signSessionKey, readSessionKey, } from "./accounts.js";
56
57
  import { admins2fa, authenticationOptions, enrollCodeVerdict, isInternalClient, keepsTwoAdminsVerdict, loginNext, newEnrollCode, publicPasskey, putChallenge, readPending, registrationOptions, resetVerdict, signPending, takeChallenge, twoFactorEnableVerdict, verifyAuthentication, verifyRegistration, PENDING_TTL_MS, } from "./twofactor.js";
@@ -280,7 +281,18 @@ function runCronCheck(c, target) {
280
281
  return Promise.resolve({ kind: "quiet" }); // no guard → the agent always runs
281
282
  return new Promise((resolve) => {
282
283
  const profile = target.profile ? getProfile(target.profile) : undefined;
283
- const env = { ...process.env, ...secretsFor(profile?.secrets) };
284
+ const secretEnv = secretsFor(profile?.secrets);
285
+ const env = { ...process.env, ...secretEnv };
286
+ recordSecretAudit({
287
+ action: "cron-check",
288
+ secrets: Object.keys(secretEnv),
289
+ actor: `cron ${c.id.slice(0, 8)}`,
290
+ via: "cron",
291
+ agent: c.sessionId,
292
+ agentName: loadChannels().find((ch) => ch.sessionId === c.sessionId)?.name,
293
+ ...(target.profile ? { profile: target.profile } : {}),
294
+ instance: process.cwd(),
295
+ });
284
296
  execFile("sh", ["-c", c.check], { cwd: target.cwd, env, timeout: 2 * 60_000, maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
285
297
  const out = (stdout || "").trim();
286
298
  if (out)
@@ -957,6 +969,21 @@ function callerOf(req) {
957
969
  const profile = sessions.get(id)?.profile ?? loadChannels().find((c) => c.sessionId === id)?.profile ?? null;
958
970
  return { id, profile };
959
971
  }
972
+ /** Who an HTTP call to the vault/profile routes is, for the secret audit log. */
973
+ function auditActorOf(req) {
974
+ return httpActor({
975
+ agentId: callerOf(req).id,
976
+ account: currentAccount(req)?.name ?? null,
977
+ browser: requestFromBrowser(req),
978
+ });
979
+ }
980
+ /** One audit row per direction of a profile's secret list change. */
981
+ function auditProfileSecrets(req, profile, diff, detail) {
982
+ const who = auditActorOf(req);
983
+ const base = { ...who, profile, instance: process.cwd(), ...(detail ? { detail } : {}) };
984
+ recordSecretAudit({ action: "attached", secrets: diff.added, ...base });
985
+ recordSecretAudit({ action: "detached", secrets: diff.removed, ...base });
986
+ }
960
987
  /** A browser on our own origin — the only caller allowed to change guardrails. */
961
988
  function requestFromBrowser(req) {
962
989
  return browserOrigin(req.headers.origin, req.headers.host, EXTRA_ORIGINS);
@@ -1725,6 +1752,7 @@ app.put("/secrets", (req, res) => {
1725
1752
  if (verdict === "refused")
1726
1753
  return res.status(409).json({ error: "exists", name: key });
1727
1754
  setSecret(key, value);
1755
+ recordSecretAudit({ action: verdict, secrets: [key], ...auditActorOf(req), instance: process.cwd() });
1728
1756
  // Remember WHO created it, so the agent can later attach it to its own
1729
1757
  // profile (`PUT /profiles/secret`). Only on a creation: recording an
1730
1758
  // overwrite too would let an agent clobber a human's secret to become its
@@ -1736,10 +1764,33 @@ app.put("/secrets", (req, res) => {
1736
1764
  }
1737
1765
  res.json({ names: secretNames(), result: verdict });
1738
1766
  });
1767
+ // Who wrote, attached and RECEIVED which secret (names only). Admin only: it
1768
+ // maps every credential to the agents and people that held it. See
1769
+ // src/secret-audit.ts.
1770
+ app.get("/secrets/audit", (req, res) => {
1771
+ if (currentAccount(req)?.role !== "admin")
1772
+ return res.status(403).json({ error: "admins only" });
1773
+ const q = req.query;
1774
+ const str = (v) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
1775
+ const limit = Math.min(Math.max(Number(q.limit) || 500, 1), 5000);
1776
+ const since = Number(q.since) || undefined;
1777
+ res.json({
1778
+ rows: filterAudit(readSecretAudit(), {
1779
+ secret: str(q.secret),
1780
+ actor: str(q.actor),
1781
+ agent: str(q.agent),
1782
+ action: str(q.action),
1783
+ since,
1784
+ limit,
1785
+ }),
1786
+ });
1787
+ });
1739
1788
  app.delete("/secrets", (req, res) => {
1740
1789
  const name = String(req.query.name ?? req.body?.name ?? "").trim();
1741
- if (name)
1790
+ if (name && secretNames().includes(name)) {
1742
1791
  deleteSecret(name);
1792
+ recordSecretAudit({ action: "deleted", secrets: [name], ...auditActorOf(req), instance: process.cwd() });
1793
+ }
1743
1794
  res.json({ names: secretNames() });
1744
1795
  });
1745
1796
  // Agent profiles (global): role + permission guardrails + referenced secrets,
@@ -2461,7 +2512,9 @@ app.put("/profiles", (req, res) => {
2461
2512
  };
2462
2513
  if (b.effort !== undefined && b.effort !== "" && !isEffortLevel(b.effort))
2463
2514
  return res.status(400).json({ error: `unknown effort level: ${String(b.effort)}` });
2515
+ const secretDiff = profileSecretDiff(getProfile(name)?.secrets, p.secrets);
2464
2516
  upsertProfile(p);
2517
+ auditProfileSecrets(req, name, secretDiff);
2465
2518
  res.json(loadProfiles().map((x) => ({ ...x, origin: promptOrigin(x, shippedProfile(x.name)) })));
2466
2519
  });
2467
2520
  /**
@@ -2538,6 +2591,8 @@ app.put("/profiles/secret", (req, res) => {
2538
2591
  const secrets = [...new Set([...(existing.secrets ?? []), name])];
2539
2592
  upsertProfile({ ...existing, secrets });
2540
2593
  console.log(`profile-secret: ${caller} += ${name}`);
2594
+ if (!(existing.secrets ?? []).includes(name))
2595
+ recordSecretAudit({ action: "attached", secrets: [name], ...auditActorOf(req), profile: caller, instance: process.cwd() });
2541
2596
  res.json({
2542
2597
  ok: true,
2543
2598
  profile: caller,
@@ -2564,14 +2619,17 @@ app.post("/reload", (req, res) => {
2564
2619
  // new process is ready so it carries on. Scoped to the SELF-reload only — not
2565
2620
  // the GUI reload, restart-all, or an auto-update respawn (those touch many/all
2566
2621
  // agents at once, and waking every idle one would be a token storm).
2567
- void restartSession(s)
2622
+ void restartSession(s, { actor: `agent ${id.slice(0, 8)}`, via: "agent" })
2568
2623
  .then(() => continueAfterReload(s.id))
2569
2624
  .catch(() => { });
2570
2625
  });
2571
2626
  app.delete("/profiles", (req, res) => {
2572
2627
  const name = String(req.query.name ?? req.body?.name ?? "").trim();
2573
- if (name)
2628
+ if (name) {
2629
+ const before = getProfile(name)?.secrets;
2574
2630
  removeProfile(name);
2631
+ auditProfileSecrets(req, name, profileSecretDiff(before, []), "profile deleted");
2632
+ }
2575
2633
  res.json(loadProfiles());
2576
2634
  });
2577
2635
  /** Build the /telegram state view (token is never included). */
@@ -2773,14 +2831,14 @@ app.post("/autoupdate", (req, res) => {
2773
2831
  * await the loop: a caller (the toggle, the button) must not hang for minutes
2774
2832
  * while a big fleet cycles.
2775
2833
  */
2776
- function restartAllSessions(ids) {
2834
+ function restartAllSessions(ids, by = SYSTEM_SPAWN) {
2777
2835
  // A subset (e.g. only the agents flagged as needing a Claude Code reload) when
2778
2836
  // `ids` is given, all live sessions otherwise. Same one-at-a-time discipline.
2779
2837
  const wanted = ids && ids.length ? new Set(ids) : null;
2780
2838
  const live = [...sessions.values()].filter((s) => !wanted || wanted.has(s.id));
2781
2839
  void (async () => {
2782
2840
  for (const s of live)
2783
- await restartSession(s).catch(() => { });
2841
+ await restartSession(s, by).catch(() => { });
2784
2842
  })();
2785
2843
  return live.length;
2786
2844
  }
@@ -2793,7 +2851,7 @@ app.post("/restart-all", (req, res) => {
2793
2851
  const ids = Array.isArray(req.body?.sessions)
2794
2852
  ? req.body.sessions.filter((x) => typeof x === "string" && !!x)
2795
2853
  : undefined;
2796
- const restarted = restartAllSessions(ids);
2854
+ const restarted = restartAllSessions(ids, auditActorOf(req));
2797
2855
  console.log(`restart-all: respawning ${restarted} agent(s)${ids ? " (filtered)" : ""}, one at a time`);
2798
2856
  res.json({ restarted });
2799
2857
  });
@@ -3030,7 +3088,7 @@ app.post("/ledger", (req, res) => {
3030
3088
  cfg.ledgerEnabled = ledgerEnabled;
3031
3089
  saveConfig(cfg);
3032
3090
  broadcastAll(versionMessage());
3033
- const restarted = changed ? restartAllSessions() : 0;
3091
+ const restarted = changed ? restartAllSessions(undefined, auditActorOf(req)) : 0;
3034
3092
  res.json({ ledgerEnabled, restarted });
3035
3093
  });
3036
3094
  /**
@@ -3384,7 +3442,7 @@ function broadcast(s, msg, except) {
3384
3442
  * preserved (resume reads the transcript); every attached client keeps its ref.
3385
3443
  * Shared by the `restart` message and by `set-profile` with `restart: true`.
3386
3444
  */
3387
- async function restartSession(s) {
3445
+ async function restartSession(s, by = SYSTEM_SPAWN) {
3388
3446
  s.restarting = true;
3389
3447
  // The raw pipe is bound to the pilot we're about to replace — close it;
3390
3448
  // clients re-attach on the next "ready" if the terminal is open.
@@ -3445,6 +3503,7 @@ async function restartSession(s) {
3445
3503
  // A restart spawns a brand-new process, so its anchor is current: re-record
3446
3504
  // it (clearing `legacy`), which is what makes the reload indicator go dark.
3447
3505
  recordSpawnFingerprint(s);
3506
+ recordSecretInjection(s, by, "restart");
3448
3507
  s.lastStaleSig = undefined;
3449
3508
  // `lastTurnMs`, like the connect-path `ready`: the transient "working" above
3450
3509
  // started the client's turn timer, and this "ready" is what stops it — without
@@ -3492,19 +3551,27 @@ function profileMessage(s) {
3492
3551
  function broadcastProfile(s) {
3493
3552
  broadcast(s, profileMessage(s));
3494
3553
  }
3495
- // ── Reload indicator: is a running agent behind what a fresh spawn gives? ────
3496
- //
3497
- // The signal is version-INDEPENDENT (see `stalenessReasons`): it compares what
3498
- // the RUNNING process actually received against what shadok injects now. The
3499
- // anchor is DURABLE (the channel on disk, `spawnFp`/`spawnAt`/`spawnClaudeVer`)
3500
- // because the in-memory `prepromptById` snapshot is reset to "current" whenever
3501
- // a surviving tmux pane is re-adopted after a server restart — which is exactly
3502
- // the moment a shadok auto-update changes the prompt under a live agent, so an
3503
- // in-memory anchor would hide the very drift this detects.
3504
- /** Record this agent's spawn fingerprint — but ONLY for a process we actually
3505
- * spawned. An adopted pane is the OLD process: its stored fingerprint must be
3506
- * left alone (or seeded as `legacy` the first time, so a pre-feature agent
3507
- * reads as stale until reloaded). Called once after `attachPilot`. */
3554
+ const SYSTEM_SPAWN = { actor: "system", via: "system" };
3555
+ /**
3556
+ * Audit which vault secrets a FRESH process just received. An adopted tmux
3557
+ * pane got its env when it was first spawned (already logged then), so a
3558
+ * reattach records nothing — it hands no value to anyone.
3559
+ */
3560
+ function recordSecretInjection(s, by, detail) {
3561
+ if (s.pilot instanceof TmuxPilot && s.pilot.attached)
3562
+ return;
3563
+ const profile = s.profile ? getProfile(s.profile) : undefined;
3564
+ recordSecretAudit({
3565
+ action: "injected",
3566
+ secrets: Object.keys(secretsFor(profile?.secrets)),
3567
+ ...by,
3568
+ agent: s.id,
3569
+ agentName: loadChannels().find((c) => c.sessionId === s.id)?.name,
3570
+ ...(s.profile ? { profile: s.profile } : {}),
3571
+ instance: process.cwd(),
3572
+ detail,
3573
+ });
3574
+ }
3508
3575
  function recordSpawnFingerprint(s) {
3509
3576
  const adopted = s.pilot instanceof TmuxPilot && s.pilot.attached;
3510
3577
  if (adopted) {
@@ -3690,7 +3757,7 @@ function ensureClaudeOnce(revalidate = true) {
3690
3757
  claudeReady = null; });
3691
3758
  return p;
3692
3759
  }
3693
- async function createSession(id, cwd, args, worktree = null, profile = null, model = null, effort = null) {
3760
+ async function createSession(id, cwd, args, worktree = null, profile = null, model = null, effort = null, by = SYSTEM_SPAWN) {
3694
3761
  const pilot = makePilot(id, cwd, args, profile, model, effort);
3695
3762
  // Resumed sessions start with what the transcript already consumed.
3696
3763
  const seededUsage = scanUsage(sessionFilePath(cwd, id));
@@ -3739,6 +3806,7 @@ async function createSession(id, cwd, args, worktree = null, profile = null, mod
3739
3806
  // tell whether it drifted. No-op-safe for an adopted pane (keeps the old
3740
3807
  // anchor); seeds `legacy` the first time a pre-feature agent is adopted.
3741
3808
  recordSpawnFingerprint(s);
3809
+ recordSecretInjection(s, by, args.includes("--resume") ? "resume" : "spawn");
3742
3810
  return s;
3743
3811
  }
3744
3812
  /**
@@ -4383,6 +4451,9 @@ wss.on("connection", (ws, req) => {
4383
4451
  // profile's `deny` (measured), so pilotctl and the agents, which connect with
4384
4452
  // no Origin at all (invariant 11), must not be able to reach it.
4385
4453
  const fromBrowser = requestFromBrowser(req);
4454
+ // Who a respawn asked from this socket is credited to in the secret audit
4455
+ // log. A function, not a value: `origin` is only known once `start` arrives.
4456
+ const wsBy = () => spawnActor({ account: me?.name, origin, peer: callerPeer });
4386
4457
  let session = null;
4387
4458
  // Where does this client come from? Declared at `start` (web, cron, telegram,
4388
4459
  // cli…), it travels with the prompt echo: other clients must be able to SAY
@@ -4663,7 +4734,7 @@ wss.on("connection", (ws, req) => {
4663
4734
  const initialName = !resumed && typeof msg.name === "string" && msg.name.trim()
4664
4735
  ? msg.name.trim().slice(0, 80)
4665
4736
  : undefined;
4666
- session = await createSession(id, effectiveCwd, args, worktree, profile, model, effort);
4737
+ session = await createSession(id, effectiveCwd, args, worktree, profile, model, effort, spawnActor({ account: me?.name, origin: msg.origin, peer: callerPeer, parent: parentAtStart }));
4667
4738
  session.clients.add(ws);
4668
4739
  if (resumed) {
4669
4740
  const turns = historyWithOffers(loadHistory(effectiveCwd, id), id);
@@ -5086,7 +5157,7 @@ wss.on("connection", (ws, req) => {
5086
5157
  case "restart": {
5087
5158
  if (!session)
5088
5159
  return fail("no session started");
5089
- await restartSession(session);
5160
+ await restartSession(session, wsBy());
5090
5161
  break;
5091
5162
  }
5092
5163
  case "set-profile": {
@@ -5105,7 +5176,7 @@ wss.on("connection", (ws, req) => {
5105
5176
  upsertChannel({ sessionId: s.id, profile: name });
5106
5177
  broadcastProfile(s); // even without a restart: the gap shows everywhere
5107
5178
  if (msg.restart)
5108
- await restartSession(s);
5179
+ await restartSession(s, wsBy());
5109
5180
  break;
5110
5181
  }
5111
5182
  case "set-model": {
@@ -5120,7 +5191,7 @@ wss.on("connection", (ws, req) => {
5120
5191
  const model = msg.model?.trim() || null;
5121
5192
  s.model = model ?? undefined;
5122
5193
  upsertChannel({ sessionId: s.id, model });
5123
- await restartSession(s); // respawns with the new --model (s.model)
5194
+ await restartSession(s, wsBy()); // respawns with the new --model (s.model)
5124
5195
  broadcastProfile(s); // running == desired now
5125
5196
  break;
5126
5197
  }
@@ -5140,7 +5211,7 @@ wss.on("connection", (ws, req) => {
5140
5211
  return fail(`unknown effort level: ${wanted}`);
5141
5212
  s.effort = wanted ?? undefined;
5142
5213
  upsertChannel({ sessionId: s.id, effort: wanted });
5143
- await restartSession(s); // respawns with the new --effort
5214
+ await restartSession(s, wsBy()); // respawns with the new --effort
5144
5215
  broadcastProfile(s); // running == desired now
5145
5216
  break;
5146
5217
  }