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 .devcontainer/aliases.sh
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
alias ll="ls -lah"
alias gs="git status"
alias op="opencode"
alias ocs="opencode-sandbox"
alias workspaces="cd /workspaces"
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ bash scripts/recovery/validate-recovery.sh # verify workstation is in goo
| `DB_PATH` | `/workspaces/opencode-workbench/.saga/.tracker.db` |
| `DEEPSEEK_API_KEY` | primary model: `deepseek/deepseek-v4-pro` |
| `OPENCODE_API_KEY` | OpenCode Zen + Console Go providers |
| `SIMONPLMAK_CLOUD_PAT` | GitHub MCP (binary) + shadcn MCP + `gh` auth |
| `SIMONMAK_ASCENT_PAT` | GitHub MCP (binary) + shadcn MCP + `gh` auth |
| `NPM_TOKEN` | GitHub Packages (`@simonmak-ascent`) — same PAT |
| `VERCEL_ACCESS_TOKEN`, `PERPLEXITY_API_KEY`, `BRAVE_API_KEY`, `SENTRY_AUTH_TOKEN`, `MOONSHOT_API_KEY`, `SURREAL_*`, `ALIBABA_CLOUD_*`, `AZURE_*`, `STRIPE_SECRET_KEY` | respective MCP servers/providers |

Expand All @@ -83,6 +83,7 @@ bash .devcontainer/start-browserless.sh
- **Toolchain**: `node` 22, `corepack`/`pnpm`, `uv` + Python 3.11, `rsync`, `gcc-c++`
- **CLI**: `gh`, `vercel`, `opencode` (aliased as `op`), `opencode serve`
- **Playwright**: chromium (headless shell) + RHEL system libs
- **Sandbox**: `opencode-sandbox` (bubblewrap) — run OpenCode with a read-only system, a writable workspace, and no access to `~/.ssh` or `~/.env.workbench`; installed by the optional `sandbox` component (`scripts/sandbox/opencode-sandbox.sh`)

## OpenCode

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ This repository is the **single source of truth** for a complete cloud developme
- Operational prompts (disaster recovery, backup, security)
- Recovery procedures (playbook, checklist, gap analysis)
- Security governance (secret management, threat model)
- OpenCode sandbox (bubblewrap) — run the agent with a read-only system and no secret mounts
- **`opencode-workbench`** — an MCP server that installs this profile on any Linux box

**No important knowledge exists only in human memory.**
Expand Down Expand Up @@ -181,7 +182,7 @@ All secrets stored in the build box Secrets. Never in repository files.
|---|--------|---------|
| 1 | `DEEPSEEK_API_KEY` | OpenCode (primary model) |
| 2 | `OPENCODE_API_KEY` | OpenCode Zen / Console Go providers |
| 3 | `SIMONPLMAK_CLOUD_PAT` | GitHub MCP, shadcn MCP, `gh` |
| 3 | `SIMONMAK_ASCENT_PAT` | GitHub MCP, shadcn MCP, `gh` |
| 4 | `PERPLEXITY_API_KEY` | Perplexity MCP |
| 5 | `BRAVE_API_KEY` | Brave Search MCP |
| 6 | `BROWSERLESS_TOKEN` | Browserless MCP + container |
Expand Down
2 changes: 1 addition & 1 deletion connector.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "opencode-workbench",
"title": "OpenCode Workbench",
"description": "Clones the OpenCode workbench installation onto Linux machines, locally or over SSH.",
"version": "3.0.0",
"version": "3.1.0",
"transport": {
"type": "stdio",
"command": "npx",
Expand Down
12 changes: 11 additions & 1 deletion docs/CHANGELOG_WORKBENCH.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
> All notable changes to the workbench configuration.
> Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)

## [2026-10-03] — v3.1.0

