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
157 changes: 157 additions & 0 deletions backend/cli/src/cli/cmd/sandbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import type { Argv } from "yargs"
import { cmd } from "./cmd"
import { UI } from "../ui"
import { Instance } from "../../project/instance"
import { Config } from "../../config/config"
import { Sandbox } from "../../sandbox/sandbox"

const S = UI.Style

/** The trusted (global + managed) sandbox policy that actually gets enforced. */
async function effectiveSandbox(): Promise<Config.Sandbox | undefined> {
return Config.trustedSandbox()
}

function printStatus(config?: Config.Sandbox) {
const d = Sandbox.describe()
const enabled = config?.enabled === true

UI.println(`${S.TEXT_NORMAL_BOLD}Execution sandbox${S.TEXT_NORMAL}`)
UI.println(
` status ${enabled ? `${S.TEXT_SUCCESS_BOLD}enabled` : `${S.TEXT_DIM}disabled`}${S.TEXT_NORMAL}` +
`${S.TEXT_DIM} (agent shell commands${enabled ? " are confined to the workspace" : " run with full user authority"})${S.TEXT_NORMAL}`,
)
UI.println(` platform ${d.platform}`)
UI.println(
` backend ${
d.available
? `${S.TEXT_SUCCESS}${d.backend}${S.TEXT_NORMAL} ${S.TEXT_DIM}(${d.tool})${S.TEXT_NORMAL}`
: `${S.TEXT_WARNING}unavailable${S.TEXT_NORMAL} ${S.TEXT_DIM}— ${d.reason}${S.TEXT_NORMAL}`
}`,
)
if (enabled) {
UI.println(` network ${config?.network ?? "allow"}`)
UI.println(` on missing backend ${config?.onUnavailable ?? "warn"}`)
if (config?.allowWrite?.length) UI.println(` extra writable ${config.allowWrite.join(", ")}`)
}
if (enabled && !d.available) {
UI.println("")
UI.println(
` ${S.TEXT_WARNING_BOLD}Note:${S.TEXT_NORMAL} sandbox is on but no backend exists here — ` +
`commands run per "${config?.onUnavailable ?? "warn"}". It takes effect on machines with a backend.`,
)
}
}

async function showStatus() {
await Instance.provide({
directory: process.cwd(),
async fn() {
printStatus(await effectiveSandbox())
},
})
}

const StatusCommand = cmd({
command: ["status", "$0"],
describe: "show sandbox status (backend + current config)",
handler: async () => {
UI.empty()
await showStatus()
},
})

const EnableCommand = cmd({
command: "enable",
describe: "turn the execution sandbox on",
builder: (yargs: Argv) =>
yargs
.option("network", {
choices: ["allow", "deny"] as const,
describe: "allow or deny network egress from sandboxed commands (default: allow)",
})
.option("allow", {
type: "string",
array: true,
describe: "extra absolute path the sandbox may write to (repeatable)",
})
.option("on-unavailable", {
choices: ["warn", "error", "allow"] as const,
describe: "what to do when no backend exists on a machine (default: warn)",
}),
handler: async (args) => {
await Instance.provide({
directory: process.cwd(),
async fn() {
const patch: Partial<Config.Sandbox> = { enabled: true }
if (args.network) patch.network = args.network as "allow" | "deny"
if (args["on-unavailable"]) patch.onUnavailable = args["on-unavailable"] as "warn" | "error" | "allow"
const allow = args.allow as string[] | undefined
if (allow?.length) patch.allowWrite = allow
await Config.setSandbox(patch)
UI.empty()
UI.println(`${S.TEXT_SUCCESS_BOLD}Sandbox enabled${S.TEXT_NORMAL} ${S.TEXT_DIM}(global config)${S.TEXT_NORMAL}`)
const d = Sandbox.describe()
if (!d.available) {
UI.println(
`${S.TEXT_WARNING}No sandbox backend on this machine (${d.reason}).${S.TEXT_NORMAL} ` +
`It will apply where one is available.`,
)
}
UI.empty()
},
})
await showStatus()
UI.empty()
UI.println(`${S.TEXT_DIM}Verify it holds: openscience sandbox test${S.TEXT_NORMAL}`)
},
})

