shadok-ai 0.17.27 → 0.17.28

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
@@ -256,7 +256,9 @@ Enter to skip; you can add it later from the web UI).
256
256
  topic too** — a photo as a photo, anything else as a document — so whoever is
257
257
  reading there sees what you sent instead of a path. A message can be attachments alone, with no words. The files are
258
258
  served back to the browser by `GET /media/<name>` from the same directory
259
- Telegram attachments land in.
259
+ Telegram attachments land in. On a multi-tenant instance each group has its own
260
+ media directory (`/media/g-<group>/<name>`), readable only by that group's
261
+ agents and served only to people who can see that group.
260
262
  - **Files an agent hands you** — when an agent's result IS a file (a report, an
261
263
  export, a screenshot, an archive) it arrives as a download card in the chat,
262
264
  inline if it is an image, and as an upload in the Telegram topic. It goes
@@ -1148,9 +1150,10 @@ returns the cwd to start the tweak agent in). The instance-wide writes —
1148
1150
  `DELETE /profiles` — are **admin only, from the browser**; a member sees those
1149
1151
  controls disabled. `PUT /profiles` is admin only on a multi-tenant instance
1150
1152
  (profiles are global there), and `POST /restart-all {sessions:[…]}` stays open,
1151
- narrowed to the agents the caller may drive. Also `/media/<name>` (GET — serves a
1152
- pasted/dropped or Telegram attachment back for inline display; sandboxed, basename
1153
- only), and the sign-in group: `/auth` (GET
1153
+ narrowed to the agents the caller may drive. Also `/media/<name>` and
1154
+ `/media/g-<group>/<name>` (GET — serves a pasted/dropped or Telegram attachment
1155
+ back for inline display; sandboxed, never a symlink, a group's directory only to
1156
+ someone who can see that group), and the sign-in group: `/auth` (GET
1154
1157
  — `{loggedIn, email?, subscriptionType?, tokenExpiresAt?, accessExpiresAt?, backend?, backendModel?}`;
1155
1158
  the expiries come from the credentials store, never a token value; `backend:
1156
1159
  "custom"` when the instance runs on another model), `/auth/login`
@@ -37,7 +37,17 @@ omit the check — the prompt runs every time.
37
37
 
38
38
  A check does **not** run inside this agent's process. The server runs it with
39
39
  `sh -c`, in the CHANNEL's directory, with the secrets of the channel's
40
- **profile** injected as environment variables.
40
+ **profile** injected as environment variables. On a multi-tenant instance it
41
+ runs as this agent's own OS user, exactly like the agent.
42
+
43
+ Its environment is otherwise SMALL on purpose: `PATH`, the locale and time
44
+ zone, proxy/CA settings, and `SHADOK_SESSION_ID` / `SHADOK_PORT` /
45
+ `SHADOK_SESSION_KEY` (send the key as `x-shadok-session-key` to call the local
46
+ API). Nothing else from the server's environment reaches it — no token, no
47
+ password, no cookie. A credential the check needs goes in the profile.
48
+
49
+ You can only schedule on an agent you may drive: on a multi-tenant instance,
50
+ yourself, a sibling of your own group, or a shared (public) agent.
41
51
 
42
52
  So reference a secret by name (`$MY_API_KEY`) and trust it: never hardcode a
43
53
  value into a check script, and never try to source a shell profile to get one.
package/dist/files.d.ts CHANGED
@@ -36,6 +36,12 @@ export interface OfferedFile {
36
36
  /** ms epoch of the offer. */
37
37
  at: number;
38
38
  caption?: string;
39
+ /** Identity of the file AT THE OFFER (device + inode). `GET /download`
40
+ * compares it, so a path swapped for something else after the offer — a
41
+ * symlink repointed, a file replaced — is refused rather than served.
42
+ * Absent on an offer recorded before the check existed. */
43
+ dev?: number;
44
+ ino?: number;
39
45
  }
40
46
  /** Biggest file we will register. Beyond this the agent is told, not ignored. */
41
47
  export declare const MAX_OFFER_BYTES: number;
@@ -64,6 +70,8 @@ export type OfferVerdict = {
64
70
  export type OfferStat = {
65
71
  isFile: boolean;
66
72
  size: number;
73
+ dev?: number;
74
+ ino?: number;
67
75
  } | "missing" | "unreadable";
68
76
  export declare function offerVerdict(sessionId: string, p: string, stat: OfferStat, now: number, caption?: string): OfferVerdict;
69
77
  /**
@@ -91,5 +99,11 @@ export declare function refusalMessage(r: OfferRefusal): string;
91
99
  export declare const filesFileFor: (cwd: string) => string;
92
100
  export declare function loadOffers(file: string): OfferedFile[];
93
101
  export declare function saveOffers(file: string, rows: readonly OfferedFile[]): void;
94
- /** Stat for `offerVerdict`, keeping absent and unreadable apart. */
102
+ /**
103
+ * The offer to compare a download against: the newest row of THIS session for
104
+ * this exact path (rows are superseded, so there is at most one).
105
+ */
106
+ export declare function offerFor(rows: readonly OfferedFile[], sessionId: string, p: string): OfferedFile | undefined;
107
+ /** Stat for `offerVerdict`, keeping absent and unreadable apart. As the SERVER's
108
+ * user — multi-tenant goes through `statAsUserArgv` instead (invariant 60). */
95
109
  export declare function statForOffer(p: string): OfferStat;
package/dist/files.js CHANGED
@@ -56,7 +56,11 @@ export function offerVerdict(sessionId, p, stat, now, caption) {
56
56
  const name = path.basename(raw);
57
57
  return {
58
58
  ok: true,
59
- file: { sessionId, path: raw, name, size: stat.size, at: now, ...(caption?.trim() ? { caption: caption.trim() } : {}) },
59
+ file: {
60
+ sessionId, path: raw, name, size: stat.size, at: now,
61
+ ...(caption?.trim() ? { caption: caption.trim() } : {}),
62
+ ...(stat.dev !== undefined && stat.ino !== undefined ? { dev: stat.dev, ino: stat.ino } : {}),
63
+ },
60
64
  };
61
65
  }
62
66
  /**
@@ -123,11 +127,19 @@ export function saveOffers(file, rows) {
123
127
  fs.writeFileSync(tmp, JSON.stringify(rows, null, 2), { mode: 0o600 });
124
128
  fs.renameSync(tmp, file);
125
129
  }
126
- /** Stat for `offerVerdict`, keeping absent and unreadable apart. */
130
+ /**
131
+ * The offer to compare a download against: the newest row of THIS session for
132
+ * this exact path (rows are superseded, so there is at most one).
133
+ */
134
+ export function offerFor(rows, sessionId, p) {
135
+ return rows.find((r) => r.sessionId === sessionId && r.path === p);
136
+ }
137
+ /** Stat for `offerVerdict`, keeping absent and unreadable apart. As the SERVER's
138
+ * user — multi-tenant goes through `statAsUserArgv` instead (invariant 60). */
127
139
  export function statForOffer(p) {
128
140
  try {
129
141
  const s = fs.statSync(p);
130
- return { isFile: s.isFile(), size: s.size };
142
+ return { isFile: s.isFile(), size: s.size, dev: s.dev, ino: s.ino };
131
143
  }
132
144
  catch (e) {
133
145
  return e?.code === "ENOENT" ? "missing" : "unreadable";
package/dist/files.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"files.js","sourceRoot":"","sources":["../src/files.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,EAAE,QAAQ,EAAiB,MAAM,eAAe,CAAC;AAExD;;;;;;;;;;;;;;;;;GAiBG;AAEH;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,YAAY,CAAC;AAYvC,iFAAiF;AACjF,MAAM,CAAC,MAAM,eAAe,GAAG,GAAG,GAAG,IAAI,GAAG,IAAI,CAAC;AAEjD,yEAAyE;AACzE,MAAM,CAAC,MAAM,UAAU,GAAG,GAAG,CAAC;AAkB9B,MAAM,UAAU,YAAY,CAC1B,SAAiB,EACjB,CAAS,EACT,IAAe,EACf,GAAW,EACX,OAAgB;IAEhB,MAAM,GAAG,GAAG,MAAM,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IACnC,8EAA8E;IAC9E,8EAA8E;IAC9E,4EAA4E;IAC5E,4EAA4E;IAC5E,gBAAgB;IAChB,IAAI,CAAC,GAAG,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,cAAc,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC;IAC3F,IAAI,IAAI,KAAK,SAAS;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC;IAC3E,IAAI,IAAI,KAAK,YAAY;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC;IACjF,IAAI,CAAC,IAAI,CAAC,MAAM;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC;IACxE,IAAI,IAAI,CAAC,IAAI,GAAG,eAAe;QAC7B,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC,IAAI,YAAY,eAAe,EAAE,EAAE,CAAC;IACxG,MAAM,IAAI,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC;IAChC,OAAO;QACL,EAAE,EAAE,IAAI;QACR,IAAI,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,GAAG,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,EAAE,EAAE,GAAG,EAAE,GAAG,CAAC,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE;KACxH,CAAC;AACJ,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,SAAS,CAAC,IAA4B,EAAE,CAAc;IACpE,MAAM,IAAI,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,KAAK,CAAC,CAAC,SAAS,IAAI,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;IACrF,MAAM,IAAI,GAAG,CAAC,GAAG,IAAI,EAAE,CAAC,CAAC,CAAC;IAC1B,OAAO,IAAI,CAAC,MAAM,GAAG,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,GAAG,UAAU,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;AAChF,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,YAAY,CAAC,IAA4B,EAAE,SAAiB;IAC1E,MAAM,GAAG,GAAG,IAAI,GAAG,EAAU,CAAC;IAC9B,KAAK,MAAM,CAAC,IAAI,IAAI;QAAE,IAAI,CAAC,CAAC,SAAS,KAAK,SAAS;YAAE,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;IACrE,OAAO,GAAG,CAAC;AACb,CAAC;AAED,oFAAoF;AACpF,MAAM,UAAU,UAAU,CAAC,KAA6B;IACtD,OAAO,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;AAC5C,CAAC;AAED,+DAA+D;AAC/D,MAAM,UAAU,cAAc,CAAC,CAAe;IAC5C,QAAQ,CAAC,CAAC,MAAM,EAAE,CAAC;QACjB,KAAK,cAAc;YACjB,OAAO,GAAG,CAAC,CAAC,IAAI,IAAI,SAAS,uEAAuE,CAAC;QACvG,KAAK,SAAS;YACZ,OAAO,GAAG,CAAC,CAAC,IAAI,gBAAgB,CAAC;QACnC,KAAK,YAAY;YACf,OAAO,GAAG,CAAC,CAAC,IAAI,kCAAkC,CAAC;QACrD,KAAK,YAAY;YACf,OAAO,GAAG,CAAC,CAAC,IAAI,sBAAsB,CAAC;QACzC,KAAK,SAAS;YACZ,OAAO,GAAG,CAAC,CAAC,IAAI,6BAA6B,CAAC,CAAC,MAAM,GAAG,CAAC;IAC7D,CAAC;AACH,CAAC;AAED,MAAM,QAAQ,GAAG,GAAW,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,EAAE,YAAY,EAAE,OAAO,CAAC,CAAC;AAC9E,MAAM,CAAC,MAAM,YAAY,GAAG,CAAC,GAAW,EAAU,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,EAAE,GAAG,WAAW,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;AAEvG,MAAM,UAAU,UAAU,CAAC,IAAY;IACrC,IAAI,CAAC;QACH,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC;QACpD,OAAO,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,OAAO,CAAC,CAAC,IAAI,KAAK,QAAQ,IAAI,OAAO,CAAC,CAAC,SAAS,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IACrH,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,CAAC;IACZ,CAAC;AACH,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,IAAY,EAAE,IAA4B;IACnE,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACtD,MAAM,GAAG,GAAG,GAAG,IAAI,WAAW,OAAO,CAAC,GAAG,MAAM,CAAC;IAChD,EAAE,CAAC,aAAa,CAAC,GAAG,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IACtE,EAAE,CAAC,UAAU,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;AAC3B,CAAC;AAED,oEAAoE;AACpE,MAAM,UAAU,YAAY,CAAC,CAAS;IACpC,IAAI,CAAC;QACH,MAAM,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC;QACzB,OAAO,EAAE,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IAC9C,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,OAAQ,CAA2B,EAAE,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,YAAY,CAAC;IACpF,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"files.js","sourceRoot":"","sources":["../src/files.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,154 @@
1
+ import { type Group, type Viewer } from "./groups.js";
2
+ import type { DeploymentMode } from "./capability.js";
3
+ /**
4
+ * What the SERVER does on a group's behalf — run a cron guard, read a file an
5
+ * agent handed over — must be done with that group's rights, never with the
6
+ * server's. In multi-tenant the server is root, so "run it as the server" means
7
+ * "run it as root": a cron `check` was a root shell for anyone who could reach
8
+ * `POST /crons`, and `GET /download` streamed any root-readable file an agent
9
+ * named. Pure, so every decision here is tested without a server, a root
10
+ * account or a provisioned OS user (none of which a test machine has).
11
+ *
12
+ * Invariant 58.
13
+ */
14
+ /**
15
+ * May this caller schedule / edit / delete a cron on (or otherwise act for)
16
+ * `targetId`? The HTTP twin of the WS `mayDriveSession`, with one rule more for
17
+ * an AGENT (a request carrying a per-session key): an agent acts on itself, on a
18
+ * sibling of its OWN group, or on a commons/public agent — never into another
19
+ * group, whatever principal its key maps to. That keeps the rule right both
20
+ * while the key still authenticates as the bootstrap admin and after it maps to
21
+ * an agent principal of its own.
22
+ *
23
+ * - mono-tenant: there is no wall to protect, today's behaviour (true);
24
+ * - a target with no channel row: refused in multi-tenant — a cron aimed at an
25
+ * unknown session would fire with a GUESSED directory and group;
26
+ * - a dangling group id: refused (fail closed, the twin of `canSeeSession`).
27
+ */
28
+ export declare function mayActOnSession(input: {
29
+ mode: DeploymentMode;
30
+ /** The calling agent (per-session key), with its channel's group. */
31
+ agent: {
32
+ id: string;
33
+ group: string | null;
34
+ } | null;
35
+ viewer: Viewer | null;
36
+ targetId: string;
37
+ channels: readonly {
38
+ sessionId: string;
39
+ group?: string | null;
40
+ }[];
41
+ groups: readonly Group[];
42
+ }): boolean;
43
+ /**
44
+ * The group-level half of `mayActOnSession`, reused where the thing asked for
45
+ * is a GROUP's rather than a session's (a media subdirectory).
46
+ */
47
+ export declare function mayReachGroup(input: {
48
+ mode: DeploymentMode;
49
+ agent: {
50
+ id: string;
51
+ group: string | null;
52
+ } | null;
53
+ viewer: Viewer | null;
54
+ group: string | null;
55
+ groups: readonly Group[];
56
+ self?: boolean;
57
+ }): boolean;
58
+ /**
59
+ * The OS user the server must act as for a channel of `groupId` — the SAME
60
+ * answer as the spawn's drop user (`resolveSpawnIsolation` / `channelStateHome`):
61
+ * the group's own `sk-*` user, else the commons user (null before migration,
62
+ * meaning the server's own user, exactly as a commons agent runs then).
63
+ * A group id that resolves to nothing is REFUSED, never mapped to the commons:
64
+ * acting for a group we cannot identify is how a private job would end up
65
+ * running with someone else's rights.
66
+ */
67
+ export declare function osUserForChannelGroup(groupId: string | null | undefined, groups: readonly Group[], commonsUser: string | null): {
68
+ ok: true;
69
+ user: string | null;
70
+ } | {
71
+ ok: false;
72
+ };
73
+ /**
74
+ * The server environment names a cron guard may inherit. An ALLOW-list on
75
+ * purpose: the server's env holds the GUI password, the Telegram token, a
76
+ * custom backend's key, the instance auth cookie — and a deny-list is only as
77
+ * good as the day it was written (invariant 29's lesson about subtraction).
78
+ * What a shell script legitimately needs from its surroundings is short:
79
+ * where binaries are, the locale and zone, a temp dir, proxy and CA settings,
80
+ * and the SSH fallback a container uses (`src/ssh.ts`). Its credentials come
81
+ * from the PROFILE's secrets, like the agent's own.
82
+ */
83
+ export declare const CHECK_ENV_ALLOW: RegExp;
84
+ /**
85
+ * The full environment of a cron guard. Layers, last wins:
86
+ * 1. the allow-listed part of the server env (+ HOME/USER/LOGNAME when the
87
+ * guard runs as the server's own user — they describe that user);
88
+ * 2. the target profile's secrets (what the check was always promised);
89
+ * 3. the session plumbing a script needs to call back the local API
90
+ * (`SHADOK_SESSION_ID`/`_PORT`/`_KEY` — the agent's own, never the admin
91
+ * cookie `SHADOK_AUTH`);
92
+ * 4. the drop user's HOME/USER/LOGNAME (`dropEnv`), so a dropped guard never
93
+ * keeps the server's `HOME=/root`.
94
+ */
95
+ export declare function cronCheckEnv(input: {
96
+ base: Record<string, string | undefined>;
97
+ secrets: Record<string, string>;
98
+ session: {
99
+ id: string;
100
+ port: string;
101
+ key: string;
102
+ };
103
+ user: string | null;
104
+ home: string | null;
105
+ }): Record<string, string>;
106
+ /** The guard's command: `sh -c <check>`, wrapped in the same `setpriv` drop as
107
+ * an agent's spawn when there is a user to drop to. */
108
+ export declare function cronCheckCommand(user: string | null, check: string): {
109
+ cmd: string;
110
+ argv: string[];
111
+ };
112
+ /**
113
+ * `stat` as the drop user: format, size, device and inode on four lines.
114
+ * `-L` follows a symlink — AS THAT USER, so it can only ever reach what the
115
+ * user could read anyway; the identity of what it reached is what the offer
116
+ * records and the download compares.
117
+ */
118
+ export declare function statAsUserArgv(user: string, p: string): {
119
+ cmd: string;
120
+ argv: string[];
121
+ };
122
+ export interface FileIdentity {
123
+ isFile: boolean;
124
+ size: number;
125
+ dev: number;
126
+ ino: number;
127
+ }
128
+ /** Parse `statAsUserArgv`'s output; null on anything unexpected. */
129
+ export declare function parseStatOutput(out: string): FileIdentity | null;
130
+ /**
131
+ * Stream the bytes AS the drop user: `dd` opens the file with that user's
132
+ * rights, so a path an agent named can never be turned into a root read — not
133
+ * by naming `/root/...`, not by swapping a symlink after the offer. Optional
134
+ * byte range (inclusive), for the `<video>` seek path.
135
+ */
136
+ export declare function readAsUserArgv(user: string, p: string, range?: {
137
+ start: number;
138
+ end: number;
139
+ } | null): {
140
+ cmd: string;
141
+ argv: string[];
142
+ };
143
+ /**
144
+ * Is the file about to be served the one that was OFFERED? An offer records
145
+ * device + inode; a later symlink swap, rename or replacement changes them.
146
+ * An offer from before this check (no identity recorded) is not refused.
147
+ */
148
+ export declare function sameOfferedFile(offered: {
149
+ dev?: number;
150
+ ino?: number;
151
+ } | undefined, now: {
152
+ dev: number;
153
+ ino: number;
154
+ }): boolean;
@@ -0,0 +1,161 @@
1
+ import { dropArgv, dropEnv, osUserFor } from "./osusers.js";
2
+ import { canSeeGroup } from "./groups.js";
3
+ /**
4
+ * What the SERVER does on a group's behalf — run a cron guard, read a file an
5
+ * agent handed over — must be done with that group's rights, never with the
6
+ * server's. In multi-tenant the server is root, so "run it as the server" means
7
+ * "run it as root": a cron `check` was a root shell for anyone who could reach
8
+ * `POST /crons`, and `GET /download` streamed any root-readable file an agent
9
+ * named. Pure, so every decision here is tested without a server, a root
10
+ * account or a provisioned OS user (none of which a test machine has).
11
+ *
12
+ * Invariant 58.
13
+ */
14
+ // ── Who may act on a session ────────────────────────────────────────────────
15
+ /**
16
+ * May this caller schedule / edit / delete a cron on (or otherwise act for)
17
+ * `targetId`? The HTTP twin of the WS `mayDriveSession`, with one rule more for
18
+ * an AGENT (a request carrying a per-session key): an agent acts on itself, on a
19
+ * sibling of its OWN group, or on a commons/public agent — never into another
20
+ * group, whatever principal its key maps to. That keeps the rule right both
21
+ * while the key still authenticates as the bootstrap admin and after it maps to
22
+ * an agent principal of its own.
23
+ *
24
+ * - mono-tenant: there is no wall to protect, today's behaviour (true);
25
+ * - a target with no channel row: refused in multi-tenant — a cron aimed at an
26
+ * unknown session would fire with a GUESSED directory and group;
27
+ * - a dangling group id: refused (fail closed, the twin of `canSeeSession`).
28
+ */
29
+ export function mayActOnSession(input) {
30
+ if (input.mode !== "multi-tenant")
31
+ return true;
32
+ if (!input.targetId)
33
+ return false;
34
+ const ch = input.channels.find((c) => c.sessionId === input.targetId);
35
+ if (!ch)
36
+ return false;
37
+ return mayReachGroup({ ...input, group: ch.group ?? null, self: input.agent?.id === input.targetId });
38
+ }
39
+ /**
40
+ * The group-level half of `mayActOnSession`, reused where the thing asked for
41
+ * is a GROUP's rather than a session's (a media subdirectory).
42
+ */
43
+ export function mayReachGroup(input) {
44
+ if (input.mode !== "multi-tenant")
45
+ return true;
46
+ if (input.self)
47
+ return true;
48
+ const g = input.group ? input.groups.find((x) => x.id === input.group) : null;
49
+ if (input.group && !g)
50
+ return false; // dangling → fail closed
51
+ // Group-less = the commons, visible to everyone (today's rule).
52
+ if (!g || g.nature === "public")
53
+ return true;
54
+ if (input.agent)
55
+ return (input.agent.group ?? null) === g.id;
56
+ return canSeeGroup(g, input.viewer);
57
+ }
58
+ /**
59
+ * The OS user the server must act as for a channel of `groupId` — the SAME
60
+ * answer as the spawn's drop user (`resolveSpawnIsolation` / `channelStateHome`):
61
+ * the group's own `sk-*` user, else the commons user (null before migration,
62
+ * meaning the server's own user, exactly as a commons agent runs then).
63
+ * A group id that resolves to nothing is REFUSED, never mapped to the commons:
64
+ * acting for a group we cannot identify is how a private job would end up
65
+ * running with someone else's rights.
66
+ */
67
+ export function osUserForChannelGroup(groupId, groups, commonsUser) {
68
+ if (!groupId)
69
+ return { ok: true, user: commonsUser };
70
+ const g = groups.find((x) => x.id === groupId);
71
+ if (!g)
72
+ return { ok: false };
73
+ return { ok: true, user: osUserFor(g) ?? commonsUser };
74
+ }
75
+ // ── Cron guard execution ────────────────────────────────────────────────────
76
+ /**
77
+ * The server environment names a cron guard may inherit. An ALLOW-list on
78
+ * purpose: the server's env holds the GUI password, the Telegram token, a
79
+ * custom backend's key, the instance auth cookie — and a deny-list is only as
80
+ * good as the day it was written (invariant 29's lesson about subtraction).
81
+ * What a shell script legitimately needs from its surroundings is short:
82
+ * where binaries are, the locale and zone, a temp dir, proxy and CA settings,
83
+ * and the SSH fallback a container uses (`src/ssh.ts`). Its credentials come
84
+ * from the PROFILE's secrets, like the agent's own.
85
+ */
86
+ export const CHECK_ENV_ALLOW = /^(PATH|LANG|LANGUAGE|LC_[A-Z_]+|TZ|TERM|TMPDIR|SHELL|GIT_SSH_COMMAND|SSL_CERT_FILE|SSL_CERT_DIR|NODE_EXTRA_CA_CERTS|HTTPS?_PROXY|https?_proxy|NO_PROXY|no_proxy|ALL_PROXY|all_proxy)$/;
87
+ /** Identity names kept only when the guard runs as the server's own user
88
+ * (mono-tenant, or the commons before migration) — they are that user's. */
89
+ const SELF_IDENTITY = new Set(["HOME", "USER", "LOGNAME"]);
90
+ /**
91
+ * The full environment of a cron guard. Layers, last wins:
92
+ * 1. the allow-listed part of the server env (+ HOME/USER/LOGNAME when the
93
+ * guard runs as the server's own user — they describe that user);
94
+ * 2. the target profile's secrets (what the check was always promised);
95
+ * 3. the session plumbing a script needs to call back the local API
96
+ * (`SHADOK_SESSION_ID`/`_PORT`/`_KEY` — the agent's own, never the admin
97
+ * cookie `SHADOK_AUTH`);
98
+ * 4. the drop user's HOME/USER/LOGNAME (`dropEnv`), so a dropped guard never
99
+ * keeps the server's `HOME=/root`.
100
+ */
101
+ export function cronCheckEnv(input) {
102
+ const env = {};
103
+ for (const [k, v] of Object.entries(input.base)) {
104
+ if (v === undefined)
105
+ continue;
106
+ if (CHECK_ENV_ALLOW.test(k) || (!input.user && SELF_IDENTITY.has(k)))
107
+ env[k] = v;
108
+ }
109
+ Object.assign(env, input.secrets);
110
+ env.SHADOK_SESSION_ID = input.session.id;
111
+ env.SHADOK_PORT = input.session.port;
112
+ env.SHADOK_SESSION_KEY = input.session.key;
113
+ Object.assign(env, dropEnv(input.user, input.home));
114
+ return env;
115
+ }
116
+ /** The guard's command: `sh -c <check>`, wrapped in the same `setpriv` drop as
117
+ * an agent's spawn when there is a user to drop to. */
118
+ export function cronCheckCommand(user, check) {
119
+ return user ? dropArgv(user, "sh", ["-c", check]) : { cmd: "sh", argv: ["-c", check] };
120
+ }
121
+ // ── Reading a file AS a group's user ────────────────────────────────────────
122
+ /**
123
+ * `stat` as the drop user: format, size, device and inode on four lines.
124
+ * `-L` follows a symlink — AS THAT USER, so it can only ever reach what the
125
+ * user could read anyway; the identity of what it reached is what the offer
126
+ * records and the download compares.
127
+ */
128
+ export function statAsUserArgv(user, p) {
129
+ return dropArgv(user, "stat", ["-L", "--printf=%F\\n%s\\n%d\\n%i\\n", "--", p]);
130
+ }
131
+ /** Parse `statAsUserArgv`'s output; null on anything unexpected. */
132
+ export function parseStatOutput(out) {
133
+ const [kind, size, dev, ino] = String(out ?? "").split("\n");
134
+ const n = [Number(size), Number(dev), Number(ino)];
135
+ if (!kind || n.some((x) => !Number.isFinite(x) || x < 0))
136
+ return null;
137
+ return { isFile: /^regular( empty)? file$/.test(kind.trim()), size: n[0], dev: n[1], ino: n[2] };
138
+ }
139
+ /**
140
+ * Stream the bytes AS the drop user: `dd` opens the file with that user's
141
+ * rights, so a path an agent named can never be turned into a root read — not
142
+ * by naming `/root/...`, not by swapping a symlink after the offer. Optional
143
+ * byte range (inclusive), for the `<video>` seek path.
144
+ */
145
+ export function readAsUserArgv(user, p, range) {
146
+ const ops = [`if=${p}`, "bs=65536", "status=none"];
147
+ if (range)
148
+ ops.push("iflag=skip_bytes,count_bytes,fullblock", `skip=${range.start}`, `count=${range.end - range.start + 1}`);
149
+ return dropArgv(user, "dd", ops);
150
+ }
151
+ /**
152
+ * Is the file about to be served the one that was OFFERED? An offer records
153
+ * device + inode; a later symlink swap, rename or replacement changes them.
154
+ * An offer from before this check (no identity recorded) is not refused.
155
+ */
156
+ export function sameOfferedFile(offered, now) {
157
+ if (!offered || offered.dev === undefined || offered.ino === undefined)
158
+ return true;
159
+ return offered.dev === now.dev && offered.ino === now.ino;
160
+ }
161
+ //# sourceMappingURL=group-exec.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"group-exec.js","sourceRoot":"","sources":["../src/group-exec.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Where a pasted / Telegram attachment lives, per GROUP. Pure.
3
+ *
4
+ * Multi-tenant used to put every group's media in ONE directory under the
5
+ * shared pot, mode 2775 group `shadok-shared` — and every `sk-*` user is in
6
+ * that group. So any agent could read another group's screenshots, overwrite
7
+ * them, or plant `x.png -> /root/.shadok-ai/secrets.json`, which `/media`
8
+ * (served as root, following symlinks) then handed to the browser. The pot is
9
+ * also group-WRITABLE with no sticky bit, so the media directory itself could
10
+ * be renamed away and replaced.
11
+ *
12
+ * Now: a root-owned media ROOT outside the pot (0711 — traversable, not
13
+ * listable), and one subdirectory per group, `g-<key>`, owned root:<that
14
+ * group's OS user's group>, mode 2750. Only root writes there; only that
15
+ * group's user (and root) reads. Mono-tenant keeps its single flat directory.
16
+ *
17
+ * Invariant 58.
18
+ */
19
+ /** Prefix of a group media subdirectory; a flat (legacy / mono) file never
20
+ * sits in a directory with this shape, so the two cannot be confused. */
21
+ export declare const MEDIA_GROUP_PREFIX = "g-";
22
+ export declare const MEDIA_GROUP_DIR_RE: RegExp;
23
+ /** The subdirectory name of a group. A group id that is already a safe name is
24
+ * used as is (readable on disk: `g-main`, `g-<uuid>`); anything else is hashed
25
+ * rather than sanitised, because a lossy sanitising could map two ids to ONE
26
+ * directory — the very sharing this exists to end. */
27
+ export declare function mediaGroupKey(groupId: string | null | undefined): string;
28
+ /** The directory a new attachment of this group is written to. */
29
+ export declare function mediaDirForGroup(root: string, groupId: string | null | undefined, multiTenant: boolean): string;
30
+ /** Mode of a file the server writes into the media tree: the group's user reads
31
+ * it in multi-tenant (the directory's setgid group), only the server otherwise. */
32
+ export declare function mediaFileMode(multiTenant: boolean): number;
33
+ /**
34
+ * Split a `/media` request into the directory it names and the file. `key` is
35
+ * null for a flat (legacy / mono-tenant) file. Anything that is not exactly one
36
+ * or two plain path segments is refused (`null`).
37
+ */
38
+ export declare function parseMediaRequest(segments: readonly string[]): {
39
+ key: string | null;
40
+ name: string;
41
+ } | null;
42
+ /** The group a media key belongs to, among the known groups (null if none). */
43
+ export declare function groupForMediaKey(key: string, groupIds: readonly string[]): string | null;
44
+ /** A user's primary gid from `/etc/passwd` text, or null. */
45
+ export declare function passwdGid(passwd: string, user: string): number | null;
46
+ /**
47
+ * How a group media directory must look. No drop user (commons before
48
+ * migration, whose agents run as the server) → 0700, server only. Otherwise
49
+ * setgid 2750 on the user's group, so frames ffmpeg writes beside a video
50
+ * inherit that group too.
51
+ */
52
+ export declare function mediaDirSpec(gid: number | null): {
53
+ gid: number | null;
54
+ mode: number;
55
+ };
56
+ /**
57
+ * May a directory entry found where a group media directory should be, be kept?
58
+ * Only a real directory owned by the server. Anything else (a symlink, a file,
59
+ * a directory an agent created while the old media dir was group-writable) is
60
+ * moved aside and recreated — never written through.
61
+ */
62
+ export declare function mediaDirTrusted(st: {
63
+ isDirectory: boolean;
64
+ isSymbolicLink: boolean;
65
+ uid: number;
66
+ }, serverUid: number): boolean;
67
+ /**
68
+ * May `/media` serve this entry (from `lstat`)? A regular file, never a
69
+ * symlink; in multi-tenant also one the SERVER wrote — a file an agent planted
70
+ * in the old shared directory is not media anyone uploaded.
71
+ */
72
+ export declare function mediaServable(st: {
73
+ isFile: boolean;
74
+ isSymbolicLink: boolean;
75
+ uid: number;
76
+ }, multiTenant: boolean, serverUid: number): boolean;
@@ -0,0 +1,103 @@
1
+ import path from "node:path";
2
+ import { createHash } from "node:crypto";
3
+ import { MAIN_GROUP_ID } from "./groups.js";
4
+ /**
5
+ * Where a pasted / Telegram attachment lives, per GROUP. Pure.
6
+ *
7
+ * Multi-tenant used to put every group's media in ONE directory under the
8
+ * shared pot, mode 2775 group `shadok-shared` — and every `sk-*` user is in
9
+ * that group. So any agent could read another group's screenshots, overwrite
10
+ * them, or plant `x.png -> /root/.shadok-ai/secrets.json`, which `/media`
11
+ * (served as root, following symlinks) then handed to the browser. The pot is
12
+ * also group-WRITABLE with no sticky bit, so the media directory itself could
13
+ * be renamed away and replaced.
14
+ *
15
+ * Now: a root-owned media ROOT outside the pot (0711 — traversable, not
16
+ * listable), and one subdirectory per group, `g-<key>`, owned root:<that
17
+ * group's OS user's group>, mode 2750. Only root writes there; only that
18
+ * group's user (and root) reads. Mono-tenant keeps its single flat directory.
19
+ *
20
+ * Invariant 58.
21
+ */
22
+ /** Prefix of a group media subdirectory; a flat (legacy / mono) file never
23
+ * sits in a directory with this shape, so the two cannot be confused. */
24
+ export const MEDIA_GROUP_PREFIX = "g-";
25
+ export const MEDIA_GROUP_DIR_RE = /^g-[A-Za-z0-9_-]{1,64}$/;
26
+ /** The subdirectory name of a group. A group id that is already a safe name is
27
+ * used as is (readable on disk: `g-main`, `g-<uuid>`); anything else is hashed
28
+ * rather than sanitised, because a lossy sanitising could map two ids to ONE
29
+ * directory — the very sharing this exists to end. */
30
+ export function mediaGroupKey(groupId) {
31
+ const id = groupId || MAIN_GROUP_ID;
32
+ if (/^[A-Za-z0-9_-]{1,64}$/.test(id))
33
+ return MEDIA_GROUP_PREFIX + id;
34
+ return MEDIA_GROUP_PREFIX + "h" + createHash("sha256").update(id).digest("hex").slice(0, 32);
35
+ }
36
+ /** The directory a new attachment of this group is written to. */
37
+ export function mediaDirForGroup(root, groupId, multiTenant) {
38
+ return multiTenant ? path.join(root, mediaGroupKey(groupId)) : root;
39
+ }
40
+ /** Mode of a file the server writes into the media tree: the group's user reads
41
+ * it in multi-tenant (the directory's setgid group), only the server otherwise. */
42
+ export function mediaFileMode(multiTenant) {
43
+ return multiTenant ? 0o640 : 0o600;
44
+ }
45
+ /**
46
+ * Split a `/media` request into the directory it names and the file. `key` is
47
+ * null for a flat (legacy / mono-tenant) file. Anything that is not exactly one
48
+ * or two plain path segments is refused (`null`).
49
+ */
50
+ export function parseMediaRequest(segments) {
51
+ const ok = (s) => !!s && s !== "." && s !== ".." && !/[/\\\0]/.test(s);
52
+ if (segments.length === 1 && ok(segments[0]))
53
+ return { key: null, name: segments[0] };
54
+ if (segments.length === 2 && MEDIA_GROUP_DIR_RE.test(segments[0]) && ok(segments[1]))
55
+ return { key: segments[0], name: segments[1] };
56
+ return null;
57
+ }
58
+ /** The group a media key belongs to, among the known groups (null if none). */
59
+ export function groupForMediaKey(key, groupIds) {
60
+ for (const id of [MAIN_GROUP_ID, ...groupIds])
61
+ if (mediaGroupKey(id) === key)
62
+ return id;
63
+ return null;
64
+ }
65
+ /** A user's primary gid from `/etc/passwd` text, or null. */
66
+ export function passwdGid(passwd, user) {
67
+ for (const line of String(passwd ?? "").split("\n")) {
68
+ const f = line.split(":");
69
+ if (f.length >= 4 && f[0] === user) {
70
+ return /^\d+$/.test(f[3]) ? Number(f[3]) : null;
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+ /**
76
+ * How a group media directory must look. No drop user (commons before
77
+ * migration, whose agents run as the server) → 0700, server only. Otherwise
78
+ * setgid 2750 on the user's group, so frames ffmpeg writes beside a video
79
+ * inherit that group too.
80
+ */
81
+ export function mediaDirSpec(gid) {
82
+ return gid == null ? { gid: null, mode: 0o700 } : { gid, mode: 0o2750 };
83
+ }
84
+ /**
85
+ * May a directory entry found where a group media directory should be, be kept?
86
+ * Only a real directory owned by the server. Anything else (a symlink, a file,
87
+ * a directory an agent created while the old media dir was group-writable) is
88
+ * moved aside and recreated — never written through.
89
+ */
90
+ export function mediaDirTrusted(st, serverUid) {
91
+ return st.isDirectory && !st.isSymbolicLink && st.uid === serverUid;
92
+ }
93
+ /**
94
+ * May `/media` serve this entry (from `lstat`)? A regular file, never a
95
+ * symlink; in multi-tenant also one the SERVER wrote — a file an agent planted
96
+ * in the old shared directory is not media anyone uploaded.
97
+ */
98
+ export function mediaServable(st, multiTenant, serverUid) {
99
+ if (st.isSymbolicLink || !st.isFile)
100
+ return false;
101
+ return !multiTenant || st.uid === serverUid;
102
+ }
103
+ //# sourceMappingURL=media-scope.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"media-scope.js","sourceRoot":"","sources":["../src/media-scope.ts"],"names":[],"mappings":"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"}