@sweny-ai/core 0.1.144 → 0.1.145

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.
@@ -19,7 +19,10 @@
19
19
  * host network except through that proxy.
20
20
  * - filesystem: the workspace (cwd) and a scratch HOME are writable; the rest
21
21
  * of the filesystem is read-only; the operator's credential files are
22
- * unreadable. Dry runs (`readOnly`) leave the workspace read-only too.
22
+ * unreadable. Sibling homes under the same configured scratch root are
23
+ * hidden; only this run's HOME is exposed. Dry runs (`readOnly`) leave the
24
+ * workspace read-only too. All concurrent credential-bearing adapters must
25
+ * use the same scratch root; different roots are not mutually isolated.
23
26
  * - env: exactly the env the adapter passes (already scoped by agent-env.ts),
24
27
  * with HOME, XDG dirs and TMPDIR pointed into the scratch HOME. On Linux the
25
28
  * process also gets its own PID namespace, so it cannot read another
@@ -55,7 +58,7 @@ export interface SandboxWrapRequest extends AgentSpawn {
55
58
  }
56
59
  /** The spawn to run instead, plus the scratch it owns. */
57
60
  export interface WrappedSpawn extends AgentSpawn {
58
- /** Scratch HOME. Adapters may write config here (generated MCP config, copied auth) before spawning. */
61
+ /** Scratch HOME. Adapters sharing one scratch root may copy scoped auth/config here before spawning. */
59
62
  home: string;
60
63
  /** Remove the scratch HOME and the sandbox settings. Idempotent. */
61
64
  cleanup(): Promise<void>;
@@ -104,6 +107,8 @@ export declare function buildSrtSettings(req: Pick<SandboxWrapRequest, "cwd" | "
104
107
  credentialHome: string;
105
108
  exists?: (p: string) => boolean;
106
109
  credentialPaths?: readonly string[];
110
+ /** Shared wrapper storage to hide, with only `home` re-exposed. */
111
+ isolationRoot?: string;
107
112
  }): SrtSettings;
108
113
  /** Env for the wrapped process: the adapter's env with HOME, XDG and temp dirs moved into the scratch HOME. */
109
114
  export declare function scratchEnv(env: Record<string, string>, home: string): Record<string, string>;
@@ -114,7 +119,15 @@ export interface SrtWrapperOptions {
114
119
  credentialHome?: string;
115
120
  /** Override {@link CREDENTIAL_PATHS} (relative to `credentialHome`, or absolute). */
116
121
  credentialPaths?: readonly string[];
117
- /** Where scratch dirs are created. Default: `os.tmpdir()`. */
122
+ /**
123
+ * Storage parent, default `os.tmpdir()`. Runs share a per-user child under
124
+ * this directory, hidden except for their own HOME. Concurrent adapters
125
+ * carrying credentials must use the same parent; separate roots are not
126
+ * mutually isolated. The existing parent is never removed. Its canonical
127
+ * hierarchy must be owned by this user or root; other-writable ancestors
128
+ * must be sticky (like /tmp). The shared child must be private and owned
129
+ * by this user. Unsafe existing paths are rejected, never repaired.
130
+ */
118
131
  scratchRoot?: string;
119
132
  }