const DisableCommand = cmd({
command: "disable",
describe: "turn the execution sandbox off",
handler: async () => {
await Config.setSandbox({ enabled: false })
UI.empty()
UI.println(`${S.TEXT_NORMAL_BOLD}Sandbox disabled${S.TEXT_NORMAL} ${S.TEXT_DIM}(global config)${S.TEXT_NORMAL}`)
},
})

const TestCommand = cmd({
command: "test",
describe: "prove the sandbox actually confines writes (and network) on this machine",
handler: async () => {
UI.empty()
const result = await Sandbox.selfTest()
if (!result.available) {
const d = Sandbox.describe()
UI.println(`${S.TEXT_WARNING}No sandbox backend available${S.TEXT_NORMAL} — ${d.reason}.`)
UI.println(`${S.TEXT_DIM}Nothing to test here.${S.TEXT_NORMAL}`)
return
}
UI.println(
`${S.TEXT_NORMAL_BOLD}Sandbox self-test${S.TEXT_NORMAL} ${S.TEXT_DIM}(${result.backend})${S.TEXT_NORMAL}`,
)
for (const c of result.checks) {
const mark = c.skipped ? `${S.TEXT_DIM}– skip` : c.pass ? `${S.TEXT_SUCCESS}✓ pass` : `${S.TEXT_DANGER}✗ FAIL`
UI.println(` ${mark}${S.TEXT_NORMAL} ${c.name}${c.detail ? ` ${S.TEXT_DIM}(${c.detail})${S.TEXT_NORMAL}` : ""}`)
}
UI.empty()
UI.println(
result.ok
? `${S.TEXT_SUCCESS_BOLD}Containment verified.${S.TEXT_NORMAL}`
: `${S.TEXT_DANGER_BOLD}Containment FAILED — do not rely on the sandbox until this passes.${S.TEXT_NORMAL}`,
)
},
})

export const SandboxCommand = cmd({
command: "sandbox",
describe: "manage the agent execution sandbox (confine shell commands to the workspace)",
builder: (yargs: Argv) =>
yargs.command(StatusCommand).command(EnableCommand).command(DisableCommand).command(TestCommand).demandCommand(0),
handler: async () => {
UI.empty()
await showStatus()
},
})
15 changes: 15 additions & 0 deletions backend/cli/src/cli/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { OpenScience } from "../openscience"
import { Auth } from "../auth"
import { Config } from "../config/config"
import { Provider } from "../provider/provider"
import { Sandbox } from "../sandbox/sandbox"
import { Global } from "../global"
import { openUrl } from "../util/open-url"
import { runAtlasLogin } from "./cmd/connect"
Expand Down Expand Up @@ -252,6 +253,20 @@ export const DoctorCommand = cmd({
prompts.log.success(`Local models: ${locals.map(([id]) => id).join(", ")} (run \`openscience local list\`)`)
}
prompts.log.info(`Default model: ${config.model ?? "auto (chosen from available providers)"}`)

const sandbox = Sandbox.describe()
const sandboxOn = (await Config.trustedSandbox())?.enabled === true
const sandboxLine = sandboxOn
? sandbox.available
? { level: "success" as const, msg: `Sandbox: on (${sandbox.backend}) (run \`openscience sandbox test\`)` }
: { level: "warn" as const, msg: `Sandbox: on but no backend here — ${sandbox.reason}` }
: {
level: "info" as const,
msg: sandbox.available
? `Sandbox: off (${sandbox.backend} available — \`openscience sandbox enable\`)`
: "Sandbox: off",
}
prompts.log[sandboxLine.level](sandboxLine.msg)
} catch {}

