gg architecture
gg keeps product behavior in a reusable Go core and treats every interface as an adapter. The CLI, Web app, and Electron client therefore share session semantics, concurrency rules, approval handling, and tool execution.
Package responsibilities
| Package | Responsibility |
|---|---|
internal/app | Application use cases, session actions, turn execution, run lifecycle, approvals, event replay, and transport-safe DTOs. |
internal/session | JSONL persistence, tree reconstruction, migrations, durable branch head, cross-process writer leases, conflict detection, and repository lookup by stable session ID. |
internal/agent | Provider loop, tool calls, approvals, and streaming agent events. |
internal/transport/jsonrpc | Stable method names and request/response validation. It contains no UI or filesystem policy. |
internal/transport/stdio | Concurrent newline-delimited JSON-RPC for local child processes. |
internal/transport/httpapi | Bearer-protected RPC plus replayable SSE events for browser deployments. |
internal/daemon and cmd/ggd | Composition root for config, provider, repository, workspace, and transports. |
internal/cliapp | CLI composition only (one-shot prompts, line-based interactive mode, management commands). Business behavior delegates to app.Service. |
internal/userprofile | User profile (~/.gg/USER.md): load, parse, and render the "who you are" system block. |
internal/memory | Structured memory store (~/.gg/memory/): curated MEMORY.md, daily logs YYYY-MM-DD.md, people/ and groups/ notes, legacy memory.md migration, daily-log pruning, and keyword search. |
internal/scheduler | Cron/once job definitions, file-locked JSON store (~/.gg/scheduler/), the firing loop, and the unattended approval policy. The daemon provides the Executor; the core never imports the agent runtime except for the approver types. |
internal/artifact | Versioned agent-produced deliverables: ~/.gg/artifacts/<id>/ holds artifact.json plus immutable v1.md, v2.md, … Create/version/publish/remove; markdown and HTML types; owner-only permissions (0600 files, 0700 dir). Version allocation and publish marking are serialized with an flock on artifacts.lock. |
internal/library | The user's curated file collection (~/.gg/library/): flat files plus index.json (id, name, source, size, added_at). Accepts uploads (Add) and in-memory content (AddBytes, used by artifact publish). Mutations are serialized with an flock on library.lock; index.json is a reserved name and collisions are case-insensitive. |
internal/filelock | Cross-process exclusive file locking (flock) shared by the artifact and library stores; explicit error on platforms without flock. |
internal/connector | Third-party connection framework: OAuth 2.0 authorization-code flow (localhost callback + PKCE, state CSRF check), token persistence (~/.gg/connectors/<name>.json, 0600, flock-serialized), on-demand refresh. |
internal/connector/google | First provider: Gmail (search/read/send) and Google Calendar (agenda/create) over plain net/http; auto-refresh transport with one 401 retry. Scopes: gmail.readonly + gmail.send + calendar.events. |
internal/tools | Builtin agent tool implementations, including the computer tools (computer.go plus per-OS backends computer_linux.go / computer_darwin.go; Windows and other Unixes get unsupported stubs). Trust boundary: tools run with the user's own OS privileges — any write or external side effect must implement agent.ApprovalDescriber so every call passes the approval pipeline. |
internal/mcp | MCP client on the official modelcontextprotocol/go-sdk: config (~/.gg/mcp.json), stdio/HTTP dialing, schema adaptation, and the mcp_<server>_<tool> tool bridge. Trust boundary: servers are third-party code — stdio subprocesses inherit only a minimal environment, every MCP tool is approval-gated, and a server that fails to dial or list is skipped until its config changes or the Service is reopened. |
ui/src | Shared React UI; Web and Electron use the same HTTP transport. |
ui/electron | Native window, workspace picker, and sidecar lifecycle: spawns ggd --http with a per-launch random token and hands the renderer only its endpoint + token over a narrow context-isolated bridge. |
Dependencies point inward: UI and transports depend on application use cases; the application layer depends on agent and persistence abstractions; the core never imports a UI or network package.
Application components
See run diagnostics and performance for log fields, debug configuration and reproducible long-session benchmarks.
Service is the adapter-facing facade. Its internal components have separate ownership:
| Component | Responsibility |
|---|---|
conversation | Session state, branch state, serialized turns, and durable commits. Model and summary changes update memory only after persistence succeeds. |
turnExecutor | Provider/runner orchestration and context compaction; no transport or command parsing. |
promptBuilder | Personal, project and skill prompt reads without allocating tool resources. |
conversationTools | Capability construction, cached browser/MCP/media resources, and their cleanup. Options.ToolProviders allows a host or test to supply its own registry. |
NewSessionService | Shared hydration and model-selection policy for CLI, daemon and child conversations. Explicit model overrides take precedence over persisted selection. |
Run / eventReplay | Execution outcome and approvals / bounded transport replay. Scheduler and Telegram use Run.Await, so replay expiry cannot turn successful execution into a failure. |
The caller that owns a Service closes it: CLI defers cleanup, while Manager closes retired sessions and drains workers before shutdown. Tool-definition discovery uses a temporary tool owner and closes it after discovery; MCP definitions may still require a live connection.
CLI and Runtime publish artifacts through app.PublishArtifact, which owns the version check and compensation. Failed compensation is reported together with the original publish error. JSONL records share EntryMetadata and one concrete payload; codecs, journal writes and pure tree projection are separate files while the flat v3 format remains unchanged.
Personal layer (profile + memory)
app.SetupPersonal(cfg) runs at every CLI and daemon startup and wires the personal layer into the system prompt:
internal/userprofile: loads~/.gg/USER.md(created bygg init; missing file is fine, failures never block startup) and renders a compact "who you are" block: name/call name, timezone, language, notes.internal/memory: ensures~/.gg/memory/, migrates a legacy~/.gg/memory.mdintomemory/MEMORY.md(legacy file renamed tomemory.md.bak, never overwritten when curated content already exists), prunes expired daily logs, and snapshots prompt content.
System prompt assembly order (after the coding instructions): user profile, curated memory (MEMORY.md), today's daily-log tail (YYYY-MM-DD.md), then project instructions (AGENTS.md) and skills. Tool surface: memory_add accepts scope=general|daily|person:<name>|group:<name> (default general); memory_search does case-insensitive keyword search across scopes and returns up to 10 path:line: snippet hits. Both are hidden when memory is disabled.
Scheduler (cron / once jobs)
internal/scheduler owns job definitions and the firing loop; internal/daemon wires it into the running daemon (ggd), and gg job ... (in internal/cliapp) manages jobs from any process:
- Store:
~/.gg/scheduler/jobs.json(definitions, written atomically via rename) andruns.jsonl(append-only run log, one JSON object per line). Alljobs.jsonread-modify-write cycles are serialized with anflockonjobs.lock(Linux/macOS only), so the CLI and the daemon can edit jobs concurrently. Pure reads useViewand never rewrite the file. State files are owner-only (0600, directory0700), matching session and memory persistence. - Loop: the daemon runs
Scheduler.Run(ctx)in the background (ggd --no-schedulerdisables it). The wait for the next firing is capped at 30s so jobs added or resumed by the CLI while the loop sleeps are picked up promptly. Each tick advances a due job's persistedNextRunbefore dispatching its goroutine, so a long run can never make the loop spin on the same firing; a firing that arrives while the previous run is still in flight is recorded once as "skipped" and itsNextRunadvanced as well. A per-job in-memory guard still prevents overlapping executions. Transient store errors — including a failed startup reconciliation — are reported to the daemon's stderr and retried instead of killing the loop. - Execution: every firing runs one agent turn in a fresh session (
scheduler/<name>-<timestamp>) viaapp.Runtime.StartTurnWithApprover, so runs are auditable and resumable. The job'sTimeout(default 10m) bounds the turn. A job records the workspace directory it was created from; the daemon only fires jobs whose workspace matches its own (jobs created before workspace tracking, with an empty value, fire anywhere). - Approval policy:
scheduler.UnattendedApproverdenies every approval-gated tool by default; a job created with--allow-allopts in. Dangerous shell commands are denied even with--allow-all. This is injected explicitly — the scheduler never relies on a nil approver, which the runner would treat as "allow everything". - Restart semantics: cron jobs are rescheduled from the current time (missed firings are not caught up); a past-due once job that never ran fires once on daemon startup. The daemon writes
~/.gg/ggd.pidsogg job addcan warn when no daemon is alive to fire jobs.
Daemon channels and supervision
Long-running daemon services (the job scheduler, the Telegram bot) run as channels behind one shared launch path in internal/daemon:
- Panic isolation: every channel goroutine, every Telegram update handler, and every stdio JSON-RPC request handler recovers from panics, logs the stack trace, and keeps serving — one bad message or request can never take the whole daemon down. A panicking Telegram update is acked (not re-delivered) because the panic is deterministic for that input; a panicking stdio request gets a JSON-RPC
-32603internal-error response (notifications stay response-free, even on panic). Panics one level deeper are contained too: a panicking scheduled-job execution is recorded as a failed run and reported, and a panicking agent turn finishes its run as failed instead of killing the daemon. - Liveness: a channel that fails at runtime is reported and left stopped (it never takes the daemon down with it). Per-channel state (
running/failed/stopped, plus the error and timestamp) is tracked by a monitor and served on the authenticatedGET /healthendpoint aschannels; the unauthenticated/healthzstays a pure process-liveness probe. - Single instance:
ggdholds an exclusiveflockon~/.gg/ggd.pidfor its whole lifetime, and the outcome is fail-closed: a second full daemon gets "already running" and refuses to start, and any failure to establish the lock at all refuses startup rather than running unguarded (which would risk duplicate scheduled jobs and duplicate Telegram replies). The lock — not the pid inside — is the guard, so a crashed daemon can never block a fresh start. The one exception is a parent-supervised sidecar (--exit-on-stdin-eof, e.g. the Electron app): when another instance already owns the channels, the sidecar serves its API without starting the scheduler/messaging channels instead of failing outright.~/.ggand the pid file are owner-only (0700/0600), enforced on existing paths too, matching the other state stores.
Artifacts and library
internal/artifact owns versioned deliverables; internal/library owns the user's file collection; the two meet at publish time:
- Store:
~/.gg/artifacts/<id>/artifact.json(id, title, type, version, published_version, timestamps) plus immutablev<n>.md/v<n>.htmlfiles, all written atomically (temp file + rename).artifact_editappends a full new version — never a diff merge — so every version stays reproducible. Supported types aremarkdownandhtml; content is capped at 1 MiB per version so artifacts cannot blow up the agent context. All read-modify-write cycles are serialized with anflockonartifacts.lock(viainternal/filelock), so concurrent edits from the CLI and the daemon cannot allocate the same version number.Listalways returns a non-nil slice so JSON callers see[], notnull, on an empty store. - Agent tools:
artifact_create(title, type, content)andartifact_edit(artifact_id, content), registered via theartifactentry inapp.toolProviderswhen the store opens. Both implementApprovalRequest(title/type/size plus a before/after content preview) because they write outside the working directory; the unattended approver denies them unless the job opted into--allow-all. - Publish:
gg artifact publish <id>and the Web Publish button (JSON-RPCartifact.publish) share one flow inapp.PublishArtifact: the latest version's bytes are saved into the library first (library.AddBytes→~/.gg/library/<slug>.<ext>, sourceartifact:<id>), and only then is the artifact'spublished_versionadvanced.Store.Publish(id, expectedVersion)refuses withErrVersionChangedwhen the artifact gained a newer version between the caller's read and the mark — the half-saved library copy is removed and the caller retries instead of recording a published version whose bytes were never saved. - Library:
gg library add <path> [--name]copies a regular file (≤ 50 MiB, enforced on the actual copied bytes, not the pre-copy stat) into~/.gg/library/with collision-safe naming;index.jsonis the source of truth and is updated atomically. Name collisions are detected case-insensitively (the macOS default filesystem is case-insensitive) and the reserved nameindex.jsonis rejected, so an upload can never overwrite or delete the index. All mutations run under anflockonlibrary.lockso concurrent adds cannot reserve the same name or drop each other's entries.library list|remove|pathmanage entries by id or case-insensitive name. - Web:
artifact.list/artifact.get/artifact.publishover the existing JSON-RPC transport;system.infoadvertises theartifactcapability and the Web client only shows the Artifacts tab when the connected daemon reports it. The React Artifacts tab renders markdown withmarkedand HTML inside<iframe sandbox="">(no scripts execute). The reader always shows the latest version (drafts added after publishing stay visible); the Publish button appears wheneverpublishedVersion < version. Trust boundary: artifact content comes from the local user's own agent or files, same trust as the chat transcript.
Runtime model
- A
Runtimeopens sessions by stable ID and never exposes session file paths over the network boundary. - A
Managerowns open conversation services. Different sessions may run concurrently, while one session permits only one active run. Inactive sessions are evicted least-recently-used, and completed runs expire by count and age. - Each turn receives a run ID. Events carry a monotonically increasing sequence number and remain replayable through
run.waitor/eventswithin a bounded retention window. SSE frames include sequence IDs and idle heartbeats, so Web clients can reconnect with exponential backoff and resume from the last event. A client that falls behind receivesevent_history_expiredand reloads the session snapshot. run.activeandrun.getexpose reconnect-safe run state, including the retained sequence window and pending approvals. On reload, Web and Electron clients reopen the last selected session and reattach to its active run.- Tool approval is asynchronous: an
approval_requestedevent pauses the tool, andrun.approveresolves it. Cancellation and steering use the same run/session boundary. - Fork and clone create independent services and stores. Checkout writes a
headentry so the selected tree position survives a restart even if no new message is sent. - JSONL writes use a short-lived cross-process lease plus optimistic head validation. A stale CLI or daemon receives
session_conflictinstead of silently interleaving branches.
Public JSON-RPC surface
The transport-independent methods are:
system.infofor the gg protocol version and capability negotiation; it also reportsdegradedProviders— tool capabilities whose providers failed their most recent build, with reasons (added in protocol 1.3, omitted when healthy). Providers distinguish expected-disabled (Chromium not installed, Google not connected, no MCP config) from operational failures (unreadable store, unreachable MCP server): only the latter are logged and reported. Every session reports both failures and recoveries into one workspace-wide registry, so a provider that recovers in any session stops being reported as degraded. Reasons are sanitized before reaching clients (the home directory is redacted to~); the full error stays in the daemon logsession.list,session.create,session.open,session.get,session.renamesession.actionwithtree,fork, orclonerun.start,run.wait,run.get,run.active,run.cancel,run.approve,run.steerartifact.list,artifact.get,artifact.publish
stdio uses one JSON-RPC object per line and supports concurrent requests, which is required while one request is waiting for an approval. HTTP accepts JSON-RPC at POST /rpc; GET /events streams run events as SSE. HTTP mode requires a Bearer token and binds only to loopback unless --allow-remote is explicit. The packaged Electron renderer loads from file:// (origin null), so the daemon answers CORS preflights (OPTIONS) from null and loopback http(s) origins outside the bearer-token gate; the token stays the real authentication boundary. JSON-RPC 2.0 remains the wire format; system.info.protocolVersion independently versions gg methods, events, DTOs, and stable application error codes.
Tools execute sequentially by default. Only tools explicitly implementing agent.ParallelTool with ParallelSafe() == true may overlap, at most four at once. A mutation or other nonparallel tool forms a barrier between parallel groups. File edit/write operations share per-path locks across sessions within the process, and a tool panic becomes a paired error result with a stack trace in the log.
Concurrent loads of one session reuse the registered Service. Runtime admission defaults to 16 active turns shared by every workspace, with a 30-minute turn timeout; ggd --max-active-runs and --run-timeout override these settings. Replay uses a ring buffer bounded by both 8192 events and 4 MiB of serialized data per run. A single oversized event also expires history; clients receiving event_history_expired reload the session and run status. HTTP RPC bodies are limited to 4 MiB including trailing whitespace and must contain exactly one JSON value, validated before dispatch. RPC body reads and each SSE write have 15-second deadlines; waiting for SSE events has no fixed connection lifetime.
Session appends retain the writer lease and fsync durability. An unchanged file identity, size, and modification time avoids replaying the full JSONL on every append; a changed fingerprint falls back to the full recovery/head-conflict checks. ID lookup reads headers rather than every transcript, while listing logs and skips corrupt neighbors. Shutdown cancels active turns, drains channel workers, and closes every workspace's browser/MCP resources outside manager locks, with a bounded daemon wait before releasing its instance lock.
Client boundary
The React app depends on a small Transport interface. HttpTransport talks to ggd over Bearer-protected HTTP (POST /rpc plus SSE /events) and is shared by the Web and Electron clients. On desktop, the Electron main process spawns ggd --http 127.0.0.1:<port> --token <random> --exit-on-stdin-eof and exposes only the endpoint, token, and workspace label to the renderer; renderer code has no Node.js access. The sidecar's stdin is a control pipe that the main process holds: if the parent dies without cleanup (crash/SIGKILL skips before-quit), stdin reaches EOF and the daemon shuts itself down instead of lingering as an orphan.
The CLI (gg -p, script mode, and management commands) is deliberately not a thin client: it runs the agent in-process through internal/cliapp and never talks to ggd over HTTP. This keeps the CLI usable with no daemon running, with lower latency and script-friendly behavior, at the cost of two execution paths for the same agent logic (in-process vs. ggd-hosted for Web/Electron). This is a known trade-off, not an oversight: converge the CLI onto the HTTP transport only if the two paths observably drift in behavior.
Extension rules
When adding behavior:
- Put the use case and its tests in
internal/app. - Add or change persistence behind
session.Repository. - Expose a transport-neutral method/event only when a client needs it.
- Keep Web- or Electron-specific policy in its adapter.
- Keep file paths, provider secrets, and unrestricted IPC out of public DTOs.
- Add a new agent capability as a
ToolProviderentry, an MCP server declaration, or a skill — never a central if/else chain. If it writes or causes an external side effect, implementagent.ApprovalDescriberso the unattended approver denies it by default. - Platform- or environment-dependent tools degrade through
Available/Buildand are never advertised when unavailable;ToolCapabilities()stays derived from the registry. - External code and prompt content (MCP servers, skills) run at the lowest trust: scrubbed subprocess environments, approval-gated tools, fail-skip instead of fail-fatal.
This makes a future mobile client, remote Web deployment, or alternative terminal UI another adapter rather than another implementation of the agent.
Adding agent tools
Tools are assembled per turn by the ToolProvider registry (internal/app/toolregistry.go): each provider's Build returns one capability's tools, and a nil slice or error degrades that capability to absent without affecting the rest. To add a tool:
- Implement
agent.Toolininternal/tools(or the capability's own package). - Add or extend a
ToolProviderentry intoolProviders— never a central if/else chain. - If the tool performs a write or external side effect, implement
agent.ApprovalDescriberso every call passes through the approval pipeline (the unattended approver denies approval-gated tools unless the job opted in).
Transports advertise capabilities from app.ToolCapabilities(), which is derived from the registry, so the advertised set cannot drift from the tools actually registered.
MCP servers
External tools come from MCP servers declared in ~/.gg/mcp.json:
{"servers": {
"filesystem": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"], "env": {"API_KEY": "..."}},
"remote": {"url": "https://example.com/mcp"}
}}internal/mcp dials each server (stdio subprocess or streamable HTTP) and adapts its tools as mcp_<server>_<tool> (sanitized to function-calling-safe names, max 64 chars, numeric suffix on collision). Server tools always implement ApprovalDescriber: every MCP call is approval-gated. Connections are memoized per Service (one dial per conversation) and reaped on Service.Close. A server that fails to dial or list is skipped until its config changes or the Service is reopened, so one broken server cannot slow every turn. stdio servers inherit only a minimal environment (PATH/HOME plus the server's own env map) — the parent process environment is never passed through wholesale.
Skills
Skills (internal/skills) are the third extension layer: markdown playbooks loaded from project skill roots and ~/.agents/skills, read by the model on demand. A skill adds knowledge and procedure, not code execution — the agent carries out skill steps with the existing tool surface, so a skill inherits the approval policy of the tools it uses. Trust boundary: skills are prompt content from the repo or the user's own files; treat third-party skill text like any untrusted prompt input.
Extension layers
The three layers, from most to least trusted:
- Builtin providers (
internal/app/toolregistry.go): compiled into gg, built per turn, degrading to absent when prerequisites are missing (no Chromium, Google not connected) or unsupported on the platform (Availablegate). Writes and external side effects implementagent.ApprovalDescriber. Worked example: the computer tools —internal/tools/computer.goplus per-OS backends; on Windows and other Unixes the capability is neither built nor advertised. - MCP servers (
~/.gg/mcp.json,internal/mcp): external processes exposing tools asmcp_<server>_<tool>; always approval-gated; connections memoized perService; a broken server is skipped, never fatal. - Skills (
internal/skills): markdown procedures the model reads on demand; no new code runs; approval comes from the tools the steps use.
Deliberate decision — browser navigation is approval-gated. browser_navigate requires user approval: the URL shown in the approval prompt is checked against the SSRF guard (which rejects cloud metadata, localhost, intranet targets) before Chromium ever connects, and the user confirms the exact destination. This was tightened because the SSRF guard only inspects the initial URL — Chromium follows redirects and page subresources on its own, so an initial-URL check alone cannot keep navigation off internal targets. browser_read and browser_screenshot stay exempt: they operate on the page the user already approved and cannot trigger new navigation. Residual risks: (1) post-approval redirects can still reach internal hosts (Chromium follows them without re-check); (2) in-context data can be exfiltrated by embedding it in a fetched URL — accepted, since secrets should not sit in the agent's context unprotected. Approval-gating only the navigation (not every read) keeps autonomous research usable: one approval opens the page, subsequent reads are free.
app.ToolCapabilities() derives the advertised set from the registry, so clients never see a capability whose tools cannot be built.
Security model
Tool use runs as the user. Approval is the gate for writes and other side effects; a nil approver in the agent runner still means "allow", so every caller that can run tools passes an approver on purpose. The CLI no longer uses a nil approver for --approval auto.
Approval modes
The CLI policy lives in internal/cliapp. It applies to one-shot prompts (gg -p and gg <prompt>) and to the line-based interactive mode.
| Mode | Terminal | Other approval-gated tools | Dangerous bash |
|---|---|---|---|
auto (default) | prompt | deny, with a message that explains --yolo and --approval on-request | prompt when a terminal can ask, otherwise deny |
on-request | prompt; the process exits if there is no terminal | same | prompt |
yolo (--yolo or --approval yolo) | allow without a prompt | allow without a prompt | prompt when a terminal can ask, otherwise deny |
never | allow without a prompt | allow without a prompt | deny (this mode never asks) |
"Terminal" means stdin and stderr are both terminals, which is what the line prompt can actually use. Clearly read-only bash commands skip the prompt in every mode. edit, write, and the other approval-gated tools are not read-only. Writes into the workspace agent area (.gg/agent/) stay pre-approved, as before.
The Web and desktop clients keep the daemon's runApprover: the UI asks, and a missing answer does not become an allow. Scheduled jobs use scheduler.UnattendedApprover (deny by default, --allow-all opts in, dangerous bash still denied).
Command classifier
tools.ClassifyCommand labels each bash command before the approver runs. The label and the reason are fields on agent.ApprovalRequest (risk, riskReason) and are included in the approval details.
- Read-only (
ls,cat,head,tail,grep,rg,findwithout-exec/-delete,git status/diff/log/showwithout a leading-c/--config-envand without--ext-diff/--textconv,go version/env,go listonly with-mod=readonlyor-mod=vendor,dateonly when it formats or parses a time, and a short allowlist of other inspection commands). No prompt.git -cis not read-only: acore.fsmonitorvalue is a hook command.go list -mod=modand an unconstrainedgo listare not read-only.date -s,date --set, and a positional timestamp set the clock and are not read-only. - Dangerous.
rm -rfof/,/*,~,$HOME,/home,/Users, or/root;rmand other writes (>,cp/mv/tee/chmod/chown, …) that resolve outside the working directory;curl/wget/fetchpiped into a shell, including throughenv(curl … | env sh);sudo/doas/pkexec/su; disk and format tools (mkfs,fdisk,dd of=/dev/…,diskutil, …); power commands (shutdown,reboot, …). The reason is shown on the approval request. These are not waived by--yoloor by a job's--allow-all. A write path is resolved through the nearest existing directory, symlinks included, before it is compared with the working directory. - Everything else (tests, builds, ordinary edits,
rminside the working directory) keeps the mode's normal policy.
Chains (&&, ||, |, ;) are classified per segment. A download piped into a shell is dangerous even when each segment would otherwise look ordinary. $(...) and backticks are classified with quote and escape state, so echo "$(printf ')'; rm -rf /)" is dangerous and a single-quoted '$(rm -rf /)' is not executed. bash -c and eval re-enter the classifier on their script text.
Sandbox
--sandbox is off by default. When it is set, bash tries to run inside an OS helper:
- Linux:
bwrap, with the filesystem read-only except the working directory, a fresh tmpfs on/tmp, and--die-with-parent. The tmpfs is mounted before the working directory is bound, so a workspace under/tmpstays visible. - macOS:
sandbox-exec, denying file writes except the working directory,/tmp, and the per-user temporary directory. - Anywhere else, or when the helper is not on
PATHor cannot start (user namespaces off, seatbelt denied): the command runs as usual and its output says the sandbox was unavailable.
The probe runs once per process. Sandboxed commands use shell -c so a login profile that writes outside the working directory does not fail the sandbox. The network is not confined.
Known limitations
- The classifier is not a shell. Aliases, encoded payloads, and interpreters (
python -c,perl -e,busybox) can express the same effect without matching a pattern. Obfuscation is out of scope; the goal is to catch the obvious cases and to fail closed (ask, or deny) when a line cannot be treated as read-only. - Only
~and$HOME/${HOME}are treated as the home directory.rm -rf $DIRis not known to be catastrophic. - The read-only allowlist is by program name. A different binary earlier on
PATHwould be trusted. The sandbox is the control that does not depend on that name. --yoloand--approval neverrun ordinary writes and any shell command the classifier does not flag, as the user.- The sandbox does not block network access. On macOS,
/tmpand the user temp dir stay writable. A helper that fails its probe degrades to no sandbox rather than refusing the command. - Windows has no sandbox helper.
- Approving a prompt, passing
--yolo, or creating a job with--allow-allruns the tool with the user's own privileges. Approval is a checkpoint, not a confinement boundary, except for the optional sandbox's filesystem-write limits.
Quality gates
Every PR is expected to arrive green and stay reviewable:
- CI: the 6 checks (Go on ubuntu-latest and macos-latest, Web and Electron) must pass;
go vet ./...runs in CI and must be clean. - Race:
go test -raceoninternal/app,internal/session,internal/transport/...(CI runs this on Linux). - Format:
gofmt -lclean. Runmake checklocally before pushing — it runs format, vet, and the full test suite. - New packages: interfaces first, unit tests on the core paths, typed errors, doc comments on exported symbols.
- Size: keep PRs under ~500 lines so one reviewer can hold them in their head; split bigger work into stacked phases.
- Review: Codex review findings are verified independently — a finding is fixed only after confirming it against the code, not on the review's say-so. Valid P0/P1/P2 are fixed; P3s are judgment calls. Merge only on green CI.
- Platforms: Linux and macOS are supported.
internal/toolsandinternal/appmust also compile for Windows (GOOS=windows go build), and every new platform-split file needs anotherstub so the package compiles on any Unix target.