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 +7 -4
- package/context/scheduler-skill/SKILL.md +11 -1
- package/dist/files.d.ts +15 -1
- package/dist/files.js +15 -3
- package/dist/files.js.map +1 -1
- package/dist/group-exec.d.ts +154 -0
- package/dist/group-exec.js +161 -0
- package/dist/group-exec.js.map +1 -0
- package/dist/media-scope.d.ts +76 -0
- package/dist/media-scope.js +103 -0
- package/dist/media-scope.js.map +1 -0
- package/dist/server.js +434 -77
- package/dist/server.js.map +1 -1
- package/dist/telegram.d.ts +18 -1
- package/dist/telegram.js +88 -22
- package/dist/telegram.js.map +1 -1
- package/package.json +1 -1
- package/public/index.html +11 -3
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>`
|
|
1152
|
-
pasted/dropped or Telegram attachment
|
|
1153
|
-
|
|
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
|
-
/**
|
|
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: {
|
|
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
|
-
/**
|
|
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;
|
|
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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"}
|