if (!(await isConfigured())) {
Expand Down
57 changes: 57 additions & 0 deletions backend/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,34 @@ export namespace Config {
})
export type Permission = z.infer<typeof Permission>

export const Sandbox = z
.object({
enabled: z
.boolean()
.optional()
.describe(
"Run the agent's shell commands inside an OS sandbox (macOS Seatbelt / Linux bubblewrap) that confines writes to the workspace. Off by default.",
),
network: z
.enum(["allow", "deny"])
.optional()
.describe("Whether sandboxed commands may reach the network. Default: allow."),
allowWrite: z
.array(z.string())
.optional()
.describe("Extra absolute paths — beyond the workspace and temp dirs — the sandbox may write to."),
onUnavailable: z
.enum(["warn", "error", "allow"])
.optional()
.describe(
"Behaviour when no sandbox backend exists on this platform: 'warn' (default) runs unsandboxed with a notice, 'error' refuses to run the command, 'allow' runs unsandboxed silently.",
),
})
.meta({
ref: "SandboxConfig",
})
export type Sandbox = z.infer<typeof Sandbox>

export const Command = z.object({
template: z.string(),
description: z.string().optional(),
Expand Down Expand Up @@ -1104,6 +1132,7 @@ export namespace Config {
instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
permission: Permission.optional(),
sandbox: Sandbox.optional().describe("OS-level execution sandbox for the agent's shell commands."),
tools: z.record(z.string(), z.boolean()).optional(),
enterprise: z
.object({
Expand Down Expand Up @@ -1460,6 +1489,34 @@ export namespace Config {
return patchConfigPath(scope, ["provider", id], undefined)
}

/**
* Execution-sandbox policy resolved from GLOBAL + MANAGED (admin) config only.
* Project config is deliberately excluded: the sandbox is a machine-wide safety
* boundary, so an untrusted repo's `openscience.json` must not be able to weaken
* or disable it. Managed (enterprise) config wins over the user's global config.
*/
export async function trustedSandbox(): Promise<Sandbox | undefined> {
const base = (await global()).sandbox
let managed: Sandbox | undefined
if (existsSync(managedConfigDir)) {
let acc: Info = {}
for (const file of CONFIG_FILES) acc = mergeDeep(acc, await loadFile(path.join(managedConfigDir, file)))
managed = acc.sandbox
}
if (!base && !managed) return undefined
return { ...(base ?? {}), ...(managed ?? {}) }
}

/** Merge a patch into the GLOBAL `sandbox` config block, JSONC-preserving. The
* execution sandbox is a machine-wide safety setting and is only ever read
* from global + managed config (see {@link trustedSandbox}), so it is always
* written globally — a project-scoped value would be silently ignored. */
export async function setSandbox(patch: Partial<Sandbox>) {
const current = (await getGlobal()).sandbox
const next: Sandbox = { ...(current ?? {}), ...patch }
return patchConfigPath("global", ["sandbox"], next)
}

/** Remove a key path from the global config (deep-merge can't unset). */
export async function unsetGlobal(target: string[]) {
return patchConfigPath("global", target, undefined)
Expand Down
2 changes: 2 additions & 0 deletions backend/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { ProjectCommand } from "./cli/cmd/project"
import { WalletCommand } from "./cli/cmd/billing"
import { KeysCommand, ConnectCommand, DisconnectCommand } from "./cli/cmd/auth"
import { LocalCommand } from "./cli/cmd/local"
import { SandboxCommand } from "./cli/cmd/sandbox"
import { InitCommand, DoctorCommand } from "./cli/onboard"
import { OpenScience } from "./openscience"

Expand Down Expand Up @@ -122,6 +123,7 @@ const cli = yargs(hideBin(process.argv))
.command(WebCommand)
.command(ModelsCommand)
.command(LocalCommand)
.command(SandboxCommand)
.command(SkillCommand)
.command(StatsCommand)
.command(ExportCommand)
Expand Down
Loading
Loading