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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,24 @@ tagged release also ships native binaries for Linux, macOS, and Windows.
sent-message surfaces, and Compute into a quieter results-first workspace.
- Unified logical model names while keeping API-key and ChatGPT access routes
explicit in both the composer and Settings.
- Let untrusted projects run routine terminal, kernel, shell, and local-compute
work immediately inside the enforced native sandbox, while keeping project
extensions, remote compute, package installation, and host execution behind
explicit trust or stricter managed policy.

### Fixed

- Hardened research runs against repeated terminal URLs, guessed download-size
escalation, substantially identical timed-out kernel work, stale tool
outcomes, cross-process cancellation races, and orphaned kernel lifecycles.
- Made compute-job actions self-describing and recover harmless legacy aliases
and stringified targets without weakening canonical validation.
- Made brokered downloads derive their safe size from available workspace disk
instead of agent-guessed byte caps, with copy-ready root-download and
sandboxed move guidance for folder destinations.
- Removed the fixed Modal Volume browser-download ceiling and made large file
delivery use live disk-derived staging capacity plus cancellation-safe
streaming instead of buffering responses in memory.
- Preserved exact session and tool-output filesystem capabilities across local
work and delegated handoffs without broadening external-directory access.
- Restored the v2 Review settings API, truthful runtime progress capture, and
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for how the system fits together, [CONTRI

## Security

The permission system keeps you aware of what the agent is doing; it is not an isolation boundary by itself. OpenScience also includes an opt-in OS execution sandbox: macOS Seatbelt or Linux bubblewrap can confine writes to the workspace and deny network egress. It is off by default and is not a full jail, so run inside a container or VM for hostile code. Managed Atlas tokens stay out of general subprocess environments, arbitrary Python/R kernels receive a minimal environment, and credential-shaped values are redacted from output. To configure and verify containment, run `openscience sandbox enable` and `openscience sandbox test`; to report a vulnerability, see [SECURITY.md](SECURITY.md).
The permission system keeps you aware of what the agent is doing; it is not an isolation boundary by itself. OpenScience enables its OS execution sandbox by default: macOS Seatbelt or Linux bubblewrap confines commands to the workspace and approved paths and denies network egress. Routine terminals, kernels, shell commands, and local jobs can run immediately inside that verified boundary; remote jobs, kernel environment changes, project extensions, and host execution still require explicit project trust. The default fails closed when no backend is available, and the sandbox is not a full jail, so run inside a container or VM for hostile code. Managed Atlas tokens stay out of general subprocess environments, arbitrary Python/R kernels receive a minimal environment, and credential-shaped values are redacted from output. To inspect or verify containment, run `openscience sandbox` and `openscience sandbox test`; to report a vulnerability, see [SECURITY.md](SECURITY.md).

## License

Expand Down
6 changes: 3 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ OpenScience is an AI agent that runs locally on your machine. The agent can run

### Execution sandbox

The permission system prompts you before the agent runs a command or writes a file, so you stay aware of what it is doing. A permission prompt is not an isolation boundary by itself, and the execution sandbox is off by default.
The permission system decides whether the agent may take an action. A permission prompt is not an isolation boundary by itself. OpenScience enables its execution sandbox by default and refuses to run when a native backend is unavailable unless you explicitly choose a fallback policy.

When enabled, OpenScience wraps shell commands and Python/R kernel code in an OS sandbox: macOS Seatbelt or Linux bubblewrap. It confines writes to the workspace and approved paths and can deny network egress. Run `openscience sandbox enable`, then `openscience sandbox test`; if the test does not report **Containment verified**, do not rely on it. Reads and local IPC remain available, Windows has no sandbox backend, and the boundary is not a full jail. Use a container or VM for hostile code.
OpenScience wraps terminal and shell commands, Python/R kernels, and local compute jobs in macOS Seatbelt or Linux bubblewrap. It confines reads and writes to the session workspace and explicitly granted paths and denies network egress. Routine work can run immediately inside that verified boundary; remote jobs, kernel environment changes, project-owned extensions, and host execution still require explicit project trust. Run `openscience sandbox test`; if it does not report **Containment verified**, do not rely on that backend. Windows has no sandbox backend, and the boundary is not a full VM. Use a container or VM for hostile code.

### Server mode

Expand All @@ -19,7 +19,7 @@ Server mode is opt-in. The server binds to localhost (127.0.0.1) only and enforc
| Category | Why |
| --------------------------- | -------------------------------------------------------------------- |
| Server access when opted in | If you enable server mode, API access is expected behavior. |
| Full read isolation | The sandbox confines writes; it does not hide readable local files. |
| Granted-root contents | A command may read files inside roots explicitly granted to it. |
| Windows sandboxing | Windows has no execution-sandbox backend yet. |
| LLM provider data handling | Data you send to a provider is governed by that provider's policies. |
| MCP server behavior | External MCP servers you configure are outside the trust boundary. |
Expand Down
12 changes: 5 additions & 7 deletions backend/cli/src/agent/prompt/research.txt
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,11 @@ result; internal profiles and skills are implementation details, not user-facing
- Use persistent Python/R for stateful analysis and shell for builds, tests, files, and scripts.
Kernel state is working memory, not reproducibility: save source, inputs, parameters, and outputs,
and clean-rerun material results when practical.
- Use WebFetch text mode only for bounded pages and API responses. For large or binary science data,
set WebFetch `output_path` to a simple workspace-root filename. If metadata gives an exact size,
set `max_bytes` once just above it; when size is unknown, omit it to use the bounded default. Never
probe the same URL by repeatedly raising the cap.
Stream once through the authorized broker into the session workspace, verify its digest, and
process it locally. Paginate APIs instead of repeatedly requesting an oversized response. Do not
assume Shell has network access.
- Use WebFetch text mode only for bounded pages and APIs. Download large or binary scientific data to a root
basename. For `papers/foo.pdf`, use `output_path:"foo.pdf"`; only after success run sandboxed Bash
`mkdir -p -- 'papers' && test ! -e 'papers/foo.pdf' && mv -- 'foo.pdf' 'papers/foo.pdf'`. Never probe folder paths or send retired
cap/size-evidence fields; WebFetch uses live free disk minus its reserve. Verify the brokered download,
process it locally, and paginate APIs. Do not assume Shell has network access.
- Treat an explicitly requested immutable data release as an evidence constraint. If it cannot be
retrieved and verified, disclose that early; stop that branch or clearly bound and label any
live-release fallback rather than silently mixing releases.
Expand Down
46 changes: 44 additions & 2 deletions backend/cli/src/cli/cmd/cmd.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,49 @@
import type { CommandModule } from "yargs"
import type { ArgumentsCamelCase, CommandModule } from "yargs"
import { DataRootBarrier } from "../../global/data-root-barrier"

type WithDoubleDash<T> = T & { "--"?: string[] }

let dataRootOperation: DataRootBarrier.Operation | undefined

// Only these commands intentionally keep serving after parse returns. Every
// other parsed command (including aliases and shell completion) gets one
// physical data-root marker for its complete middleware/handler lifetime.
// A negative list avoids silently dropping protection when a command adds an
// alias or a new short-lived top-level entry.
const longLivedCommands = new Set(["web", "serve"])

export async function runDataRootMiddleware<T>(
command: string | undefined,
filepath: string,
action: () => T | Promise<T>,
timeoutMs = 120_000,
): Promise<T> {
if (!command || longLivedCommands.has(command)) return await action()
let operation = dataRootOperation
if (!operation) {
operation = await DataRootBarrier.enter(filepath, timeoutMs)
dataRootOperation = operation
}
return await operation.during(async () => await action())
}

export async function runInDataRootScope<T>(action: () => T | Promise<T>): Promise<T> {
const operation = dataRootOperation
if (!operation) return await action()
return await operation.during(async () => await action())
}

export async function disposeDataRootOperation() {
const operation = dataRootOperation
dataRootOperation = undefined
await operation?.[Symbol.asyncDispose]()
}

export function cmd<T, U>(input: CommandModule<T, WithDoubleDash<U>>) {
return input
const handler = input.handler
if (!handler) return input
return {
...input,
handler: (args: ArgumentsCamelCase<WithDoubleDash<U>>) => runInDataRootScope(() => handler(args)),
} satisfies CommandModule<T, WithDoubleDash<U>>
}
20 changes: 11 additions & 9 deletions backend/cli/src/cli/cmd/debug/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,17 @@ export const DebugCommand = cmd({
.command(SnapshotCommand)
.command(AgentCommand)
.command(PathsCommand)
.command({
command: "wait",
describe: "wait indefinitely (for debugging)",
async handler() {
await bootstrap(process.cwd(), async () => {
await new Promise((resolve) => setTimeout(resolve, 1_000 * 60 * 60 * 24))
})
},
})
.command(
cmd({
command: "wait",
describe: "wait indefinitely (for debugging)",
async handler() {
await bootstrap(process.cwd(), async () => {
await new Promise((resolve) => setTimeout(resolve, 1_000 * 60 * 60 * 24))
})
},
}),
)
.demandCommand(),
async handler() {},
})
Expand Down
6 changes: 3 additions & 3 deletions backend/cli/src/cli/cmd/generate.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Server } from "../../server/server"
import type { CommandModule } from "yargs"
import { cmd } from "./cmd"

