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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ A TypeScript monorepo. `@pickforge/picklab` is the published package; the rest a
## Security model

- MCP tools never invoke sudo. Privileged provisioning happens only through the CLI (`picklab setup lab-user`, or `init` with explicit `--create-lab-user`), with explicit consent (`--yes` or a prompt).
- Privileged provisioning commands run through graphical `sudo` (`sudo -A`) on Linux, never a plain terminal password prompt: PickLab detects a graphical session (`WAYLAND_DISPLAY`/`DISPLAY`) and a `SUDO_ASKPASS` helper (your own `SUDO_ASKPASS`, or the first of `ksshaskpass`/`ssh-askpass`/`lxqt-openssh-askpass`/the standard distro paths) before spawning anything privileged, and injects `SUDO_ASKPASS` — the only environment variable this feature ever adds — into that one command. PickLab never ships, generates, or installs its own askpass helper, and never captures, logs, or persists the password prompt. macOS/Windows are out of scope for this release: no graphical prompt is attempted there. If no graphical session or helper is available (headless, SSH, CI, or a missing helper), or the platform isn't Linux, the command fails closed with an actionable error naming the manual fallback — run the same command yourself with `sudo` in a terminal. A cancelled or denied graphical prompt surfaces as a distinct failure with no automatic retry, and nothing about the prompt is written to logs, config, or run artifacts.
- All user inputs are spawned as argument arrays — never interpolated into shell strings.
- The DevTools relay validates the installed upstream package name, exact version, declared bin, and confined real path before spawning Node with an argument array. Its browser URL is always derived as `http://127.0.0.1:<session-cdp-port>`.
- Relay stdout is protocol-only. A pending JSON-RPC record is capped at 16 MiB. Upstream diagnostic lines are capped at 64 KiB, redacted, and forwarded only to stderr; an over-limit line is dropped with a safe notice. Upstream update checks and usage statistics are disabled.
Expand Down
46 changes: 46 additions & 0 deletions docs/releases/UNRELEASED.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@ release description, then reset it after the release is published.

## User-facing changes

