foldrun 5.0.1 → 5.0.2

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/docs/api.md CHANGED
@@ -335,7 +335,7 @@ Reads return which secrets exist, never their values.
335
335
 
336
336
  | Route | Methods | |
337
337
  |---|---|---|
338
- | `/api/keys` | GET, POST, DELETE | API keys — an admin's (`keys:manage`) to list, mint and revoke; any key may revoke itself (DELETE with its own `id`, audited as self — what `foldrun logout` does). POST `label`, `role` (viewer \| editor \| admin, never above your own, default editor), `workspaces` (names, or `null` for all — never wider than your own scope); `expires` (`30d`, `90d`, `365d`, any number of days up to 3650, or `never` — the default; anything else is a 400, never a key that lives forever); returns `{ ok, key, id, prefix, role, workspaces, expiresAt }`, and the key is shown once. GET lists each key with `createdAt`, `createdBy`, `expiresAt`, `lastUsedAt`, `lastIp` and `replacedBy` (the id of the key a rotation replaced it with; `null` until then) — the last-use pair written at most once a minute per key, the address cut to its /24 (IPv4) or /48 (IPv6). A key past its `expiresAt` is refused with 401 `API key expired on <date>`; the owner is emailed seven days before. Mint, revoke and rotate are written to the audit log. POST with `workspace` or `access` is refused (400): those minted deploy keys for the platform's own git, which went on 17 Oct 2026 — a deploy key minted before is refused everywhere (403). A key for one workspace is `workspaces: ["<ws>"]`. DELETE `id`. `?tenant=` / body `tenant` is read only on an install with no users yet (first-run bootstrap) |
338
+ | `/api/keys` | GET, POST, DELETE | API keys — an admin's (`keys:manage`) to list, mint and revoke; any key may revoke itself (DELETE with its own `id`, audited as self — what `foldrun logout` does). POST `label`, `role` (viewer \| editor \| admin, never above your own, default editor), `workspaces` (names, or `null` for all — never wider than your own scope); `expires` (`30d`, `90d`, `365d`, any number of days up to 3650, or `never` — the default; anything else is a 400, never a key that lives forever); returns `{ ok, key, id, prefix, role, workspaces, expiresAt }`, and the key is shown once. GET lists each key with `createdAt`, `createdBy`, `expiresAt`, `lastUsedAt`, `lastIp` and `replacedBy` (the id of the key a rotation replaced it with; `null` until then) — the last-use pair written at most once a minute per key, the address cut to its /24 (IPv4) or /48 (IPv6). A key past its `expiresAt` is refused with 401 `API key expired on <date>`; the owner is emailed seven days before. Mint, revoke and rotate are written to the audit log. POST with `workspace` or `access` is refused (400): those minted deploy keys for the platform's own git, which went on 17 Oct 2026 — a deploy key minted before is refused everywhere (403). A key for one workspace is `workspaces: ["<ws>"]`. DELETE `id`. `?tenant=` / body `tenant` is read only on an install with no users yet (first-run bootstrap), and there, when the install has a setup token (`FOLDRUN_SETUP_TOKEN`, written by the self-hosting installer), only with that token in `X-Foldrun-Setup-Token` |
339
339
  | `/api/keys/<id>/rotate` | POST | a new key with the old one's label, role, workspaces and deploy scope — and, when it had an end, the same lifetime from today — returned once like a mint (`{ ok, key, id, prefix, role, workspaces, expiresAt, replaced: { id, endsAt } }`). `grace`: `0` (default — the old key is revoked at once), or a duration up to `7d` such as `1h` or `24h`, during which the old key still works and after which it is refused as expired. A key is rotated once: rotating a key already replaced is 409 `this key was already rotated to <prefix>…; rotate that one` — and someone who lost the new key's secret rotates the NEW key, which mints another and ends the lost one (rotating the old one again would leave the lost key working). Two rotations of one key at once mint one key. `keys:manage`; a key outside the caller's scope is 404. Audited as `apikey.rotated` |
340
340
  | `/api/team` | GET, POST | members with their roles and workspace scope; mint an invite — `role` (viewer \| editor \| admin, default editor), `workspaces` (names, or omit for all), `email` (bound into the invite: only that address can accept it; sent through the account's `RESEND_API_KEY` unless `send: false`). Returns `path` (the invite's relative URL), `expires`, `sent` and any `sendError` either way; the full link is in the mail |
341
341
  | `/api/team/members/<id>` | PATCH, DELETE | change a member's role (`role`) and/or which workspaces they may open (`workspaces`: names, or null for all); remove them |