### Added
- **`sandbox` component** — optional [bubblewrap](https://github.com/containers/bubblewrap)
sandbox for the OpenCode CLI. Installs `~/.local/bin/opencode-sandbox` (alias `ocs`),
which runs `opencode` with a read-only system, writable workspace + OpenCode state only,
and no access to `~/.ssh`, cloud credentials or `~/.env.workbench`. Wrapper:
`scripts/sandbox/opencode-sandbox.sh`.
- Documented the sandbox under `docs/security-model.md` (Layer 2b) and `mcp-server/README.md`.

## [2026-10-03] — v3.0.0

### Breaking
Expand Down Expand Up @@ -153,7 +163,7 @@

### Changed
- **Active model**: `kimi-for-coding/k3` → `deepseek/deepseek-v4-pro`
- **devcontainer.json `remoteEnv`**: Added 6 missing env vars (BROWSERLESS_TOKEN, PERPLEXITY_API_KEY, BRAVE_API_KEY, FIGMA_TOKEN, SENTRY_AUTH_TOKEN, SIMONPLMAK_CLOUD_PAT, DEEPSEEK_API_KEY, DB_PATH)
- **devcontainer.json `remoteEnv`**: Added 6 missing env vars (BROWSERLESS_TOKEN, PERPLEXITY_API_KEY, BRAVE_API_KEY, FIGMA_TOKEN, SENTRY_AUTH_TOKEN, SIMONMAK_ASCENT_PAT, DEEPSEEK_API_KEY, DB_PATH)
- **Figma MCP**: Fixed env var name from `FIGMA_TOKEN` → `FIGMA_API_KEY`
- Runtime sync: `~/.config/opencode/opencode.json` synced from repo
- Updated `docs/environment-inventory.md` — reflected model change, added new vars
Expand Down
19 changes: 18 additions & 1 deletion docs/security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
| Tier | Description | Examples | Storage |
|------|-------------|----------|---------|
| **Tier 0 — Platform** | GitHub-injected tokens | `GITHUB_TOKEN`, `GH_TOKEN` | Automatic |
| **Tier 1 — Critical** | Required for core function | `DEEPSEEK_API_KEY`, `SIMONPLMAK_CLOUD_PAT` | build box Secrets |
| **Tier 1 — Critical** | Required for core function | `DEEPSEEK_API_KEY`, `SIMONMAK_ASCENT_PAT` | build box Secrets |
| **Tier 2 — Operational** | Required for MCP servers | `BRAVE_API_KEY`, `SENTRY_AUTH_TOKEN`, `FIGMA_TOKEN`, `PERPLEXITY_API_KEY`, `BROWSERLESS_TOKEN` | build box Secrets |
| **Tier 3 — Optional** | Nice to have, not critical | `OPENROUTER_API_KEY`, `GOOGLE_API_KEY`, `KIMI_API_KEY` | build box Secrets |
| **Tier 4 — Non-Secret** | Configuration only | `DATABASE_URL`, `DB_PATH`, `BROWSERLESS_HOST` | `devcontainer.json` |
Expand All @@ -57,6 +57,23 @@
- Each MCP server receives only its required environment variables
- `opencode.json` uses named `{env:VAR}` references (not blanket env passthrough)

### Layer 2b: OpenCode Sandboxing
The optional `sandbox` component installs **bubblewrap** and `~/.local/bin/opencode-sandbox`,
a wrapper that runs the OpenCode CLI (`opencode-sandbox [args...]`, alias `ocs`) inside a
bubblewrap namespace:

- **Read-only system** — `/usr`, `/etc`, `/bin`, `/lib`, `/opt`, `/run` are mounted `--ro-bind`.
- **Writable, scoped state** — only the workspace and OpenCode's own directories
(`~/.config/opencode`, `~/.local/share/opencode`, `~/.cache/opencode`,
`~/.local/state/opencode`) are writable.
- **Secrets excluded** — `~/.ssh`, `~/.env.workbench`, `~/.aws`, `~/.config/gh` and
other home paths are **not** mounted, so the agent cannot read them.
- **Network allowed by default** (remote model APIs); set `OPENCODE_SANDBOX_NO_NET=1`
for `--unshare-net` isolation.

Requires kernel support for unprivileged user namespaces; where unavailable the installer
warns and the component fails closed (run `opencode` directly only if you accept that).

### Layer 3: Documentation Discipline
- Secrets documented by name and purpose only
- Never record values, prefixes, or lengths in docs
Expand Down
7 changes: 6 additions & 1 deletion mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Every tool takes a `target`:

- **required** — `git`, `curl`, `node` (>=20), `opencode`
- **core** — `pnpm`, `uv`, `gh`, `npm-mcps`, `vendored-mcps`, `research-mcps`, `skills`, `plugins`, `playwright-browsers`
- **optional** — `github-mcp`, `docker`, `docker-containers`, `data-tools`, `scientific`, `db-clients`
- **optional** — `github-mcp`, `docker`, `docker-containers`, `data-tools`, `scientific`, `db-clients`, `sandbox`

Add-ons (esg-hub, humanity4ai, saga, surrealdb, google-workspace, google-search,
ms-365, stripe, alibaba-cloud-ops, designlang, difflens) are omitted from the rendered
Expand All @@ -70,6 +70,11 @@ The connector **never reads, transmits, or writes secret values**. It writes an
`~/.env.workbench` template (mode `600`) listing the `{env:VAR}` names the rendered
config references. Fill them on the target.

The optional `sandbox` component installs [bubblewrap](https://github.com/containers/bubblewrap)
and an `opencode-sandbox` wrapper that runs the OpenCode CLI with a read-only system and
only the workspace + OpenCode's own state directories mounted — `~/.ssh`, cloud
credentials and `~/.env.workbench` stay outside the sandbox.

## Development

```bash
Expand Down
20 changes: 20 additions & 0 deletions mcp-server/src/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,25 @@ pip3 install --break-system-packages -q numpy scipy pandas matplotlib sympy || w
install: `pkg_install postgresql-client || true
pipx install pgcli 2>/dev/null || true`,
},
{
id: "sandbox",
title: "OpenCode sandbox (bubblewrap)",
tier: "optional",
description:
"Runs the OpenCode CLI in a bubblewrap sandbox: read-only system, writable workspace, and no access to ~/.ssh, cloud credentials or ~/.env.workbench.",
preview: "install bubblewrap and ~/.local/bin/opencode-sandbox",
detect: `command -v bwrap >/dev/null 2>&1 && [ -x "$WB_HOME/.local/bin/opencode-sandbox" ]`,
install: `
command -v bwrap >/dev/null 2>&1 || pkg_install bubblewrap || true
command -v bwrap >/dev/null 2>&1 || { warn "bubblewrap unavailable (kernel may block unprivileged user namespaces)"; exit 1; }
if [ -z "\${WB_REPO:-}" ] || [ ! -f "$WB_REPO/scripts/sandbox/opencode-sandbox.sh" ]; then
warn "sandbox script not found under WB_REPO"; exit 1
fi
mkdir -p "$WB_HOME/.local/bin"
install -m 0755 "$WB_REPO/scripts/sandbox/opencode-sandbox.sh" "$WB_HOME/.local/bin/opencode-sandbox"
ok "opencode-sandbox -> ~/.local/bin/opencode-sandbox"
`,
},
];

export function componentById(id: string): Component | undefined {
Expand Down Expand Up @@ -361,6 +380,7 @@ ${CORE_NPM_MCPS.map((p) => ` npm rm -g --silent ${p} >/dev/null 2>&1 || true`).
"docker-containers": `command -v docker >/dev/null 2>&1 || { warn "docker missing"; exit 1; }
docker rm -f pg-memory browserless >/dev/null 2>&1 || true`,
"playwright-browsers": `rm -rf "$WB_HOME/.cache/ms-playwright"`,
sandbox: `rm -f "$WB_HOME/.local/bin/opencode-sandbox"`,
};

/** Uninstall script for a component id, or undefined when removal is manual. */
Expand Down
1 change: 1 addition & 0 deletions mcp-server/test/component-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ describe("component uninstall coverage", () => {
"plugins",
"docker-containers",
"playwright-browsers",
"sandbox",
]) {
expect(typeof uninstallScript(id), `missing uninstall for ${id}`).toBe("string");
}
Expand Down
1 change: 1 addition & 0 deletions mcp-server/test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ describe("opencode-workbench server", () => {
optionalMcp: string[];
};
expect(info.components.some((c) => c.id === "opencode" && c.tier === "required")).toBe(true);
expect(info.components.some((c) => c.id === "sandbox" && c.tier === "optional")).toBe(true);
expect(info.optionalMcp).toContain("saga");
await client.close();
});
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@simonmak-ascent/opencode-workbench",
"mcpName": "io.github.simonmak-ascent/opencode-workbench",
"version": "3.0.0",
"version": "3.1.0",
"description": "MCP server and connector that clones the OpenCode workbench configuration onto Linux machines (locally or over SSH).",
"license": "MIT",
"type": "module",
Expand Down
99 changes: 99 additions & 0 deletions scripts/sandbox/opencode-sandbox.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
#
# opencode-sandbox — run the OpenCode CLI inside a bubblewrap (bwrap) sandbox.
#
# Security goal: the agent process gets a read-only system, a writable copy of
# its workspace and OpenCode's own state directories, and NOTHING ELSE from your
# home. Your SSH keys (~/.ssh), cloud credentials (~/.aws, ~/.config/gcloud),
# GitHub tokens (~/.config/gh) and the secrets file ~/.env.workbench are not
# mounted, so a compromised or misbehaving agent cannot read them.
#
# Usage:
# opencode-sandbox [args...] # same args as `opencode`
# opencode-sandbox run "..." # e.g. `opencode run` in the sandbox
#
# Environment:
# OPENCODE_SANDBOX_CWD=<dir> working dir to expose read-write (default: $PWD)
# OPENCODE_SANDBOX_NO_NET=1 isolate the network (default: network allowed,
# because model APIs are remote)
# OPENCODE_SANDBOX_RW=<paths> extra colon-separated paths to bind read-write
# OPENCODE_SANDBOX_RO=<paths> extra colon-separated paths to bind read-only
# OPENCODE_SANDBOX_BIN=<path> opencode binary (default: resolved via PATH)
#
# Requires: bubblewrap (`bwrap`). Install with e.g.
# sudo apt-get install -y bubblewrap # Debian/Ubuntu
# sudo dnf install -y bubblewrap # Fedora/RHEL
#
set -euo pipefail

if ! command -v bwrap >/dev/null 2>&1; then
echo "opencode-sandbox: bubblewrap (bwrap) is required." >&2
echo " Debian/Ubuntu: sudo apt-get install -y bubblewrap" >&2
echo " Fedora/RHEL: sudo dnf install -y bubblewrap" >&2
echo " Alpine: sudo apk add bubblewrap" >&2
echo "Refusing to run unsandboxed. Use 'opencode' directly if you accept that." >&2
exit 127
fi

WORKDIR="${OPENCODE_SANDBOX_CWD:-$PWD}"
HOME_DIR="${HOME:-/root}"
OPENCODE_BIN="${OPENCODE_SANDBOX_BIN:-$(command -v opencode || true)}"

if [ -z "$OPENCODE_BIN" ]; then
echo "opencode-sandbox: 'opencode' not found on PATH (set OPENCODE_SANDBOX_BIN)." >&2
exit 127
fi

args=(
--die-with-parent
--new-session
--unshare-pid
--unshare-uts
--unshare-ipc
--proc /proc
--dev /dev
--tmpfs /tmp
)

# Read-only system.
for d in /usr /etc /bin /sbin /lib /lib64 /opt /run; do
[ -e "$d" ] && args+=(--ro-bind "$d" "$d")
done

# Writable workspace.
[ -d "$WORKDIR" ] && args+=(--bind "$WORKDIR" "$WORKDIR")

# OpenCode's own state — writable, created if absent. These are the only parts
# of $HOME the sandbox sees.
for d in \
"$HOME_DIR/.config/opencode" \
"$HOME_DIR/.local/share/opencode" \
"$HOME_DIR/.cache/opencode" \
"$HOME_DIR/.local/state/opencode"; do
mkdir -p "$d" 2>/dev/null || true
[ -e "$d" ] && args+=(--bind "$d" "$d")
done

# Where the opencode binary itself may live (read-only).
for d in "$HOME_DIR/.opencode" "$HOME_DIR/.local/bin" "$HOME_DIR/.npm-global"; do
[ -e "$d" ] && args+=(--ro-bind "$d" "$d")
done

# Caller-provided extra mounts (colon-separated path lists).
if [ -n "${OPENCODE_SANDBOX_RO:-}" ]; then
IFS=':' read -r -a _ro <<<"$OPENCODE_SANDBOX_RO"
for p in "${_ro[@]}"; do [ -e "$p" ] && args+=(--ro-bind "$p" "$p"); done
fi
if [ -n "${OPENCODE_SANDBOX_RW:-}" ]; then
IFS=':' read -r -a _rw <<<"$OPENCODE_SANDBOX_RW"
for p in "${_rw[@]}"; do [ -e "$p" ] && args+=(--bind "$p" "$p"); done
fi

# Network is allowed by default (remote model APIs); opt into isolation.
if [ "${OPENCODE_SANDBOX_NO_NET:-0}" = "1" ]; then
args+=(--unshare-net)
fi

args+=(--setenv HOME "$HOME_DIR" --chdir "$WORKDIR")

exec bwrap "${args[@]}" -- "$OPENCODE_BIN" "$@"
4 changes: 2 additions & 2 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json",
"name": "io.github.simonmak-ascent/opencode-workbench",
"description": "Provision an OpenCode workbench and MCP stack on any Linux box, local or over SSH.",
"version": "3.0.0",
"version": "3.1.0",
"repository": {
"url": "https://github.com/simonmak-ascent/opencode-workbench",
"source": "github"
Expand All @@ -11,7 +11,7 @@
{
"registryType": "npm",
"identifier": "@simonmak-ascent/opencode-workbench",
"version": "3.0.0",
"version": "3.1.0",
"transport": { "type": "stdio" }
}
],
Expand Down
41 changes: 41 additions & 0 deletions vdd/specs/opencode-sandbox/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Spec — opencode-sandbox

> Traces to: `vdd/vision.md` (security of the provisioned host), `vdd/tactics.md` A-012.
> Status: implemented (v3.1.0).

## User story

As an operator who lets an AI agent run shell commands on a workstation, I want the
OpenCode CLI to run inside an OS-level sandbox with a read-only system and no access to
my SSH keys, cloud credentials or secrets file, so that a compromised or misbehaving
agent cannot read or exfiltrate them.

## Boundaries

- **Always**
- Refuse to run unsandboxed (fail closed) when `bwrap` is unavailable.
- Mount the system read-only and expose only the workspace + OpenCode's own state dirs.
- Keep `~/.ssh`, `~/.env.workbench`, `~/.aws`, `~/.config/gh` out of the sandbox.
- **Ask**
- Nothing at runtime; installation is opt-in via the `sandbox` component.
- **Never**
- Never run the agent as root inside the sandbox.
- Never mount the whole home directory.

## Acceptance criteria

- **AC-1** Given `bwrap` is installed, when I run `opencode-sandbox run "..."`, then the
command executes inside a bubblewrap namespace with `/usr` read-only.
- **AC-2** Given `~/.ssh` exists, when the sandbox runs, then `~/.ssh` is absent inside it.
- **AC-3** Given `bwrap` is missing, when I run `opencode-sandbox`, then it exits non-zero
with install guidance and does not run `opencode`.
- **AC-4** Given `OPENCODE_SANDBOX_NO_NET=1`, when the sandbox runs, then the network is
unshared.
- **AC-5** Given the `sandbox` component is installed, when `remove_component sandbox` runs,
then `~/.local/bin/opencode-sandbox` is removed.

## Non-functional

- Portable across apt/dnf/apk/pacman/zypper hosts (via the shared `pkg_install` preamble).
- No root at run time (user namespaces); `sudo` only at install time.
- The wrapper is shell-only and has no network calls of its own.
1 change: 1 addition & 0 deletions vdd/tactics.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ Derived from: `vdd/strategy.md`. Scope: Infrastructure only.
| A-009 | Add `server.json` manifest for the Official MCP Registry | MUST | Distribution | S | None |
| A-010 | Registry-listing runbook + claim/verify each of the 5 registries; automate what the API allows | MUST | Distribution | M | A-009 |
| A-011 | Capture a full `mcp-tdqs score` (tier A floor) and wire a CI gate when a scorer key is available | SHOULD | P1 | M | A-008 |
| A-012 | Add an optional `sandbox` component: a bubblewrap wrapper (`scripts/sandbox/opencode-sandbox.sh`) confining the OpenCode CLI (read-only system, no `~/.ssh`/secret mounts) | SHOULD | Security | S | None |

**AMEND dependency map:** `A-002 → A-008 → A-011`; `A-009 → A-010`.
**AMEND blocker:** A-011 (full score) and Smithery/PulseMCP listing require credentials the
Expand Down
Loading