export const GenerateCommand = {
export const GenerateCommand = cmd({
command: "generate",
handler: async () => {
const specs = await Server.openapi()
Expand Down Expand Up @@ -35,4 +35,4 @@ export const GenerateCommand = {
})
})
},
} satisfies CommandModule
})
16 changes: 14 additions & 2 deletions backend/cli/src/cli/cmd/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ function printStatus(config?: Config.Sandbox) {
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}`,
`${S.TEXT_DIM} (agent shell commands${
enabled ? " are confined to approved paths" : " require project trust before using full user authority"
})${S.TEXT_NORMAL}`,
)
UI.println(` platform ${d.platform}`)
UI.println(
Expand All @@ -31,14 +33,17 @@ function printStatus(config?: Config.Sandbox) {
)
if (enabled) {
UI.println(` network ${config?.network ?? "deny"}`)
UI.println(
` project trust ${config?.requireProjectTrust ? "required for all execution" : "routine sandboxed work allowed"}`,
)
UI.println(` on missing backend ${config?.onUnavailable ?? "error"}`)
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 ?? "error"}". It takes effect on machines with a backend.`,
`execution follows the "${config?.onUnavailable ?? "error"}" fallback policy. It takes effect on machines with a backend.`,
)
}
}
Expand Down Expand Up @@ -78,6 +83,10 @@ const EnableCommand = cmd({
.option("on-unavailable", {
choices: ["warn", "error", "allow"] as const,
describe: "what to do when no backend exists on a machine (default: error)",
})
.option("require-project-trust", {
type: "boolean",
describe: "require explicit project trust even for routine sandboxed commands",
}),
handler: async (args) => {
await Instance.provide({
Expand All @@ -86,6 +95,9 @@ const EnableCommand = cmd({
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"
if (typeof args["require-project-trust"] === "boolean") {
patch.requireProjectTrust = args["require-project-trust"]
}
const allow = args.allow as string[] | undefined
if (allow?.length) {
patch.allowWrite = allow.map((value) => {
Expand Down
5 changes: 3 additions & 2 deletions backend/cli/src/cli/cmd/uninstall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import os from "os"
import { cmd } from "./cmd"

interface UninstallArgs {
keepConfig?: boolean
Expand All @@ -22,7 +23,7 @@ interface RemovalTargets {
binary: string | null
}

export const UninstallCommand = {
export const UninstallCommand = cmd({
command: "uninstall",
describe: "uninstall openscience while keeping your work and settings by default",
builder: (yargs: Argv) =>
Expand Down Expand Up @@ -88,7 +89,7 @@ export const UninstallCommand = {

prompts.outro("Done")
},
}
})

async function collectRemovalTargets(args: UninstallArgs, method: Installation.Method): Promise<RemovalTargets> {
const directories = uninstallDirectories(args)
Expand Down
5 changes: 3 additions & 2 deletions backend/cli/src/cli/cmd/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import type { Argv } from "yargs"
import { UI } from "../ui"
import * as prompts from "@clack/prompts"
import { Installation } from "../../installation"
import { cmd } from "./cmd"

export const UpgradeCommand = {
export const UpgradeCommand = cmd({
command: "upgrade [target]",
describe: "upgrade openscience to the latest or a specific version",
builder: (yargs: Argv) => {
Expand Down Expand Up @@ -68,4 +69,4 @@ export const UpgradeCommand = {
spinner.stop("Upgrade complete")
prompts.outro("Done")
},
}
})
Loading