120
133
  export declare class SrtSandboxWrapper implements SandboxWrapper {
@@ -19,7 +19,10 @@
19
19
  * host network except through that proxy.
20
20
  * - filesystem: the workspace (cwd) and a scratch HOME are writable; the rest
21
21
  * of the filesystem is read-only; the operator's credential files are
22
- * unreadable. Dry runs (`readOnly`) leave the workspace read-only too.
22
+ * unreadable. Sibling homes under the same configured scratch root are
23
+ * hidden; only this run's HOME is exposed. Dry runs (`readOnly`) leave the
24
+ * workspace read-only too. All concurrent credential-bearing adapters must
25
+ * use the same scratch root; different roots are not mutually isolated.
23
26
  * - env: exactly the env the adapter passes (already scoped by agent-env.ts),
24
27
  * with HOME, XDG dirs and TMPDIR pointed into the scratch HOME. On Linux the
25
28
  * process also gets its own PID namespace, so it cannot read another
@@ -40,7 +43,7 @@
40
43
  */
41
44
  import { execFile } from "node:child_process";
42
45
  import { existsSync, realpathSync } from "node:fs";
43
- import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
46
+ import { lstat, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
44
47
  import { homedir, tmpdir } from "node:os";
45
48
  import * as path from "node:path";
46
49
  import { DEFAULT_SANDBOX_DOMAINS, parseList, resolveSandboxMode } from "../agent-env.js";
@@ -119,10 +122,14 @@ export function buildSrtSettings(req, opts) {
119
122
  const denyRead = (opts.credentialPaths ?? CREDENTIAL_PATHS)
120
123
  .map((rel) => (path.isAbsolute(rel) ? rel : path.join(opts.credentialHome, rel)))
121
124
  .filter((p) => exists(p));
125
+ // Deny the parent, not a snapshot of sibling homes: later-created runs
126
+ // must stay hidden too. srt allowRead carves out only our own HOME.
127
+ if (opts.isolationRoot)
128
+ denyRead.push(opts.isolationRoot);
122
129
  const allowWrite = req.readOnly ? [opts.home] : [real(req.cwd), opts.home];
123
130
  return {
124
131
  network: { allowedDomains, deniedDomains: [], strictAllowlist: true, allowLocalBinding: false },
125
- filesystem: { denyRead, allowRead: [], allowWrite, denyWrite: [] },
132
+ filesystem: { denyRead, allowRead: opts.isolationRoot ? [opts.home] : [], allowWrite, denyWrite: [] },
126
133
  };
127
134
  }
128
135
  /** Env for the wrapped process: the adapter's env with HOME, XDG and temp dirs moved into the scratch HOME. */
@@ -147,7 +154,39 @@ export class SrtSandboxWrapper {
147
154
  this.opts = opts;
148
155
  }
149
156
  async wrap(req) {
150
- const dir = await mkdtemp(path.join(real(this.opts.scratchRoot ?? tmpdir()), "sweny-sbx-"));
157
+ const uid = process.getuid?.();
158
+ if (uid === undefined)
159
+ throw new Error("Cannot validate sandbox scratch ownership on this platform");
160
+ const storageParent = await realpath(this.opts.scratchRoot ?? tmpdir());
161
+ // A private child can still be renamed by another user through a writable
162
+ // ancestor. Check the canonical hierarchy before accepting any run paths.
163
+ for (let ancestor = storageParent;; ancestor = path.dirname(ancestor)) {
164
+ const info = await lstat(ancestor);
165
+ if (!info.isDirectory() ||
166
+ (info.uid !== uid && info.uid !== 0) ||
167
+ ((info.mode & 0o022) !== 0 && (info.mode & 0o1000) === 0)) {
168
+ throw new Error(`Unsafe sandbox scratch ancestor: ${ancestor}`);
169
+ }
170
+ if (ancestor === path.dirname(ancestor))
171
+ break;
172
+ }
173
+ // Keep these components short: srt creates Unix sockets beneath HOME/tmp,
174
+ // and Linux socket paths must fit in 107 bytes (including caller parents).
175
+ const isolationRoot = path.join(storageParent, `sweny-${uid}`);
176
+ try {
177
+ await mkdir(isolationRoot, { mode: 0o700 });
178
+ }
179
+ catch (error) {
180
+ if (error.code !== "EEXIST")
181
+ throw error;
182
+ }
183
+ // Never adopt a symlink, a foreign-owned directory, or a directory other
184
+ // users can access. Validate newly created paths too; do not chmod them.
185
+ const info = await lstat(isolationRoot);
186
+ if (!info.isDirectory() || info.uid !== uid || (info.mode & 0o077) !== 0) {
187
+ throw new Error(`Unsafe sandbox scratch directory: ${isolationRoot}`);
188
+ }
189
+ const dir = await mkdtemp(path.join(isolationRoot, "r-"));
151
190
  let cleaned = false;
152
191
  const cleanup = async () => {
153
192
  if (cleaned)
@@ -162,6 +201,7 @@ export class SrtSandboxWrapper {
162
201
  home,
163
202
  credentialHome: this.opts.credentialHome ?? homedir(),
164
203
  credentialPaths: this.opts.credentialPaths,
204
+ isolationRoot,
165
205
  });
166
206
  const settingsPath = path.join(dir, "srt-settings.json");
167
207
  await writeFile(settingsPath, JSON.stringify(settings, null, 2), { mode: 0o600 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.144",
3
+ "version": "0.1.145",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"