Tools & Capabilities¶
Agents act on the world through tools. SynthOrg defines a pluggable tool system with 15+ categories (file system, git, web, database, terminal, sandbox, MCP bridge, analytics, communication, design, headless browser, governed external data access, virtual desktop), layered sandboxing (subprocess for low-risk, Docker for high-risk, Kubernetes for future multi-tenant), MCP server integration, and a progressive-disclosure model that limits the surface exposed to an agent to what its role and autonomy tier permit.
Tool Categories¶
| Category | Tools | Typical Roles |
|---|---|---|
| File System | Read, write, edit, list, delete files | All developers, writers |
| Code Execution | Run code in sandboxed environments | Developers, QA |
| Version Control | Git operations, PR management | Developers, DevOps |
| Web | http_request (raw HTTP under an SSRF policy), html_parser, web_search (vendor-agnostic, with declared recency / domain filters) and web_fetch (one page as markdown over an explicit local / proxy / render ladder). See web-research.md |
Researchers, analysts, any agent working against a third-party API |
| Database | Query, migrate, admin | Backend devs, DBAs |
| Terminal | Shell commands (sandboxed); shell_command can run one in the background (background=True, when tools.shell_command_background_enabled), returning a job id that check_background_job / read_background_job_output / cancel_background_job / list_background_jobs address on a later turn. See Background Shell Commands |
DevOps, senior devs |
| Design | Image generation, mockup tools | Designers |
| Communication | Email / notification dispatcher tools (the Slack notification sink lives here; agent-invocable Slack access is the chat_* tools under External Data) plus delegate_and_await, the blocking sub-agent delegation tool that runs a child Task inline and returns its transcript (gated on a wired SubAgentRunner) |
PMs, executives |
| Analytics | Metrics, dashboards, reporting | Data analysts, CFO |
| Deployment | CI/CD, container management | DevOps, SRE |
| Memory | Search memory, recall by ID | All agents (tool-based strategy) |
| Browser | Headless Playwright + Chromium: navigate, screenshot, SSIM diff, axe accessibility scan, full spec, direct WebStorage read/write (storage_get/storage_set/storage_remove/storage_clear), and WebAuthn passkey handling via a virtual authenticator (webauthn_install/webauthn_create_credential/webauthn_list_credentials/webauthn_delete_credential). The url field is restricted to http/https and rejects link-local / cloud-metadata hosts (169.254.169.254, metadata.google.internal); local files use the workspace-scoped path field. Loopback and private addresses stay allowed so the in-sandbox app-under-test is reachable. Session state (cookies, localStorage, passkeys) persists across calls (see Browser Session State) |
QA, frontend devs, agents validating web deliverables |
| External Data | Governed external API/data access through a configured connection: credentials brokered from the connection catalog, egress constrained to the connection host, and sensitive/write calls gated to approval. The generic external_api tool adds a full SSRF policy + DNS pinning + per-connection rate limiting on top. The first-party forge tools (forge_repo, forge_issue, forge_pull_request, forge_ci) give the software-build org real hands on a repository (read repo/file, open/comment issues, open/comment/review/merge PRs, read CI runs; forge_ci is GitHub-only even on a bound Forgejo connection) and the chat tools (chat_messages, chat_directory) drive the operator control-plane channel (egress pinned to the platform host, e.g. slack.com, by construction). All are vendor-neutral (the concrete forge/chat provider is selected by the bound connection's type); reads on a sensitive connection and every write route through the identity-bound approval flow |
Agents consuming third-party APIs, driving a forge, or messaging the operator while building deliverables |
| Desktop | Virtual desktop (Xvfb + xdotool + scrot in a container): launch a GUI app, click/type/press-keys/scroll, capture screenshots | QA, frontend devs, agents validating GUI deliverables |
| MCP Servers | Any MCP-compatible tool | Configurable per agent |
Tool Execution Model¶
When the LLM requests multiple tool calls in a single turn, ToolInvoker.invoke_all runs them
in program order, the order the model issued them, which is the order it meant: a mutating
call runs alone, after everything issued before it has finished, and only a run of consecutive
read-only calls fans out under one asyncio.TaskGroup. Which calls are read-only is declared
in security/action_types.py::READ_ONLY_ACTION_TYPES (code:read, vcs:read, db:query,
memory:read), a claim about effect rather than about the verb (test:run reads like a read
and leaves build artefacts behind), and a custom action type is mutating until declared
otherwise. Running everything side by side was the first design: two edits of one file issued
in one turn raced, and the second hunk was applied to text the first had already replaced. An
optional max_concurrency parameter (default unbounded) limits a read stage's parallelism via
asyncio.Semaphore. Recoverable errors are captured as ToolResult(is_error=True) without
aborting sibling invocations. Non-recoverable errors (MemoryError, RecursionError) are
collected and re-raised after all stages complete (bare exception for one, ExceptionGroup
for multiple).
Permission checking follows a priority-based system:
get_permitted_definitions()filters tool definitions sent to the LLM; the agent only receives tools it is permitted to use- At invocation time, denied tools return
ToolResult(is_error=True)with a descriptive denial reason (defence-in-depth against LLM hallucinating unpresented tools)
Resolution order: denied list (highest) > allowed list > access-level categories > deny (default).
Tool Sandboxing¶
Tool execution uses a layered sandboxing strategy with a pluggable SandboxBackend
protocol. The default configuration uses lighter isolation for low-risk tools and stronger
isolation for high-risk tools.
Sandbox Backends¶
| Backend | Isolation | Latency | Dependencies | Status |
|---|---|---|---|---|
SubprocessSandbox |
Process-level: env filtering (allowlist + denylist), restricted PATH (configurable via extra_safe_path_prefixes), workspace-scoped cwd, timeout + process-group kill, library injection var blocking, explicit transport cleanup on Windows |
~ms | None | Implemented |
DockerSandbox |
Container-level: keep-alive container reused per the configured lifecycle strategy (per-agent default; per-call for maximum isolation), workspace mount reproduced from the parent's own storage (below), no network (default) or sidecar-based host:port allowlist (dual-layer DNS + DNAT transparent proxy), resource limits (CPU/memory/time) |
~1-2s on first acquire; reused warm thereafter | Docker | Implemented |
K8sSandbox |
Pod-level: per-agent containers, namespace isolation, resource quotas, network policies | ~2-5s | Kubernetes | Planned |
Default Layered Sandbox Configuration
sandboxing:
default_backend: "subprocess" # subprocess, docker, k8s
overrides: # per-category backend overrides
file_system: "subprocess" # low risk -- fast, no deps
git: "subprocess" # low risk -- workspace-scoped
web: "docker" # medium risk -- needs network isolation
code_execution: "docker" # high risk -- strong isolation required
terminal: "docker" # high risk -- arbitrary commands
database: "docker" # high risk -- data mutation
browser: "docker" # opt-in -- Playwright + Chromium image
desktop: "docker" # opt-in -- Xvfb + xdotool + scrot image
subprocess:
timeout_seconds: 30
workspace_only: true # restrict filesystem access to project dir
restricted_path: true # strip dangerous binaries from PATH
docker:
image: "ghcr.io/aureliolo/synthorg-sandbox:vX.Y.Z" # replace with the release you run
network: "none" # no network by default
network_overrides: # category-specific network policies
database: "bridge" # database tools need TCP access to DB host
web: "bridge" # web tools need outbound HTTP; no inbound
allowed_hosts: [] # allowlist of host:port pairs (TCP only)
dns_allowed: true # allow outbound DNS when allowed_hosts restricts network
loopback_allowed: true # allow loopback traffic in restricted network mode
memory_limit: "512m"
cpu_limit: "1.0"
timeout_seconds: 120
pids_limit: 64 # PID cap (main container) -- guards against fork-bomb runaways
tmpfs_size: "64m" # tmpfs mounted at /tmp in the main container
sidecar_tmpfs_size: "8m" # tmpfs for the network sidecar container
mount_mode: "ro" # read-only by default
k8s: # planned -- per-agent pod isolation
namespace: "synthorg-agents"
resource_requests:
cpu: "250m"
memory: "256Mi"
resource_limits:
cpu: "1"
memory: "1Gi"
network_policy: "deny-all" # default deny, allowlist per tool
The network sidecar's PID cap, memory/CPU limits, and health-check timing are separate,
live-reloadable tools.docker_sidecar_* settings rather than part of this file. Container
removal is not a setting: AutoRemove is always off, so the boot reconciliation pass can find
and reclaim a container a hard kill left running.
Per-category backend selection is implemented in tools/sandbox/factory.py via three functions:
build_sandbox_backends (instantiates only the backends referenced by config),
resolve_sandbox_for_category (looks up the correct backend for a ToolCategory), and
cleanup_sandbox_backends (parallel cleanup with error isolation). The tool factory
(build_default_tools_from_config) wires tool categories. Core tools
(FILE_SYSTEM, VERSION_CONTROL, web, etc.) are part of the default toolset
and always registered. The
auxiliary categories DESIGN, COMMUNICATION, EXTERNAL_DATA, and ANALYTICS
are opt-in: tools are only registered when the corresponding config section is
present, and some individual tools additionally require a runtime dependency (e.g.
image tools require an ImageProvider, notification tools require a dispatcher,
the forge_*/chat_* tools require a bound connection and an enabling setting,
analytics query/metric tools require a provider or sink).
How the sandbox reaches the workspace¶
A bind spec travels to the daemon as a string and is resolved in the daemon's
namespace, which is the caller's only while the caller runs on the host. A
containerised backend that passes its own /data/agent-workspaces names a host
path that generally does not exist, and Docker creates an empty directory and
mounts that: the sandbox starts with an empty /workspace, and every command
reports an ordinary failure that has nothing to do with the command.
So a containerised backend does not pass a path. tools/sandbox/workspace_mount.py
asks the daemon how the backend's own storage is provided (reading its container
id from /proc/self/mountinfo, falling back to the hostname) and reproduces it:
- a named volume becomes the same volume plus the subpath the workspace sits
at, via
Mounts[].VolumeOptions.Subpath(Docker API >= 1.45). Reproducing the subpath rather than mounting the volume whole is what keeps one project's sandbox out of another project's files, so an older daemon is refused rather than quietly widened; - a host bind becomes the host side of that bind plus the same relative remainder;
- a backend running on the host keeps the plain host-path bind, unchanged.
A containerised backend whose workspace root is covered by none of its own
mounts raises SandboxWorkspaceUnmappableError. That refusal is the point: the
alternative is the silent empty mount above, which reads as a build that failed
rather than a workspace that was never there.
MCP stdio server sandboxing¶
ToolCategory.MCP is not routed through resolve_sandbox_for_category.
A stdio MCP server runs a third-party command (npx -y <package>@<version>),
so its isolation is a bespoke transport: the policy lives in
tools/mcp/sandbox.py and tools/mcp/container_stdio.py creates the container
over the Docker API, attaching stdin+stdout before the start so the
MCP protocol flows over the container's stdio while the server runs under
--cap-drop=ALL, --security-opt=no-new-privileges, a read-only rootfs,
NPM_CONFIG_IGNORE_SCRIPTS, and cpu/memory/pid limits. This mechanism is
parallel to, not part of, the per-category SandboxBackend selection above,
and is controlled by its own tools.mcp_sandbox_* settings (on by default,
fail-secure to on). D16 classifies MCP with the high-risk Docker-required
categories; this is how that requirement is met for the stdio transport.
Image generation¶
Image generation is a native provider capability, not a standalone subsystem.
A model advertises it through the supports_image_generation capability flag
(ModelCapabilities / ModelMetadata), populated from the provider preset or
the upstream model database, and surfaced in the dashboard model-management
view. The provider's generate_image capability (an ImageGenerationMixin
layered onto the completion driver, kept out of BaseCompletionProvider for its
size budget) runs the call through the same retry, rate-limit, and
cost-recording path as chat completion. Per-image cost is billed through a
cost_per_image model field under the image_generation call category: the
tool invoker opens a cost_recording_scope around the image_generator tool
(which declares that category), so a priced image model attributes spend to the
agent/task exactly like a completion. An unpriced image model (cost_per_image
unset) produces no cost record at all rather than a zero-valued one, since
is_zero_usage short-circuits the build before it starts; BUDGET_IMAGE_MODEL_UNPRICED
fires at warning naming the model and the setting that would price it, the
same pattern the unpriced-embedder warning follows (docs/design/memory.md).
The design image_generator tool routes through this layer via a
ProviderImageProvider adapter that satisfies the ImageProvider seam. Two
settings gate it, both off by default: design.image_generation_enabled and
design.image_model (a model reference the operator selects from connected
models). Connecting a provider whose preset ships an image model (for example
OpenAI or Gemini) makes that model selectable; the scripted provider ships an
offline, deterministic image model for tests and air-gapped installs.
Generated bytes are persisted through the design-asset store rooted at
design_tools.asset_storage_path (a durable, path-traversal-guarded filesystem
store, or an in-memory store when the path is unset), and can then be listed and
retrieved through the asset_manager tool. This is a separate seam from the
generic ArtifactStorageBackend used for workspace deliverables: design assets
carry their own id/metadata-sidecar lifecycle and are queried by the design
tools, not the task-artifact pipeline.
Network posture. An image call is a provider call: egress leaves the API
process to the provider using credentials brokered by the connection catalog,
exactly like a chat completion. The Docker sandbox default network: "none" is
unaffected because the design tool and the provider run in the API process, not
inside a sandbox container. There is no separate egress allowlist for image
generation; it inherits the provider connection posture. Residual gap: an
operator who selects a hosted image model accepts that provider outbound
egress, the same trade-off as enabling any hosted completion model.
Docker is optional; only required when code execution, terminal, web, database, or browser tools are enabled. File system and git tools work out of the box with subprocess isolation. This keeps the local-first experience lightweight while providing strong isolation where it matters.
The Docker backend talks to the daemon over aiodocker (async-native) against a
Wolfi-based, apko-composed sandbox image (Python, Node.js, and basic utilities; see
docker/sandbox/apko.yaml). If Docker is unavailable, the framework fails with a clear
error for any tool category whose configured backend is Docker; low-risk categories
(file_system, git) continue to run via subprocess (Decision Log D16).
Container Log Shipping¶
DockerSandbox collects structured logs from both sandbox and sidecar containers
before removal and ships them through the backend's observability pipeline.
Sidecar JSON stdout is parsed line-by-line; malformed lines are skipped.
Sandbox stdout/stderr are shipped alongside the sidecar entries. All shipped
events carry correlation context (agent_id, session_id, task_id,
request_id) injected via structlog contextvars, and the same IDs are set as
SYNTHORG_AGENT_ID, SYNTHORG_SESSION_ID, SYNTHORG_TASK_ID,
SYNTHORG_REQUEST_ID environment variables in both containers so
container-side logs can self-correlate.
SandboxResult includes optional Docker-specific fields: container_id,
sidecar_id, sidecar_logs, agent_id, and execution_time_ms. These
default to None/empty for non-Docker backends.
Log shipping is failure-tolerant (errors are logged at debug level, never
propagated) and bounded by ContainerLogShippingConfig.collection_timeout_seconds
and max_log_bytes. By default only metadata (sizes, counts, timing) is
shipped; raw stdout/stderr/sidecar payloads require explicit opt-in via
ship_raw_logs=True to prevent secrets from bypassing key-name-based
redaction. Configuration lives on LogConfig.container_log_shipping
(default: enabled).
Scaling Path
In a future Kubernetes deployment, each agent can run in its own pod via
K8sSandbox. At that point, the layered configuration becomes less relevant; all tools
execute within the agent's isolated pod. The SandboxBackend protocol makes this
transition seamless.
Sandbox Lifecycle Strategies¶
Container lifecycle isolation (when to create, reuse, or destroy sandbox containers)
is configurable via the pluggable SandboxLifecycleStrategy protocol
(src/synthorg/tools/sandbox/lifecycle/protocol.py). Three built-in strategies control
the trade-off between resource efficiency and isolation:
| Strategy | Behaviour | Use case |
|---|---|---|
per-agent (default) |
One persistent container per agent; destroyed after a configurable grace period (default 30s) when the agent stops | Development, trusted environments |
per-task |
New container per task; destroyed immediately on task completion | Production, medium isolation |
per-call |
New container per tool invocation; destroyed immediately (current ephemeral behaviour) | High-security, maximum isolation |
Strategy selection via sandboxing.docker.lifecycle.strategy in SandboxingConfig.
The sidecar container shares the sandbox container's lifetime (created and destroyed
together, since they share a network namespace).
The configured default is per-agent (the strategy field default in
SandboxLifecycleConfig); the table above is authoritative. The strategy is
constructed at boot (workers/runtime_builder) with the application clock and
injected into DockerSandbox via the sandbox factory. Each tool call runs as
a docker exec inside a long-lived idle container (tail -f /dev/null
entrypoint) the strategy acquires; per-agent and per-task reuse the container
across calls while per-call destroys it immediately after the single exec. The
lifecycle owner is resolved from an explicit owner_id, else the structlog
correlation context (agent_id for per-agent, task_id for per-task). The
per-call degradation below is a per-invocation safety fallback, not a change
of the configured default: when a reuse strategy cannot derive an owner for a
given call, that single call degrades to ephemeral per-call behaviour while
the configured strategy stays in force for calls that can resolve an owner. AgentEngineExecutionService releases the owner at the
task boundary (per-task destroys immediately; per-agent starts the grace
timer so a subsequent task for the same agent within the window re-acquires
the warm container).
A container is reclaimed at four points, and each covers what the others
cannot see. The task boundary covers a task. The boot reconciliation pass
covers a previous incarnation, finding orphans by the synthorg.managed=true
label after an unclean exit. Between them sits the warm container a reuse
strategy is holding when this process stops, which shutdown reclaims:
cleanup_tracked_sandbox_backends() runs as an ordered step of _run_shutdown
after every agent drain, so an in-flight command has finished and its own
per-task release has run. Without that step the warm container waits for a next
boot, and an operator scaling down is not an operator restarting.
The fourth is the container whose task-boundary release never fired: a run
that died between acquiring and releasing, or a task parked on a human whose
agent holds a warm container across the wait. Boot reconciliation cannot be
re-run for it, because its grounding ("is there a row") is right only at a
moment this process has created nothing of its own; mid-process, a container
with no row is as likely being created as orphaned. The reclamation sweep
(tools/sandbox/reclaim.py, subsystem sandbox_reclaim, requiring
persistence and the boot pass) asks a different question of a different
source: it walks the keys the lifecycle strategy itself holds, reads the owner
segment back out of each (the last colon segment once the mount-mode and
image suffixes are stripped, which is unambiguous because an owner is a UUID),
and asks the task table whether that owner's run has finished: a per-task
owner whose task is no longer assigned, in progress or in review, or a
per-agent owner with no such task at all. A finished owner is released through
release_key, the same lifecycle path the execution service releases on, so
the grace window, idle timer and background-job pin still apply and the sweep
destroys nothing itself. It runs a first pass at wiring and then on
tools.sandbox_reclaim_interval_seconds (paused by
tools.sandbox_reclaim_paused, both live); it declines by name under the
per-call strategy or a backend that holds nothing past a call; a key it cannot
read is reported and kept, because a container it cannot attribute is one it
cannot know to be finished. Distributed deployments are out of scope by the
same rule run recovery states: a dead runner there is JetStream's to notice.
The task-boundary point is no longer unconditional once background shell
commands are wired: grace/idle destroy is now gated on the container's own
pin_check (see Container pinning below), so a
container a live background job is still running in survives its own
grace/idle window rather than being destroyed on schedule.
Its population is derived rather than listed. Four sites build backends independently (the agent runtime, the tool factory when handed none, the toolsmith wiring, the self-improvement code applier) and nothing memoises, so no owner holds them all; the factory records what it builds, weakly, and the shutdown step drains that record.
Background Shell Commands¶
shell_command accepts a background: bool flag (default False,
mutually exclusive with the foreground timeout field). A backgrounded
command returns a job id immediately; four sibling terminal tools address
it on a later turn: check_background_job, read_background_job_output,
cancel_background_job, list_background_jobs. Docker-only (SubprocessSandbox
refuses loudly with SandboxBackgroundUnsupportedError; the CODE_EXECUTION/
TERMINAL categories are already force-routed to docker, so this is a
correctness formality). The per-call lifecycle strategy refuses to run a
job in the background at all (SandboxBackgroundNoReusableContainerError):
it has no persistent container for a job to outlive its own single
invocation, so start_background checks the resolved strategy's
reuses_container property before doing anything else.
Owner key, not raw id. A job's persisted owner_id is the resolved
lifecycle owner key start_background and list_background_jobs both
derive via _resolve_background_owner_key, not the caller's raw
owner_id argument. That resolution folds in the same resolve_mount_mode
segment execute() already applies to a foreground call's own container
key. Omitting it would file a background job's rows under an unqualified
owner while the agent's own foreground calls key their container under the
mount-mode-suffixed form, so the job would silently pin a container the
agent never actually uses. The per-owner concurrent-job cap
(tools.shell_command_background_max_concurrent_jobs) is enforced against
this same resolved key.
Container pinning. A live background job keeps its container alive
past the lifecycle strategy's own grace/idle expiry: PerAgentStrategy
and PerTaskStrategy each take an optional pin_check: Callable[[str],
Awaitable[bool]] consulted immediately before a container would
otherwise be destroyed; while it returns True, the strategy reschedules
the check instead of tearing the container down. DockerSandbox.pin_check
is the bound method wired in as that callable -- it also self-cleans,
force-cancelling (kill, then mark TIMED_OUT) any job past its own
max_duration_seconds before reporting whether anything genuinely live
remains, so no separate sweep task is needed for the duration ceiling.
Wiring pin_check has a real construction-order cycle: create_lifecycle_strategy
must build the strategy before build_sandbox_backends can construct the
DockerSandbox whose bound pin_check method the strategy needs, but the
strategy needs that same callable to exist. Boot wiring (tool_registry_assembly.py)
breaks the cycle in two steps -- the strategy is constructed first with no
pin check, then, once the DockerSandbox exists, strategy.bind_pin_check(docker_backend.pin_check)
sets it as a second step. bind_pin_check_if_wired (workers/_background_job_wiring.py)
declines that second step -- leaving pin_check unbound -- on any of three
gates: no background-job repository resolved yet (persistence not connected),
the configured lifecycle strategy is not PerAgentStrategy / PerTaskStrategy
(per-call has no persistent container to pin), or no docker entry exists in
the constructed sandbox backends. A test double built without this second step
observes the same thing: pin_check is None degrades to unconditional
destroy-on-expiry, unchanged from before this feature existed.
Mechanism. A background job is a wrapper script, not aiodocker's
Detach: true path: it setsids the real command (so its PID is a
process-group leader that can be signalled as a group), redirects
stdout+stderr through a
bounded-byte-count copy into a job-scoped file under the container's
/tmp (a separate tmpfs mount, outside the workspace bind, so it is
invisible to the zero-artifact workspace scan), records the confirmed PID,
and backgrounds the real work with & while the wrapper's own foreground
half just confirms the PID over a short, fast-returning attached exec.
Output is capped head-first at write time (same truncation direction
shell_command's own foreground path already uses), governed by
tools.shell_command_background_output_byte_cap. Liveness is checked
primarily through an exit-code sentinel file the wrapper writes on the
tracked process's own exit, with a raw PID signal only as a fallback --
a container's PID namespace is small and long-lived enough that a stale
PID could in principle be reused. A job's network posture is whatever the
container's own network setting already is: there is no per-exec network
override in the Docker API, so this is inherited, not independently
enforced.
Pinned foreground timeouts. A foreground execute() call's own
timeout must not collaterally kill a sibling background job sharing the
same container. Before opening the exec, _exec_command
(docker_sandbox_exec.py) checks BackgroundJobRegistry.has_live_jobs
(a cheap read-only query, deliberately not self-cleaning the way
pin_check's own expire_overdue is) against the target container. When
False -- no registry wired, or no live jobs -- the exec runs exactly as
it always has: same _open_exec / _drain_exec, same unconditional
_stop_container on timeout, byte-for-byte unchanged. When True,
_exec_command dispatches to _exec_command_pinned
(docker_sandbox_pinned_exec.py's DockerSandboxPinnedExecMixin), which
wraps the command via build_pinned_exec_command (_background_wrapper.py):
setsid plus a pidfile write ahead of a bare final exec, so the shell
becomes a process-group leader that can be signalled as a group, without
changing anything else about how the command runs -- output still
streams through the same attached exec (_drain_exec_pinned, a near-copy
of _drain_exec), not through a file, so this deliberately does not
reuse the background-job wrapper's detach-and-poll mechanism (that would
merge stdout/stderr, cap output where the sandbox layer applies none
today, and change what shell_command.py's record_if_test_run persists
as build/test evidence). On timeout, a short control exec reads the
pidfile back. A parsed positive pid is killed via the same
_kill_background_process_group helper cancel_background already
uses, which spares both the container and every job pinning it. An
unreadable or non-numeric pidfile falls back to _stop_container, the
same honest floor as the unpinned path. The pidfile's scratch directory
is cleaned up afterwards on a swallowed-failure basis; its id is a fresh,
purely local uuid4(), never
persisted to BackgroundJobRepository.
Settings (all group="Terminal", SettingLevel.ADVANCED):
shell_command_background_enabled (bool, default true, read live per
call), shell_command_background_max_concurrent_jobs (int, default 5,
resolved once into ToolCeilings at construction),
shell_command_background_output_byte_cap (int, default 1000000 bytes,
same ToolCeilings shape), shell_command_background_max_duration_seconds
(float, default 3600.0, read live at job-start time -- a job keeps the
ceiling in force when it started for its own lifetime, mirroring
shell_command_timeout_seconds's own live-read precedent for foreground
calls).
Virtual Desktop & Vision Verification¶
For GUI deliverables an agent must SEE and operate the running app, not just
unit-test it. The desktop tool (tools/desktop/) drives a headless X session
inside the existing DockerSandbox: it launches a windowed GUI app, injects
pointer / keyboard input via xdotool, and captures screenshots via scrot. The
session is stateful across calls because the per-agent lifecycle keeps the warm
container (Xvfb + the running app) alive between tool invocations; a per-call
reset surfaces as DesktopAppNotRunningError rather than a silent empty capture.
The session bring-up is pluggable behind a DesktopDriver protocol + factory
(tools/desktop/driver/): xvfb (the deterministic default: Xvfb + xdotool +
scrot) and vnc (adds an x11vnc observation channel). The protocol leaves room
for a future Windows-container / Wayland driver without reworking the tool. The
driver targets Linux-renderable GUI toolkits (Qt / Tk / GTK / Electron / X11);
the desktop-capable image is built from docker/desktop/Dockerfile. Screenshots
are written under <workspace>/.synthorg/desktop/screenshots/ with a sha256, so
they are durable provenance on disk (never in the database).
The screenshots feed the vision verifier quality gate (the UI cousin of the red-team gate); see Verification & Quality.
Browser Session State: WebStorage and WebAuthn¶
The browser tool exposes two capability families that carry state between
separate tool calls: direct WebStorage access and WebAuthn passkey handling.
Because each call launches a fresh Chromium in the sandbox, that state is
persisted in the mounted workspace under a per-owner directory
(<workspace>/.synthorg/browser/state/<owner>/) so one agent's session state
is never visible to a different agent. Two files live there:
storage_state.json: a Playwright storage-state snapshot (cookies plus per-origin localStorage). Loaded into the context on every call and re-saved after any navigation, so astorage_setfollowed by a laterstorage_getagainst the same origin observes the write, and an authenticated cookie jar survives across calls.sessionStorageis deliberately per-call: it is session-scoped by definition, and a fresh browser launch is a fresh session.webauthn_credentials.json: the virtual-authenticator credential keystore.
WebStorage. Reads name an explicit key; there is no whole-store dump, so a page's tokens or embedded secrets are never returned wholesale into the model-facing result. Written values are size-capped. Storage operations navigate to the target origin first (WebStorage is per-origin).
WebAuthn. The tool drives a Playwright virtual authenticator: no real
hardware key is involved. webauthn_create_credential generates a discoverable
passkey; the credential is re-seeded into the authenticator at the start of
every subsequent call, so webauthn_list_credentials and
webauthn_delete_credential act on the persisted set, and a
navigator.credentials.get() ceremony triggered by a page during a later
navigation is answered automatically. Passkeys a page registers itself during
browsing are synced back into the keystore.
Secret-handling posture (SEC-1). A virtual credential's private key never reaches the model-facing surface: it is stripped from every tool result and lives only in the workspace keystore file, keyed by credential id, at the same trust level as screenshots and baselines (on disk in the workspace, never in the database, never in LLM context or persisted conversation history). The sandbox re-seeds the authenticator from that host-side keystore by reference, so the key material round-trips host-side only. Per-owner isolation of the state directory is the boundary that keeps one agent's cookies and passkeys out of reach of any other agent.
Git Clone SSRF Prevention¶
The git_clone tool validates clone URLs against SSRF attacks via hostname/IP
validation with async DNS resolution (git_url_validator module). All resolved
IPs must be public; private, loopback, link-local, and reserved addresses are
blocked by default. A configurable hostname_allowlist lets legitimate internal
Git servers bypass the private-IP check.
TOCTOU DNS rebinding mitigation closes the gap between DNS validation and
git clone's own resolution:
- HTTPS URLs: Validated IPs are pinned via
git -c http.curloptResolve=host:port:ip(git >= 2.37.0; sandbox ships git 2.39+), so git uses the same addresses the validator checked. - SSH / SCP-like URLs: A second DNS resolution runs immediately before execution; if the re-resolved IP set is not a subset of the validated set, the clone is blocked.
- Literal IP URLs: Immune (no DNS resolution occurs).
Both mitigations are configurable via GitCloneNetworkPolicy.dns_rebinding_mitigation
(default: enabled). Disable for hosts behind CDNs or geo-DNS where resolved IPs
legitimately vary between queries. For full defence-in-depth, combine with
network-level egress controls (firewall, HTTP CONNECT proxy) or container
network isolation (see Tool Sandboxing above).
MCP Integration¶
External tools are integrated via the Model Context Protocol (MCP).
- SDK: Official
mcpPython SDK, pinned version. A thinMCPBridgeTooladapter layer isolates the rest of the codebase from SDK API changes (Decision Log D17) - Transports: stdio (local/dev) and Streamable HTTP (remote/production). Deprecated SSE is skipped.
- Result mapping: Text blocks concatenate to
content: str; image/audio use placeholders with base64 in metadata;structuredContentmaps tometadata["structured_content"];isErrormaps 1:1 tois_error(Decision Log D18)
SynthOrg MCP Tool Surface¶
SynthOrg exposes its own MCP server offering 225
tools across 22 domain
modules (agents, analytics, approvals, brain, budget, charter, cockpit,
communication, coordination, docs, infrastructure, integrations, knowledge,
memory, meta, organisation, quality, research, security, signals, tasks,
workflows).
Tool definitions are classified
by capability action via the
read_tool / write_tool / admin_tool builders
(src/synthorg/meta/mcp/tool_builder.py); only the admin_tool subset is
destructive and subject to the guardrail triple. Every tool is handled by an
async function in src/synthorg/meta/mcp/handlers/<domain>.py; handlers shim
onto the existing service layer rather than reimplementing business logic.
Handler Protocol. Every handler implements
ToolHandler.__call__(*, app_state, arguments: dict[str, Any], actor: AgentIdentity | None = None) -> str
(see src/synthorg/meta/mcp/handler_protocol.py). The actor argument threads the
calling agent identity through the invoker so destructive-op guardrails can
enforce attribution; handlers that don't care about identity accept it and
ignore it.
Typed args. Each MCP tool registration optionally carries
an args_model: type[BaseModel] (see MCPToolDef.args_model). When set, the
invoker validates the raw arguments dict against the Pydantic model before
dispatching to the handler; validation failures short-circuit to a typed
ArgumentValidationError envelope without ever invoking the handler. Handlers
therefore receive a structurally-validated dict and can either access fields
directly (the model ensures presence + type) or re-validate locally for
typed access (args_model.model_validate(arguments)). Tools without
args_model (legacy / dynamic shapes such as MCPBridgeTool) continue using
the manual common_args validators inside the handler body.
JSON-text arguments. Before validation, tools/_argument_decoding.py
decodes an argument a model sent as the JSON text of its value (a nested
array arriving as '[{...}]', a nullable field arriving as 'null'), because
a model that cannot emit the nested shape resubmits the same text on every
turn and each refusal is right about the type and useless about the fix.
Decoding is bounded to what the schema declares: a parameter whose declared
types do not include string, holding a string that parses to a type they do
include. A string parameter that happens to hold JSON is left alone, and a
string that does not parse is left for the validator to refuse in its own
words.
Envelope Contract. Every handler returns a JSON string. Success envelope (data is always present, pagination appears only on list/collection responses):
{
"status": "ok",
"data": {"example": "payload"},
"pagination": {"total": 100, "offset": 0, "limit": 50}
}
Note: the example shows the MCP-layer
PaginationMeta(defined insynthorg.meta.mcp.handlers.common), which intentionally retains the legacytotal/offsetshape because MCP handlers slice already-materialised sequences. The HTTP API uses a separate cursor-only envelope (synthorg.api.dto.PaginationMetawith{limit, next_cursor, has_more}); see persistence.md.
Handler-caught error envelope (domain_code identifies the error class for programmatic dispatch):
{
"status": "error",
"error_type": "ArgumentValidationError",
"message": "Argument 'approval_id' missing or not a non-blank string",
"domain_code": "invalid_argument"
}
Shared handler infrastructure lives in three sibling modules under
src/synthorg/meta/mcp/handlers/. The split keeps each module focused
on one concern and below the 800-line file ceiling.
common.py: response envelopes, pagination output, guardrails,
placeholder factories:
ok(data, *, pagination=None): success envelope with optionalPaginationMetametadata (frozen Pydantic model,allow_inf_nan=False).err(exc, *, domain_code=None): error envelope;messagealways goes throughsafe_error_description(exc)(SEC-1) anddomain_codefalls back toexc.domain_codewhen present.not_supported(tool_name, reason): stablestatus="error"/domain_code="not_supported"envelope for a wired handler whose selected backend cannot perform the operation (e.g. a memory backend that does not support fine-tuning or checkpoints). Emits theMCP_HANDLER_NOT_IMPLEMENTEDWARNING event so operators can alert on unsupported calls.capability_gap(tool_name, reason): a wired handler whose backing service is absent in this deployment, or whose primitive does not yet expose the required method (aNone-slice gap; see unshipped-surface-inventory.md for the classification). Identical wire envelope tonot_supported(domain_code="not_supported") but emits the dedicatedMCP_HANDLER_CAPABILITY_GAPINFO event so ops telemetry distinguishes it from a backend that simply cannot perform the operation.require_admin_guardrails(arguments, actor): single source of truth for the admin-op precondition triple: non-Noneactor, literalconfirm=True, non-blankreason. RaisesGuardrailViolationErrorwith a typedviolationcode ("missing_actor"/"missing_confirm"/"missing_reason").paginate_sequence(seq, *, offset, limit, total=None): in-memory page slicing that returns(page, PaginationMeta).dump_many(models): batch Pydantic model serialisation to JSON-mode dicts.
common_args.py: argument validators/extractors. Every helper
raises ArgumentValidationError on bad input so handlers can convert
to a stable err(...) envelope without catching framework-specific
exceptions:
require_arg(arguments, key, ty): typed required-argument extraction (ruffEM101-safe).require_non_blank(arguments, key): required non-blank string, whitespace-stripped.get_optional_str(arguments, key): optional non-blank string; returnsNonewhen missing/empty.require_dict(arguments, key, *, value_type=None, deep_copy=True): required dict argument; passvalue_type=strfordict[str, str]validation. Defaults to deep-copying the input to decouple handler mutations from caller payload.parse_time_window(arguments, *, until_required=True): ISO 8601 since/until parsing with timezone-aware enforcement andsince < untilordering.parse_str_sequence(arguments, key): optional sequence-of-non-blank-strings.coerce_pagination(arguments, *, default_limit=50): offset/limit parsing with strict bounds and explicit bool rejection. MCP tools default to 50; this is intentionally lower than the repository-layerDEFAULT_LIST_LIMIT = 100so paginated MCP responses stay terse for assistants.actor_id(actor)/require_actor_id(actor)/actor_label(actor): actor identity helpers. Useactor_idfor optional attribution,require_actor_idwhen attribution is mandatory (raises if unidentifiable),actor_labelonly for emit-only paths where a"mcp-anonymous"fallback is acceptable.
common_logging.py: the three handler-layer log helpers.
Module-scoped logger keyed at synthorg.meta.mcp.handlers so test
assertions see a single stable event source regardless of which domain
handler emitted the event:
log_handler_argument_invalid(tool, exc): caughtArgumentValidationError. EmitsMCP_HANDLER_ARGUMENT_INVALIDat WARNING.log_handler_invoke_failed(tool, exc, **context): genericExceptionfrom the service layer.**contextcarries optional correlation ids (e.g.task_id=,decision_id=); keys that would shadow the canonical event fields (tool_name,error_type,error,event,log_level) are rejected withValueError.log_handler_guardrail_violated(tool, exc): caughtGuardrailViolationErrorfrom an admin-op precondition. Records only the typedviolationcode; the human message stays in the response envelope.
All three route exception messages through safe_error_description
(SEC-1) so secret-shaped fragments are scrubbed before reaching logs.
Domain Codes. Handlers set stable wire codes so callers can dispatch
programmatically: invalid_argument, guardrail_violated, not_supported,
not_found, conflict (e.g. active-checkpoint delete), plus any
domain-specific codes set via the domain_code kwarg on err(...).
Registry Immutability. Each domain handler module exports an
XXX_HANDLERS: Mapping[str, ToolHandler] constant wrapped in
MappingProxyType to enforce read-only access. Each feature manifest
calls mcp_descriptor(..., handlers=<loader>) (the public keyword) to
pair its domain with a deferred zero-arg loader returning that constant;
mcp_descriptor stores it as the internal handlers_factory field on the
descriptor it returns. build_handler_map() in
src/synthorg/meta/mcp/handlers/__init__.py walks discover_features(),
invokes each feature's handlers_factory(), merges the maps, and raises
on a duplicate key across features.
Schema-Level Validation. Admin-op schemas in
src/synthorg/meta/mcp/domains/*.py enforce the reason field as a
non-whitespace string via "minLength": 1 + "pattern": r".*\S.*", and the
confirm field as literal true via JSON Schema "enum": [true]. Handler
guardrails run regardless so validation stays uniform once services come
online.
Self-Extending Toolkit¶
The toolsmith lets the organisation extend its own MCP tool surface at runtime when it hits a recurring capability gap, governed end to end (autonomous detection, LLM authoring, the self-improvement guard chain, benchmark validation, and live registration into a layered tool registry). It has its own design page: Toolsmith (Self-Extending Toolkit). It is disabled by default (meta.self_improvement -> tool_creation_enabled).
Progressive Tool Disclosure¶
When the tool inventory exceeds roughly thirty tools, loading every full definition into the LLM context upfront becomes a major token tax. Progressive disclosure uses a three-level hierarchy inspired by Google ADK's skill loading pattern:
| Level | Contents | When injected | Token cost |
|---|---|---|---|
| L1 metadata | name, one-line description, category, cost tier, parameter names (required, then optional) | Always (system prompt) | ~100 tokens/tool |
| L2 body | full description, JSON Schema, examples, failure modes | On demand via load_tool() |
<5K tokens/tool |
| L3 resource | markdown guides, code samples, example traces | Explicit via load_tool_resource() |
Varies |
Discovery tools (always available regardless of agent access level):
list_tools(): returns L1 metadata for all permitted toolsload_tool(tool_name): returns L2 body; marks tool as loaded inAgentContextload_tool_resource(tool_name, resource_id): returns specific L3 resource
Context injection:
- L1 metadata is injected into the system prompt for all permitted tools. It carries the
parameter names because a tool runs when called by name whether or not its body was
loaded, so the summary has to be enough to call it with: an agent that guessed
write_file's parameters spent three refused calls and aload_toolround-trip before its first write landed. - Full
ToolDefinitionobjects are sent via the provider APItoolsparameter only for loaded tools + discovery tools - L3 resources are never auto-injected; returned inline from
load_tool_resource
Auto-unload: When AgentContext.context_fill_percent exceeds
ToolDisclosureConfig.unload_threshold_percent (default 80%), the oldest-loaded
L2 body is unloaded (FIFO by insertion order). L1 metadata remains.
Configuration (ToolDisclosureConfig):
l1_token_budget(default 3000): max tokens for L1 metadatal2_token_budget(default 15000): max tokens for loaded L2 bodiesauto_unload_on_budget_pressure(defaulttrue)unload_threshold_percent(default 80.0)
Cross-reference: MCP integration above is the external tool integration pattern; progressive disclosure is the local analogue for managing context cost.
Action Type System¶
Action types classify agent actions for use by autonomy presets (see Security & Approval), SecOps validation, and tiered timeout policies (Decision Log D1).
Registry: StrEnum for 47 built-in action types (type safety, autocomplete, typos caught
by static type checking and config-load-time validation) + ActionTypeRegistry for custom
types via explicit registration. Unknown strings are rejected at config load time; a typo
in human_approval list silently meaning "skip approval" is a critical safety concern.
Granularity: Two-level category:action hierarchy. Category shortcuts expand to all
actions in that category (e.g., auto_approve: ["code"] expands to all code:* actions).
Fine-grained overrides are supported (e.g., human_approval: ["code:create"]).
Taxonomy (47 leaf types):
code:read, code:write, code:create, code:delete, code:refactor
test:write, test:run
docs:write
design:generate, design:delete
vcs:read, vcs:commit, vcs:push, vcs:branch
deploy:staging, deploy:production
publish:staging, publish:production
comms:internal, comms:external
budget:spend, budget:exceed
org:hire, org:fire, org:promote, org:delegate
db:query, db:mutate, db:admin
arch:decide
tool:create
memory:read
knowledge:ingest, knowledge:reindex
browser:navigate, browser:screenshot, browser:diff, browser:accessibility_scan, browser:spec
external_data:request
research:run
desktop:launch, desktop:click, desktop:type, desktop:key, desktop:screenshot, desktop:scroll
Classification: Static tool metadata. Each BaseTool declares its action_type. Default
mapping from ToolCategory to action type. Non-tool actions (org:hire, budget:spend) are
triggered by engine-level operations. No LLM in the security classification path.
Tool Access Levels¶
Tool Access Level Configuration
tool_access:
levels:
sandboxed:
description: "No external access. Isolated workspace."
file_system: "workspace_only"
code_execution: "containerized"
network: "none"
git: "local_only"
restricted:
description: "Limited external access with approval."
file_system: "project_directory"
code_execution: "containerized"
network: "allowlist_only"
git: "read_and_branch"
requires_approval: ["deployment", "database_write"]
standard:
description: "Normal development access."
file_system: "project_directory"
code_execution: "containerized"
network: "open"
git: "full"
terminal: "restricted_commands"
elevated:
description: "Full access; granting it requires human approval."
file_system: "full"
code_execution: "containerized"
network: "open"
git: "full"
terminal: "full"
deployment: true
custom:
description: "Per-agent custom configuration."
The ToolPermissionChecker implements two layers of enforcement: category-level gating
(each access level maps to permitted ToolCategory values) and granular sub-constraints
(SubConstraintEnforcer) checking file system scope, network mode, terminal access, git access,
code execution isolation, and approval requirements against each tool invocation. Per-agent
overrides can customise all six dimensions via ToolPermissions.sub_constraints. K8s sandbox
backend integration is on the roadmap.
Per-target action types on the credential-holding families¶
The deploy_* and publish_* families each bind a per-target action type
(deploy:staging / deploy:production, publish:staging /
publish:production) so an autonomy grant for a tamer family never
auto-approves a production release or image push, and their destructive tools
carry the confirm + reason + actor guardrail.
A streamable-HTTP MCP server once exposed a scoped subset of these to an embedded coding harness. It went with the harness: the in-process MCP machinery above is not network-facing, and a network-facing credential-brokering endpoint with nothing calling it is attack surface for nothing.
See Also¶
- Providers: LLM abstraction and routing
- Security & Approval: autonomy tiers, approval gates
- Design Overview: full index