- **Privileged provisioning now runs through graphical `sudo` on Linux
(`picklab setup lab-user`, `picklab init --create-lab-user`,
`picklab doctor --fix`).** Instead of a plain terminal password prompt,
PickLab detects a graphical session (`WAYLAND_DISPLAY`/`DISPLAY`) and a
`SUDO_ASKPASS` helper (your own `SUDO_ASKPASS`, or the first of
`ksshaskpass`/`ssh-askpass`/`lxqt-openssh-askpass`/the standard distro
paths) before spawning anything privileged, then runs `sudo -A` with that
helper — the only environment variable this feature injects. PickLab never
ships or installs its own helper, and never captures, logs, or persists the
password prompt. Headless sessions, a missing helper, or a non-Linux
platform (macOS/Windows are out of scope this release) fail closed with an
actionable error naming the manual `sudo ...` fallback — no automatic
fallback to a plain interactive password prompt. A cancelled or denied
graphical prompt surfaces as a distinct failure with no retry. See the
README's "Security model" section. (pickforge/picklab#27)
- **Default run storage changed.** Screenshots, logs, manifests, and evidence
journals now default to `~/.pickforge/picklab/projects/<projectId>/runs/`
(outside the project) instead of `<project>/.picklab/runs/`, so a default
Expand Down Expand Up @@ -75,6 +90,16 @@ release description, then reset it after the release is published.
- Centralized CLI provisioning policy for plan classification, consent,
dry-runs, ordered preflight failures/skips, adapter-owned sudo routing,
cancellation, redacted presentation plans, and partial results.
- Added `packages/cli/src/provision/askpass.ts`, a pure four-state capability
detector (`available`/`no-helper`/`headless`/`unsupported-platform`)
implementing the "Shared graphical sudo (askpass) security contract —
locked v1" shared with pickforge/pickforge#215/#258
(`crates/pickforge-core/src/process/askpass.rs`); wired it into the
provisioning executor's privileged-step materialization (`sudo -A` +
`SUDO_ASKPASS`, arg-array only) and into `setup lab-user`/`doctor
--fix`/`init`. sudo-level denial/cancellation (detected from sudo's own
`sudo:`-prefixed diagnostics, never prompt text) now surfaces as a distinct
`cancelled` executor status instead of a generic failure.
- Added a framing-aware DevTools NDJSON relay with protocol validation,
backpressure, bounded diagnostics, redacted failures, and evidence hooks.
- Added atomic, crash-recoverable evidence journals, active-run ownership,
Expand All @@ -101,6 +126,23 @@ release description, then reset it after the release is published.
overrides) and `run-catalog.test.ts`'s "openRunCatalog storage modes" suite
(home default, legacy project-local discovery, project isolation, custom
mode), plus a `git status --porcelain` repo-cleanliness smoke test.
- New `askpass.test.ts` (capability detection: headless/graphical, user-set
vs. probe-list priority, empty-value handling, Linux/non-Linux gating) and
an extended `executor.test.ts` "privileged execution via graphical sudo"
suite (materialization to `sudo -A` + `SUDO_ASKPASS`, env-propagation
proving only that key is added, arg-array safety for hostile argv,
preflight failure for each non-available capability state, sudo
cancellation/denial via a stand-in `sudo` script surfacing a distinct
`cancelled` status with no retry, and redaction of a planted secret in a
sudo denial message). Extended `cli.test.ts` with real end-to-end coverage
routing `setup lab-user`/`init --create-lab-user` through a fake graphical
`sudo -A` + `SUDO_ASKPASS` helper, plus platform-appropriate manual-fallback
assertions for the no-session/no-helper/non-Linux cases. The
happy-path/cancellation `cli.test.ts` cases that need a real Linux
graphical-session capability are `skipIf(process.platform !== "linux")`
(this dev sandbox is macOS); they run in full on Linux CI.
Real `sudo -A -v` hardware smoke (a live graphical prompt, not a stand-in)
is not covered here and is deferred to a real Linux desktop.
- `bun run test` — same pre-existing failure set as unmodified `main`,
verified test-by-test against `main` on this branch's (macOS) dev sandbox.
These are platform-environment failures, not specific to this change; on
Expand All @@ -120,6 +162,10 @@ release description, then reset it after the release is published.
Darwin-only failures above stop the process before the v8 coverage
provider flushes it — reproduced identically on unmodified `main`).
- Platform smoke checks outside Linux.
- Real `sudo -A -v` graphical-prompt smoke on a live Linux desktop (approve
and cancel) — the automated suite covers this with a stand-in `sudo`/
askpass-path script per test, never a real graphical prompt; a real-desktop
smoke is deferred.
- Live remote SSH-tunnel smoke test.
- Tag-triggered npm publish and draft-release creation (runs only after merge and
tag push).
Expand Down
10 changes: 9 additions & 1 deletion packages/cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { EnvLike } from "@pickforge/picklab-core";
import { resolveAskpassCapability } from "../provision/askpass.js";
import {
evaluateChecks,
formatCheckLine,
Expand All @@ -7,6 +8,7 @@ import {
import { collectSnapshot, type DetectionSnapshot } from "../provision/detect.js";
import {
executeProvisioning,
type ExecuteProvisioningResult,
type ProvisioningSection,
type StepResult,
} from "../provision/executor.js";
Expand All @@ -29,6 +31,10 @@ export interface DoctorCliOptions {

export interface DoctorFixReport {
dryRun: boolean;
/** Mirrors `ExecuteProvisioningResult.status` so `--json` consumers can
* distinguish a declined/cancelled/sudo-cancelled fix run from a generic
* failure without string-matching `steps`/errors. */
status: ExecuteProvisioningResult["status"];
steps: ProvisioningStep[];
skipped: string[];
results: StepResult[];
Expand Down Expand Up @@ -115,6 +121,7 @@ async function buildFixPlan(
reason: `lab-user: ${labUserPrivilegeUnavailableMessage(
snapshot.labUser.name,
)}`,
context: "lab-user: ",
},
consent: {
onDenied: "skip",
Expand Down Expand Up @@ -169,12 +176,13 @@ export async function runDoctor(
log,
privilege: {
sudoPath: snapshot.sudo,
nonInteractive: process.stdin.isTTY !== true,
askpass: resolveAskpassCapability(env),
},
},
);
report.fix = {
dryRun: opts.dryRun === true,
status: execution.status,
steps: execution.plan.steps,
skipped: execution.skipped,
results: execution.results,
Expand Down
11 changes: 10 additions & 1 deletion packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import path from "node:path";
import type { EnvLike, PicklabProfile } from "@pickforge/picklab-core";
import { resolveAskpassCapability } from "../provision/askpass.js";
import {
evaluateChecks,
formatCheckLine,
Expand All @@ -9,6 +10,7 @@ import {
import { collectSnapshot, type DetectionSnapshot } from "../provision/detect.js";
import {
executeProvisioning,
type ExecuteProvisioningResult,
type ProvisioningSection,
type StepResult,
} from "../provision/executor.js";
Expand All @@ -33,6 +35,11 @@ export interface InitCliOptions {

export interface InitReport {
ok: boolean;
/** Mirrors `ExecuteProvisioningResult.status` so `--json` consumers can
* distinguish a declined/cancelled/sudo-cancelled provisioning run from a
* generic failure without string-matching `errors`. `"failed"` before an
* execution ever runs. */
status: ExecuteProvisioningResult["status"];
profile: PicklabProfile;
projectDir: string;
dryRun: boolean;
Expand Down Expand Up @@ -182,6 +189,7 @@ export async function runInit(

const report: InitReport = {
ok: false,
status: "failed",
profile,
projectDir,
dryRun: opts.dryRun === true,
Expand All @@ -202,13 +210,14 @@ export async function runInit(
log,
privilege: {
sudoPath: snapshot.sudo,
nonInteractive: process.stdin.isTTY !== true,
askpass: resolveAskpassCapability(env),
},
},
);
report.plan = execution.plan.steps;
report.results = execution.results;
report.ok = execution.ok;
report.status = execution.status;
if (!execution.ok) {
const errors =
execution.errors.length > 0 ? execution.errors : ["provisioning failed"];
Expand Down
11 changes: 10 additions & 1 deletion packages/cli/src/commands/setup-lab-user.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { EnvLike } from "@pickforge/picklab-core";
import { resolveAskpassCapability } from "../provision/askpass.js";
import { collectSnapshot } from "../provision/detect.js";
import {
executeProvisioning,
type ExecuteProvisioningResult,
type StepResult,
} from "../provision/executor.js";
import type { ProvisioningStep } from "../provision/plan.js";
Expand All @@ -21,6 +23,11 @@ export interface SetupLabUserCliOptions {

export interface SetupLabUserReport {
ok: boolean;
/** Mirrors `ExecuteProvisioningResult.status` so `--json` consumers can
* distinguish a declined/cancelled/sudo-cancelled provisioning run from a
* generic failure without string-matching `errors`. `"failed"` before an
* execution ever runs (e.g. an invalid lab user name/home). */
status: ExecuteProvisioningResult["status"];
name: string;
home: string;
userExists: boolean;
Expand Down Expand Up @@ -51,6 +58,7 @@ export async function runSetupLabUser(
});
const report: SetupLabUserReport = {
ok: false,
status: "failed",
name: snapshot.labUser.name,
home: snapshot.labUser.home,
userExists: snapshot.labUser.exists,
Expand Down Expand Up @@ -108,7 +116,7 @@ export async function runSetupLabUser(
log,
privilege: {
sudoPath: snapshot.sudo,
nonInteractive: process.stdin.isTTY !== true,
askpass: resolveAskpassCapability(env),
},
beforeExecute: () => {
if (opts.json !== true && snapshot.labUser.exists) {
Expand All @@ -120,6 +128,7 @@ export async function runSetupLabUser(
report.plan = execution.plan.steps;
report.results = execution.results;
report.ok = execution.ok;
report.status = execution.status;
if (!execution.ok) {
report.errors.push(execution.error ?? "provisioning failed");
}
Expand Down
157 changes: 157 additions & 0 deletions packages/cli/src/provision/askpass.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// Linux graphical `sudo` (askpass) capability detection —
// pickforge/picklab#27.
//
// Implements the *detection* half of the "Shared graphical sudo (askpass)
// security contract — locked v1" pinned on that issue (shared with
// pickforge/pickforge#215, which ships the same semantics in
// crates/pickforge-core/src/process/askpass.rs): Linux graphical sessions
// only, a fixed probe list (no dynamic discovery, no bundled helper), and
// fail-closed when nothing resolves. The *injection* half — passing
// `SUDO_ASKPASS`, and only that variable, into the spawned privileged
// command — lives in ./executor.ts (materializePrivilegedStep), since
// PickLab programmatically issues `sudo -A` rather than hosting an
// interactive shell the way PickForge does.
//
// Scope: macOS/Windows are out of scope for this release.
// `detectAskpassCapability` is a pure function so every branch — including
// the user-set-vs-probe-list priority order — is directly testable on any
// host; only `resolveAskpassCapability` gates real filesystem probing to
// Linux, matching where the feature actually activates.

import fs from "node:fs";
import type { EnvLike } from "@pickforge/picklab-core";

/**
* Fixed, documented askpass helper probe list per the locked v1 contract —
* checked in this order after a user-set `SUDO_ASKPASS`. No dynamic
* discovery beyond this list; PickLab never ships, generates, or installs
* its own askpass helper.
*/
export const ASKPASS_PROBE_PATHS: readonly string[] = [
"/usr/bin/ksshaskpass",
"/usr/bin/ssh-askpass",
"/usr/bin/lxqt-openssh-askpass",
"/usr/bin/ssh-askpass-gnome",
"/usr/lib/ssh/ssh-askpass",
"/usr/lib/openssh/gnome-ssh-askpass",
"/usr/lib/seahorse/ssh-askpass",
];

/**
* The result of the pre-flight capability check, run before any privileged
* step materializes. Every state is distinct and observable per the
* contract's failure semantics — callers must not collapse `no-helper` and
* `headless` into one generic "unavailable".
*/
export type AskpassCapability =
/** A graphical session and a resolvable helper were both found. `helper`
* is the absolute path to hand to `SUDO_ASKPASS`. */
| { state: "available"; helper: string }
/** A graphical session was detected but no helper resolved: the user's
* `SUDO_ASKPASS` (if set) doesn't point at an executable file, and
* nothing on the fixed probe list exists either. */
| { state: "no-helper" }
/** No graphical session (`WAYLAND_DISPLAY`/`DISPLAY` both unset or empty
* in the resolved environment) — SSH, a bare TTY, or headless CI. */
| { state: "headless" }
/** This platform is out of scope for the locked v1 contract
* (macOS/Windows). Never `available` here — the feature is a documented
* no-op, not a half-implementation. */
| { state: "unsupported-platform" };

function nonEmpty(value: string | undefined): value is string {
return value !== undefined && value !== "";
}

/**
* Resolve capability from an already-resolved environment (the caller must
* pass the environment the privileged command will actually inherit — see
* `resolveAskpassCapability`). Pure aside from the injected `isExecutable`
* probe, so every branch is directly testable without a real graphical
* session or real helper binaries on disk.
*/
export function detectAskpassCapability(
env: EnvLike,
isExecutable: (path: string) => boolean,
): AskpassCapability {
const graphical = nonEmpty(env.WAYLAND_DISPLAY) || nonEmpty(env.DISPLAY);
if (!graphical) {
return { state: "headless" };
}

// (1) user-set SUDO_ASKPASS, only if it points to an executable file.
const userSet = env.SUDO_ASKPASS;
if (nonEmpty(userSet) && isExecutable(userSet)) {
return { state: "available", helper: userSet };
}

// (2) first existing helper from the fixed probe list. No dynamic
// discovery beyond this list.
for (const candidate of ASKPASS_PROBE_PATHS) {
if (isExecutable(candidate)) {
return { state: "available", helper: candidate };
}
}

return { state: "no-helper" };
}

function isExecutableFile(target: string): boolean {
try {
const stat = fs.statSync(target);
return stat.isFile() && (stat.mode & 0o111) !== 0;
} catch {
return false;
}
}

/**
* Resolve the current process's askpass capability. Gated to Linux per the
* locked v1 contract's scope — macOS/Windows are always
* `unsupported-platform`, never a half-implementation.
*
* Unlike PickForge's long-lived desktop process (which caches this once per
* process lifetime), the PickLab CLI is a short one-shot invocation that
* re-execs per command, so there is no stale-cache risk to guard against
* here: detection simply re-runs, cheaply, against the current environment
* each time a provisioning command resolves it.
*/
export function resolveAskpassCapability(
env: EnvLike = process.env,
): AskpassCapability {
if (process.platform !== "linux") {
return { state: "unsupported-platform" };
}
return detectAskpassCapability(env, isExecutableFile);
}

/**
* A human-actionable message for every non-available state. Always includes
* the manual fallback the locked v1 contract requires: run the privileged
* command yourself, in a terminal, with sudo — never an automatic fallback
* to interactive password capture.
*/
export function askpassUnavailableMessage(
capability: Exclude<AskpassCapability, { state: "available" }>,
manualCommand: string,
): string {
const fallback = `Run it yourself in a terminal: ${manualCommand}`;
switch (capability.state) {
case "headless":
return (
"No graphical session detected (WAYLAND_DISPLAY and DISPLAY are " +
`both unset); graphical sudo prompts require one. ${fallback}`
);
case "no-helper":
return (
"No SUDO_ASKPASS helper found (checked your SUDO_ASKPASS and the " +
`standard probe list: ${ASKPASS_PROBE_PATHS.join(", ")}). Install ` +
`one (e.g. ssh-askpass) or set SUDO_ASKPASS to point at one. ${fallback}`
);
case "unsupported-platform":
return (
"Graphical sudo prompts are only supported on Linux in this " +
`release (detected ${process.platform}). ${fallback}`
);
}
}
Loading
Loading