package/docs/evals.md CHANGED
@@ -47,9 +47,9 @@ satisfy. The heading is the case's name, so make it the thing being checked.
47
47
  |---|---|
48
48
  | `contains:` | the text must appear |
49
49
  | `not-contains:` | the text must not appear |
50
- | `matches:` | a JavaScript regular expression over the reply, case-insensitive. No inline flags such as `(?m)`: write `(^\|\n)` for the start of a line. `foldrun check` reports one that does not compile |
50
+ | `matches:` | a JavaScript regular expression over the reply, case-insensitive, given a quarter of a second (a pattern that backtracks fails the check). No inline flags such as `(?m)`: write `(^\|\n)` for the start of a line. `foldrun check` reports one that does not compile |
51
51
  | `file:` | the path exists and is non-empty |
52
- | `run:` | a shell command that must exit 0 |
52
+ | `run:` | a shell command that must exit 0 — `foldrun eval` on your machine only: an install that isolates runs refuses it (the check fails, saying so), because there it would run on the platform's own web server rather than in a sandbox |
53
53
  | `judge:` | a model grades the reply against this sentence |
54
54
 
55
55
  **Deterministic checks run first and cost nothing.** The judge only runs if they
package/docs/security.md CHANGED
@@ -152,6 +152,30 @@ name only — for the owner and admins to read or export. Support can view an
152
152
  account only [read-only, for thirty minutes, with a reason the customer
153
153
  reads](team-and-access#support-access).
154
154
 
155
+ Signing in, in particular:
156
+
157
+ - a password reset or change, two-step sign-in turned on or off, and a new
158
+ API key each send a notice to the person's address;
159
+ - signing out ends the session on the server, not just in the browser;
160
+ - a two-step code works once; replacing a second factor means turning it
161
+ off first, which needs the password and a code;
162
+ - the sign-in forms refuse a request from another site, and `?next=` only
163
+ ever leads to a page on the same site;
164
+ - `foldrun login` asks you to type the code your terminal shows — a link
165
+ with the code in it, sent by someone else, cannot sign their machine in
166
+ as you;
167
+ - before the first sign-up, a self-hosted install's setup token guards key
168
+ minting as it guards sign-up;
169
+ - only the account's owner can set the account's `RESEND_API_KEY` and
170
+ `EMAIL_FROM`, because that sender carries everyone's reset links;
171
+ - a member or key limited to some workspaces cannot change what every
172
+ workspace shares — the library, the account's AGENTS.md defaults, its
173
+ model provider, its OAuth apps — nor people who work outside its
174
+ workspaces.
175
+
176
+ Frontmatter is YAML only: a file opened with `---js` (which some parsers
177
+ run as code) is refused.
178
+
155
179
  ## The dashboard in your browser
156
180
 
157
181
  Every page is sent with an enforced Content-Security-Policy: scripts run
package/docs/tools.md CHANGED
@@ -1890,7 +1890,13 @@ tool is broken" and "the agent used it wrong", and it costs no model call.
1890
1890
 
1891
1891
  Every tool has a Test button, and `POST /api/workspaces/<ws>/tools/<tool>/test`
1892
1892
  behind it: it calls the tool with typed arguments from where a run would call
1893
- it, shows the raw result, and costs no model call. A single-file tool that
1893
+ it, shows the raw result, and costs no model call. On an install that
1894
+ isolates runs (the hosted platform, a Docker or Kubernetes self-host) a
1895
+ script tool or an MCP server started by `command:` is your own code, which
1896
+ runs only inside a step's sandbox — the button says so and points to
1897
+ `foldrun tool test` on your machine, or a test run. An HTTP tool is tested
1898
+ as before, except that a secret bound to sites
1899
+ (`foldrun secrets sites`) is never sent to a host outside them. A single-file tool that
1894
1900
  carries its program in a fenced block is materialised for the test exactly as
1895
1901
  a call materialises it, so the test runs the identical program from the
1896
1902
  identical directory — for a long time it did not, and every fenced-code tool
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "foldrun",
3
- "version": "5.0.1",
3
+ "version": "5.0.2",
4
4
  "description": "Agents are just folders. Write them, check them, run them — on your machine.",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "type": "module",
package/src/commands.mjs CHANGED
@@ -3835,6 +3835,13 @@ async function connect(positional, flags) {
3835
3835
  const DEFAULT_PLATFORM = "https://app.foldrun.io";
3836
3836
 
3837
3837
  function openInBrowser(target) {
3838
+ // A server-supplied address: only a web page, never a file, an app or a
3839
+ // custom scheme handed to `open`.
3840
+ try {
3841
+ if (!/^https?:$/.test(new URL(target).protocol)) return Promise.resolve(false);
3842
+ } catch {
3843
+ return Promise.resolve(false);
3844
+ }
3838
3845
  const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
3839
3846
  return import("node:child_process")
3840
3847
  .then(({ spawn }) => {
@@ -4872,11 +4879,13 @@ async function pullCmd(layout, flags, only) {
4872
4879
  /** @type {{file: string, content: string}[]} */
4873
4880
  const incoming = [];
4874
4881
  for (const name of names) {
4882
+ if (!SAFE_NAME.test(name)) throw new Error(`the platform named a workspace "${name}", which is not a workspace name — refusing to write it`);
4875
4883
  const there = await remoteWorkspaceFiles(url, flags, name, isSourcePath);
4876
- for (const [rel, content] of there) incoming.push({ file: path.join(wsRoot, name, rel), content });
4884
+ for (const [rel, content] of there) incoming.push({ file: within(path.join(wsRoot, name), rel), content });
4877
4885
  }
4878
4886
  for (const f of await remoteLibrary(url, flags, LIBRARY_KINDS)) {
4879
- incoming.push({ file: path.join(root, "library", f.kind, f.path), content: f.content });
4887
+ if (!LIBRARY_KINDS.includes(f.kind)) throw new Error(`the platform sent a library kind "${f.kind}" this CLI does not know — refusing to write it`);
4888
+ incoming.push({ file: within(path.join(root, "library", f.kind), f.path), content: f.content });
4880
4889
  }
4881
4890
 
4882
4891
  const clashes = incoming.filter((f) => {
@@ -9299,6 +9308,22 @@ async function runRmCmd(runId, flags, layout) {
9299
9308
  return 0;
9300
9309
  }
9301
9310
 
9311
+ const SAFE_NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
9312
+
9313
+ /**
9314
+ * `rel` under `base`, or a refusal. Every path a server names is written
9315
+ * through this: a server (or anyone in between) must not be able to say
9316
+ * "../../.ssh/authorized_keys" and have pull write it.
9317
+ */
9318
+ function within(base, rel) {
9319
+ const root = path.resolve(base);
9320
+ const to = path.resolve(root, String(rel));
9321
+ if (path.isAbsolute(String(rel)) || (to !== root && !to.startsWith(root + path.sep))) {
9322
+ throw new Error(`the platform sent a path outside ${root} ("${rel}") — refusing to write it`);
9323
+ }
9324
+ return to;
9325
+ }
9326
+
9302
9327
  /** Where downloaded bytes go: --file (or - for stdout), else a default name. Refuses to clobber. */
9303
9328
  function writeDownload(bytes, fallback, flags, from) {
9304
9329
  if (flags.file === "-") {
@@ -9361,7 +9386,11 @@ async function remoteBytes(url, flags, apiPath, what) {
9361
9386
  }
9362
9387
  throw new HttpError(`${what}: ${why}${res.status === 401 || res.status === 403 ? refusedHint(url, flags) : ""}`, res.status, {});
9363
9388
  }
9364
- const name = /filename="([^"]+)"/.exec(res.headers.get("content-disposition") ?? "")?.[1];
9389
+ // The server's name is a suggestion for a file in THIS folder: only its
9390
+ // last segment, and never a dot-file (".zshrc", "..").
9391
+ const said = /filename="([^"]+)"/.exec(res.headers.get("content-disposition") ?? "")?.[1];
9392
+ const base = said ? path.basename(said.replaceAll("\\", "/")) : undefined;
9393
+ const name = base && !base.startsWith(".") ? base : undefined;
9365
9394
  return { bytes: Buffer.from(await res.arrayBuffer()), name };
9366
9395
  }
9367
9396
 
@@ -93,6 +93,13 @@ function write(current, profiles) {
93
93
  JSON.stringify({ current, default: current && profiles[current] ? normaliseUrl(profiles[current].url) : null, profiles, platforms }, null, 2) + "\n",
94
94
  { mode: 0o600 },
95
95
  );
96
+ // `mode` applies only when the file is created; one made before (or by
97
+ // hand) keeps whatever it had. It holds tokens: owner-only, every write.
98
+ try {
99
+ fs.chmodSync(file(), 0o600);
100
+ } catch {
101
+ /* not ours to fix (Windows ACLs, a read-only home) */
102
+ }
96
103
  }
97
104
 
98
105
  /**