@boardwalk-labs/runner 0.1.6 → 0.1.8

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.
@@ -2,10 +2,20 @@
2
2
  // Persisted runner identity — the standing credential + coordinates a machine keeps between
3
3
  // restarts (`runner start` after a reboot skips registration). One JSON file per
4
4
  // (control plane, pool), mode 0600: the runner token is a credential.
5
- import { mkdir, readFile, writeFile, unlink } from "node:fs/promises";
5
+ import { mkdir, readFile, writeFile, unlink, chmod } from "node:fs/promises";
6
6
  import * as path from "node:path";
7
7
  import * as os from "node:os";
8
8
  import { z } from "zod";
9
+ /** Re-assert restrictive perms on a path (POSIX only). `mkdir`/`writeFile` set mode only when they
10
+ * CREATE, so a pre-existing dir/file could be group/world-readable; this makes the credential
11
+ * fail-safe regardless. No-op on Windows (no POSIX mode bits). Best-effort — a chmod failure must
12
+ * not break enrollment. NOTE: this hardens the file at rest; it does NOT stop a same-UID process
13
+ * (an in-process run program) from reading it — that requires per-run UID/container isolation. */
14
+ async function hardenPerms(target, mode) {
15
+ if (process.platform === "win32")
16
+ return;
17
+ await chmod(target, mode).catch(() => undefined);
18
+ }
9
19
  export const runnerIdentitySchema = z.strictObject({
10
20
  runner_id: z.string().min(1),
11
21
  runner_token: z.string().min(1),
@@ -25,15 +35,21 @@ function identityFile(dir, controlPlaneUrl, pool) {
25
35
  }
26
36
  export async function saveIdentity(dir, identity) {
27
37
  await mkdir(dir, { recursive: true, mode: 0o700 });
38
+ await hardenPerms(dir, 0o700); // re-assert: mkdir's mode only applies when it CREATES the dir
28
39
  const file = identityFile(dir, identity.control_plane_url, identity.pool);
29
40
  await writeFile(file, `${JSON.stringify(identity, null, 2)}\n`, { mode: 0o600 });
41
+ await hardenPerms(file, 0o600); // re-assert: writeFile's mode only applies to a NEW file
30
42
  return file;
31
43
  }
32
44
  export async function loadIdentity(dir, controlPlaneUrl, pool) {
33
45
  try {
34
- const raw = await readFile(identityFile(dir, controlPlaneUrl, pool), "utf8");
46
+ const file = identityFile(dir, controlPlaneUrl, pool);
47
+ const raw = await readFile(file, "utf8");
35
48
  const parsed = runnerIdentitySchema.safeParse(JSON.parse(raw));
36
- return parsed.success ? parsed.data : null;
49
+ if (!parsed.success)
50
+ return null;
51
+ await hardenPerms(file, 0o600); // repair perms if something loosened them since save
52
+ return parsed.data;
37
53
  }
38
54
  catch {
39
55
  return null;
@@ -10,6 +10,11 @@ import { type SuspendSignal } from "./suspension.js";
10
10
  * still exist.
11
11
  */
12
12
  export declare function ensureSdkLink(execDir: string): Promise<void>;
13
+ /** Resolve the program's entry module inside the extraction dir, refusing any path that escapes it.
14
+ * The control plane validates `entry` at deploy, but a self-hosted runner may be pointed at an
15
+ * arbitrary control plane, so this is defense-in-depth: an absolute path or a `..` that resolves
16
+ * outside `dir` throws rather than importing code from elsewhere on the machine. */
17
+ export declare function resolveEntryPath(dir: string, entry: string): string;
13
18
  export interface RunProgramArgs {
14
19
  /** Run id — used for the temp dir path + correlation. */
15
20
  runId: string;
@@ -21,7 +21,7 @@
21
21
  // (no checkpoint, no exit). A crash mid-run restarts the run from the top (handled by the
22
22
  // worker/scheduler-sweep, not here). Output capture is deferred (v0 returns null).
23
23
  import { mkdir, writeFile, rm, symlink, lstat } from "node:fs/promises";
24
- import { dirname, join } from "node:path";
24
+ import { dirname, join, isAbsolute, relative, sep } from "node:path";
25
25
  import { createRequire } from "node:module";
26
26
  import { pathToFileURL } from "node:url";
27
27
  import { randomUUID } from "node:crypto";
@@ -69,6 +69,18 @@ export async function ensureSdkLink(execDir) {
69
69
  }
70
70
  }
71
71
  }
72
+ /** Resolve the program's entry module inside the extraction dir, refusing any path that escapes it.
73
+ * The control plane validates `entry` at deploy, but a self-hosted runner may be pointed at an
74
+ * arbitrary control plane, so this is defense-in-depth: an absolute path or a `..` that resolves
75
+ * outside `dir` throws rather than importing code from elsewhere on the machine. */
76
+ export function resolveEntryPath(dir, entry) {
77
+ const resolved = join(dir, ...entry.split("/"));
78
+ const rel = relative(dir, resolved);
79
+ if (isAbsolute(entry) || rel === "" || rel.startsWith("..") || rel.startsWith(`..${sep}`)) {
80
+ throw new AppError(ErrorCode.VALIDATION_FAILED, `Program entry "${entry}" escapes the program directory.`);
81
+ }
82
+ return resolved;
83
+ }
72
84
  /** Scratch filename for the in-flight artifact tarball inside a run's dir. */
73
85
  const ARTIFACT_FILE = "__program.tgz";
74
86
  /**
@@ -96,7 +108,10 @@ export async function runWorkflowProgram(args, deps) {
96
108
  // Make the bare `@boardwalk-labs/workflow` import resolve from ANY work root — hosted images
97
109
  // provide it via an ancestor node_modules, but a self-hosted daemon's workspace has none.
98
110
  await ensureSdkLink(dir);
99
- const entryPath = join(dir, ...args.entry.split("/"));
111
+ // Re-validate the entry here even though the control plane checked it at deploy: a self-hosted
112
+ // runner may point at an arbitrary control plane, so refuse an entry that could escape the
113
+ // extraction dir (absolute path or `..` segment) before importing it.
114
+ const entryPath = resolveEntryPath(dir, args.entry);
100
115
  // Run the program body. A SUSPEND is surfaced two ways and both land here as a `suspended`
101
116
  // result: (a) out of band via `suspendSignal` — racing the body, immune to a program's own
102
117
  // try/catch (the suspending seam never resolves); (b) a thrown {@link SuspendError} on the
@@ -13,6 +13,9 @@
13
13
  import { execFile } from "node:child_process";
14
14
  import { promisify } from "node:util";
15
15
  import { stat, mkdir, readFile, writeFile, rm } from "node:fs/promises";
16
+ import { tmpdir } from "node:os";
17
+ import { dirname, join } from "node:path";
18
+ import { extract as tarExtract } from "tar";
16
19
  import { createLogger } from "./support/index.js";
17
20
  const log = createLogger("WorkspaceStore");
18
21
  const exec = promisify(execFile);
@@ -30,7 +33,12 @@ export class WorkspaceStore {
30
33
  maxSnapshotBytes;
31
34
  constructor(deps) {
32
35
  this.deps = deps;
33
- this.tmpPath = deps.tmpPath ?? "/tmp/workspace-snapshot.tgz";
36
+ // Scratch tarball path. Default to os.tmpdir() (which honors TMPDIR) rather than a machine-global
37
+ // `/tmp/workspace-snapshot.tgz`: on a self-hosted runner the daemon points TMPDIR at the PER-RUN
38
+ // dir, so the snapshot can't collide between concurrent daemons and doesn't sit in world-shared
39
+ // `/tmp` where the crashed-window archive of a whole workspace would be readable. On the hosted
40
+ // single-tenant worker it's the container's own `/tmp` (one run per container).
41
+ this.tmpPath = deps.tmpPath ?? join(tmpdir(), "bw-workspace-snapshot.tgz");
34
42
  this.maxSnapshotBytes = deps.maxSnapshotBytes ?? WORKSPACE_SNAPSHOT_MAX_BYTES;
35
43
  }
36
44
  /** Restore the workflow's last `/workspace` snapshot at run start. No-op (logged) on any failure or
@@ -43,6 +51,7 @@ export class WorkspaceStore {
43
51
  const bytes = await this.deps.broker.downloadBytes(url);
44
52
  if (bytes === null)
45
53
  return; // 404 — no snapshot yet (the workflow's first run)
54
+ await mkdir(dirname(this.tmpPath), { recursive: true }); // per-run TMPDIR may not exist yet
46
55
  await this.deps.fs.writeFile(this.tmpPath, bytes);
47
56
  await this.deps.archiver.extract(this.tmpPath, this.deps.workspaceRoot);
48
57
  await this.deps.fs.rm(this.tmpPath);
@@ -62,6 +71,7 @@ export class WorkspaceStore {
62
71
  * only redundant archive is a self-hosted+persist run, where the broker returns a null URL. */
63
72
  async persist() {
64
73
  try {
74
+ await mkdir(dirname(this.tmpPath), { recursive: true }); // per-run TMPDIR may not exist yet
65
75
  const size = await this.deps.archiver.archive(this.deps.workspaceRoot, this.tmpPath);
66
76
  // Guardrail: an oversized snapshot is dropped (logged), never read into memory or uploaded — the
67
77
  // workflow re-does filesystem work next run, as it would without persistence. Checked on the
@@ -98,7 +108,13 @@ export class TarWorkspaceArchiver {
98
108
  }
99
109
  async extract(srcPath, dir) {
100
110
  await mkdir(dir, { recursive: true });
101
- await exec("tar", ["xzf", srcPath, "-C", dir]);
111
+ // Extract via node-tar, NOT `tar xzf`: extraction reads UNTRUSTED input (a program artifact
112
+ // from an arbitrary control plane on a self-hosted runner, or a prior run's workspace snapshot),
113
+ // and node-tar is hardened + portable — with default `preservePaths: false` it strips absolute
114
+ // paths and `..` members, and refuses to write THROUGH a symlink (unlinking it first), closing
115
+ // the traversal/symlink-escape gaps that differ between GNU tar and bsdtar. Same behavior on
116
+ // macOS and Linux, no dependency on which `tar` the OS ships.
117
+ await tarExtract({ file: srcPath, cwd: dir });
102
118
  }
103
119
  }
104
120
  /** Production fs — node's fs/promises. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boardwalk-labs/runner",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Boardwalk self-hosted runner: execute cloud-scheduled runs on your own machines. Currently: the canonical runner contract types.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {