Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,11 @@ FOREMAN_WORKSPACE_ALLOWED_SCOPE=
FOREMAN_VALIDATION_COMMANDS=[]
FOREMAN_VALIDATION_TIMEOUT_MS=120000
FOREMAN_VALIDATION_MAX_OUTPUT_BYTES=1048576
# Every validation command runs inside a bubblewrap (bwrap) sandbox that hides your home
# directory, credentials, Foreman's data dir, the source checkout, /tmp and /run. Validation
# fails if bwrap is missing. Set none only on hosts without bubblewrap (for example macOS);
# it runs Worker-authored code directly as you, with your credentials.
FOREMAN_VALIDATION_SANDBOX=bwrap
# Comma-separated absolute paths mounted read-only in the sandbox, for toolchains that live
# under a hidden directory and are not found automatically (for example $HOME/.volta).
FOREMAN_VALIDATION_SANDBOX_RO_PATHS=
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,23 @@ When you open a repository, Foreman inspects `.github/workflows` and suggests ma

Validation commands run in a disposable copy of the Worker workspace. Every configured command must pass. Commands absent from the list are not run or inferred.

The suggested allowed scope covers the top-level tracked paths except CI configuration (`.github/`, `.gitlab-ci.yml`, `.circleci/`, `.buildkite/`, `azure-pipelines.yml`, `Jenkinsfile`, `.travis.yml`), which runs with repository secrets once pushed. Add those paths by hand if a task really has to change them.

The checks pipeline in the review panel shows local Foreman results alongside remote GitHub CI status. CI failure excerpts are fetched from `GET /api/runs/:id/github/ci-failures`.

### Validation sandbox

The Worker's files are untrusted, and validation runs them (`pnpm install` lifecycle scripts, test files). Foreman therefore runs every validation command inside a [bubblewrap](https://github.com/containers/bubblewrap) (`bwrap`) sandbox by default. On Linux, install it with `apt install bubblewrap` or `dnf install bubblewrap`. If `bwrap` is missing or cannot create a sandbox, validation fails with an error and does not run on the host.

- **Hidden from the command:** your home directory (`/home`, `/root`, `$HOME`), Foreman's data directory, the source repository checkout, `/tmp`, `/var/tmp`, `/run` (Docker and D-Bus sockets, the user runtime directory), `/mnt`, `/media` and `/srv` are replaced by empty temporary directories. The rest of the host is mounted read-only.
- **Provided:** the workspace, read-write at `/tmp/workspace`; a fresh `HOME`; and an environment of only `PATH`, `LANG`, `LC_ALL`, `HOME`, `TMPDIR` and the cache variables below. The command has its own PID, IPC and UTS namespaces and no capabilities. A timeout or output overflow kills its whole process tree.
- **Toolchains under a hidden directory** (nvm, pnpm in `~/.local`) are mounted back read-only: `PATH` entries, the install prefix of the command and of `node` (when it is at least two levels below the home directory), and the paths in `FOREMAN_VALIDATION_SANDBOX_RO_PATHS`. Version managers that keep state beside their shims (Volta `~/.volta`, asdf `~/.asdf`, rustup `~/.rustup`, Homebrew on Linux) need their directory listed there.
- **Package-manager cache:** `<data dir>/validation-cache` (owner-only) is mounted read-write at `/tmp/foreman-cache`, with `XDG_CACHE_HOME`, `XDG_DATA_HOME`, `npm_config_cache`, `npm_config_store_dir` and `pnpm_config_store_dir` (pnpm 10 and 11), `YARN_CACHE_FOLDER` and `COREPACK_HOME` pointing into it, so installs do not download everything each time. It is shared by every validation and project. pnpm and npm verify package integrity against the lockfile, but a command that ran hostile code could still leave bad data behind, for example a swapped pnpm binary under `XDG_DATA_HOME`. Delete the directory if you suspect that.
- **Accepted risk, network access:** the sandbox keeps the host network because installs need it. A validation command can reach the internet, any service listening on localhost (including Foreman's own API) and the host's abstract-namespace Unix sockets, though it does not receive your proxy or credential variables. Do not treat the sandbox as a network boundary.
- Run Foreman as an unprivileged user. The sandbox hides paths but cannot stop a root process from reading root-only files that remain visible, such as `/etc/shadow`.

`FOREMAN_VALIDATION_SANDBOX=none` is an explicit, unsafe opt-out for hosts without bubblewrap (for example macOS). Worker-authored code then runs directly on your machine with your credentials. Foreman prints a warning at startup, marks each check with the mode it ran in, and reports `validationSandbox: {mode, available}` from `GET /api/status`. Foreman's own tests run validation through the sandbox, so `pnpm test` needs `bwrap` as well.

## Reviewing and approving results

When a run reaches `awaiting_approval`, the **Ready for your review** panel appears at the top of the run view. It shows:
Expand Down Expand Up @@ -128,6 +143,8 @@ The standard local flow requires no environment variables. The following setting
| `FOREMAN_VALIDATION_COMMANDS` | — | JSON array of `{"name","command","args","cwd?"}` entries run in the disposable validation workspace. |
| `FOREMAN_VALIDATION_TIMEOUT_MS` | `120000` | Per-command time limit (max 600,000 ms). |
| `FOREMAN_VALIDATION_MAX_OUTPUT_BYTES` | `1048576` | Per-command output capture bound (max 16 MiB). |
| `FOREMAN_VALIDATION_SANDBOX` | `bwrap` | `bwrap` runs every validation command in a bubblewrap sandbox and fails if it is unavailable. `none` runs them directly on the host with your credentials, and is unsafe. See [Validation sandbox](#validation-sandbox). |
| `FOREMAN_VALIDATION_SANDBOX_RO_PATHS` | — | Comma-separated absolute paths mounted read-only in the sandbox, for toolchains under a hidden directory such as `$HOME/.volta`. Paths that do not exist are ignored. |

Planner and Orchestrator turn timeouts are fixed at 300 seconds and are not configurable via environment variable. The optional configuration shape is recorded in `config.schema.json`.

Expand Down
4 changes: 3 additions & 1 deletion config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
"FOREMAN_WORKSPACE_ALLOWED_SCOPE": { "type": "string", "description": "Comma-separated exact paths or directory prefixes ending in / allowed for Worker changes" },
"FOREMAN_VALIDATION_COMMANDS": { "type": "string", "description": "JSON array of validation command objects with explicit command and args" },
"FOREMAN_VALIDATION_TIMEOUT_MS": { "type": "integer", "minimum": 1, "maximum": 600000, "default": 120000 },
"FOREMAN_VALIDATION_MAX_OUTPUT_BYTES": { "type": "integer", "minimum": 1, "maximum": 16777216, "default": 1048576 }
"FOREMAN_VALIDATION_MAX_OUTPUT_BYTES": { "type": "integer", "minimum": 1, "maximum": 16777216, "default": 1048576 },
"FOREMAN_VALIDATION_SANDBOX": { "type": "string", "enum": ["bwrap", "none"], "default": "bwrap", "description": "bwrap runs every validation command inside a bubblewrap sandbox and fails closed when it is unavailable; none runs them unsandboxed on the host (unsafe opt-out)" },
"FOREMAN_VALIDATION_SANDBOX_RO_PATHS": { "type": "string", "description": "Comma-separated absolute paths (for example a toolchain under your home directory) mounted read-only into the validation sandbox" }
}
}
15 changes: 12 additions & 3 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { isAbsolute, resolve } from 'node:path';
import { loadEnvFile } from 'node:process';
import { parseSandboxMode, type ValidationSandboxMode } from './validation-sandbox.js';

export interface ForemanConfig {
host: string;
Expand All @@ -19,6 +20,7 @@ export interface ForemanConfig {
validationCommands: Array<{name:string;command:string;args:string[];cwd?:string}>;
validationTimeoutMs: number;
validationMaxOutputBytes: number;
validationSandbox: { mode: ValidationSandboxMode; roPaths: string[]; dataDir: string; cacheDir: string };
}

function integer(name: string, fallback: number, min: number, max: number): number {
Expand Down Expand Up @@ -52,10 +54,16 @@ export function loadConfig(): ForemanConfig {
const rawCommands=process.env.FOREMAN_VALIDATION_COMMANDS?.trim();
if(rawCommands){try{const value=JSON.parse(rawCommands);if(!Array.isArray(value))throw new Error();validationCommands=value.map((item:unknown)=>{if(!item||typeof item!=='object')throw new Error();const x=item as Record<string,unknown>;if(typeof x.name!=='string'||!x.name.trim()||typeof x.command!=='string'||!x.command||!Array.isArray(x.args)||x.args.some(arg=>typeof arg!=='string')||(x.cwd!==undefined&&typeof x.cwd!=='string'))throw new Error();return {name:x.name,command:x.command,args:x.args as string[],...(typeof x.cwd==='string'?{cwd:x.cwd}:{})};});}catch{throw new Error('FOREMAN_VALIDATION_COMMANDS must be a JSON array of {name,command,args,cwd?}');}}
const workspaceAllowedScope=(process.env.FOREMAN_WORKSPACE_ALLOWED_SCOPE??'').split(',').map(x=>x.trim()).filter(Boolean);
const sandboxMode = parseSandboxMode(process.env.FOREMAN_VALIDATION_SANDBOX);
if (!sandboxMode) throw new Error('FOREMAN_VALIDATION_SANDBOX must be "bwrap" or "none"');
if (sandboxMode === 'none') process.stderr.write('WARNING: FOREMAN_VALIDATION_SANDBOX=none: Worker-authored validation commands run directly on this host with your credentials and network access\n');
const sandboxRoPaths = (process.env.FOREMAN_VALIDATION_SANDBOX_RO_PATHS ?? '').split(',').map(x => x.trim()).filter(Boolean);
if (sandboxRoPaths.some(path => !isAbsolute(path))) throw new Error('FOREMAN_VALIDATION_SANDBOX_RO_PATHS must be a comma-separated list of absolute paths');
const dataDir = resolve(process.env.FOREMAN_DATA_DIR?.trim() || '.foreman-data');
return {
host: process.env.FOREMAN_HOST?.trim() || '127.0.0.1',
port: integer('FOREMAN_PORT', 4399, 1, 65535),
dataDir: resolve(process.env.FOREMAN_DATA_DIR?.trim() || '.foreman-data'),
dataDir,
uhpBaseUrl: optionalHttpUrl('UHP_BASE_URL'),
uhpHarnessId,
uhpModel,
Expand All @@ -68,6 +76,7 @@ export function loadConfig(): ForemanConfig {
workspaceAllowedScope,
validationCommands,
validationTimeoutMs: integer('FOREMAN_VALIDATION_TIMEOUT_MS', 120000, 1, 600000),
validationMaxOutputBytes: integer('FOREMAN_VALIDATION_MAX_OUTPUT_BYTES', 1048576, 1, 16777216)
validationMaxOutputBytes: integer('FOREMAN_VALIDATION_MAX_OUTPUT_BYTES', 1048576, 1, 16777216),
validationSandbox: { mode: sandboxMode, roPaths: sandboxRoPaths.map(path => resolve(path)), dataDir, cacheDir: resolve(dataDir, 'validation-cache') }
};
}
Loading
Loading