Skip to main content

pyddock

py + paddock — an enclosed space where code can run freely within bounds. (Worked great in Jurassic Park.)

MCP server that gives AI agents pre-approved run_python, run_shell, run_mcp, and file i/o tools. Safely replaces shell scripting for data processing, file analysis, computation, and external command execution — no user confirmation needed.

Why

Agents write shell snippets for structured tasks. Those snippets require user approval (workflow friction) or blind trust (security risk). Common alternatives each have limitations:

  • Trust specific command strings. Agents produce variations or chain commands into ad-hoc scripts that break naive string matching, causing repeated approval prompts for equivalent operations.
  • Trust all shell execution. Unsafe when the environment has access to production resources.
  • Full containerized sandbox (Docker). Isolation cuts agents off from the dev workspace, host network, and local tooling. Restoring access requires per-project configuration that is brittle to maintain.
  • Larger agent framework with granular permissions. High adoption cost for what should be a drop-in tool.

pyddock provides Python execution and shell command access with declarative policy controls. Policies are safe to auto-approve because side effects are scoped to the configured workspace.

Intended use: Trusted shell replacement for unattended agents in development environments. The full permission policy is reported in the MCP tool description, so agents know what is allowed before writing code. When a snippet does violate a policy, the error includes the specific rule and a recommended workaround. Agents adapt non-interactively rather than blocking on human approval.

Replaces dedicated MCP servers: A full Python environment means agents can call service APIs directly (boto3, p4python, etc.) under the same policy enforcement. This eliminates the need for separate MCP servers for AWS, Atlassian, Perforce, and similar services, and allows agents to compose multiple API calls in a single run_python invocation.

Workspace scripts: Scripts in a configured directory (e.g. .kiro/scripts/) are automatically trusted. Scripts authored for workspace agents require no additional pyddock configuration.

Threat model: Pyddock's guardrails keep trustworthy local agents out of trouble. A determined adversary may discover a jailbreak, or chain with other tools in the workspace for escalation and/or exfiltration. The Python sandbox contains extensive anti-jailbreak protections, but only as a first-line of defense against accidents and casual misuse. Pyddock does not claim to be a security boundary against targeted exploitation or public abuse. In other words, life finds a way.

Setup

{
  "mcpServers": {
    "pyddock": {
      "command": "uvx",
      "args": ["pyddock", "serve", "--workspace", "<path-to-workspace>"],
      "autoApprove": ["run_python","run_shell","run_mcp","fs_readfile","fs_stat","fs_find","fs_grep","fs_append","fs_delete","fs_str_replace"]
    }
  }
}

Tool: run_python

Parameter Type Description
code string Inline Python snippet
file string Path to a .py file (mutually exclusive with code)
args string[] Available as sys.argv[1:]
timeout number Seconds (default: 30)

The last expression in your snippet is captured as RESULT (like a Jupyter cell) when it evaluates to a non-None value. Use print() for streaming output or a trailing expression for structured return values.

Tool: run_shell

Parameter Type Description
command string Executable name or script path (must match a shell policy)
args string[] Arguments passed directly to the command (no shell interpretation)
timeout number Seconds (default: 30)
cwd string Working directory, relative to the workspace root (default: the workspace root)

Commands are executed directly — no shell interpretation, pipes, redirects, chaining, or variable expansion. Only commands matching a [shell.*] policy in your config are permitted. The tool is only registered when at least one [shell.*] section exists in the config.

Script files get automatic interpreter mapping: .ps1 → powershell, .py → python, .sh → bash, .bat → cmd /c. A script or path-like command is resolved relative to the workspace root, never to cwd, so cwd cannot change which file executes.

cwd is subject to the command's arg_paths policy — normally it must be an existing directory inside the workspace and outside protected directories. Relative path arguments are resolved by the command against cwd, and are path-checked against both cwd and the workspace root.

Tool: run_mcp

Parameter Type Description
server string Configured server name. Omit to list servers and their tool policy
tool string Tool name. Omit to list that server's permitted tools with input schemas
arguments object | string Tool arguments (a JSON string is also accepted)
timeout number Seconds (default: 30)

Calls a pre-approved tool on another MCP server. Only registered when at least one [mcp.*] section resolves to a real server definition. The same capability is available inside run_python as pyddock_mcp.call(server, tool, arguments), which is the preferred form for anything beyond a single lookup: a 60KB tool result parsed down to three numbers in a snippet never enters the agent's context at all.

This is one dispatcher, not a gateway — other servers' tools are never registered as individual pyddock tools, so your agent's tool list and the host's tool budget stay untouched. Intended for MCP servers you cannot replace with a library: host-provided tools with no API behind them, org-internal servers, and local dev tooling. For anything with a decent Python SDK, call the API directly instead.

See docs/mcp-tool-access.md for the design and threat model.

Output format

Both run_python and run_shell return human-readable text sections:

--- RESULT ---
42
--- STDOUT ---
Processing 10 files...
Done.
--- STDERR ---
Warning: deprecated API
--- EXIT CODE: 0 ---
  • Sections are omitted when empty (except EXIT CODE which is always present)
  • RESULT only appears in run_python responses, when the last expression is non-None
  • run_shell responses have STDOUT, STDERR, and EXIT CODE only

File Tools

Pyddock also exposes file I/O tools that execute through the same sandbox as run_python — same filesystem scoping, same protected directories, same policy enforcement. These are convenience shortcuts for common file operations; agents can't do anything they couldn't already do via run_python with pathlib. On failure, error messages include diagnostic context to guide the agent's next attempt.

  • fs_readfile — Read a text file. Lines are 1-indexed; negative start = tail. Truncates large output with a continuation hint.
  • fs_stat — File metadata (exists, type, size, line count, modified). Returns exists: false for missing paths.
  • fs_find — Find files by name (file_glob or file_regex, mutually exclusive). Prunes hidden dot-directories and exclude_regex matches before descending (never walked, not just filtered), so large ignored trees (.venv/, node_modules/) stay fast. Returns matching relative paths, capped at max_results.
  • fs_grep — Search file contents by grep_regex (always case-insensitive). Same hidden-directory pruning, exclude_regex, and file_glob/file_regex file filtering as fs_find. Returns path:line: content per match with surrounding context lines (default: 1 for directory scans, 4 for single-file), capped at max_results and 300 chars per line, with an optional max_results_per_file cap. Skips binary files when scanning a directory; always searches a directly named file.
  • fs_append — Append to a file (creates if missing). Returns a unified diff.
  • fs_delete — Delete a file or empty directory. Returns a unified diff of removed content.
  • fs_str_replace — Find-and-replace exact text (unique match required). Returns a unified diff, or match diagnostics on failure.

Security model

run_python enforcement:

  • AST validation (pre-execution) — rejects disallowed imports, blocked calls (eval, exec, compile), and blocked attribute access (__globals__, __subclasses__, etc.)
  • Runtime enforcement (in-subprocess) — import hook, filesystem scoping, getattr/attrgetter guards, factory proxies for library method restrictions, safe sys/os/subprocess module proxies
  • Audit-hook policy engine (in-subprocess) — a sys.addaudithook that enforces the [audit] disposition table at the CPython audit-event layer, beneath the Python name bindings. This is the authoritative backstop for the name-based monkeypatches above: it catches operations that reach a real primitive through a live-object leak (e.g. type(sys.stdout.buffer.raw)(...) → the genuine _io.FileIO) or low-level os.open/os.replace. See Audit policy.

run_shell and subprocess enforcement:

  • Default deny — any command not matching a [shell.*] config section is rejected outright
  • No shell interpretation — commands execute via subprocess.run(shell=False), preventing injection, pipes, redirects, and variable expansion
  • Argument validation — per-command allow/deny regex patterns control which arguments are permitted
  • Argument path scanning — args that look like filesystem paths are validated against protected directories and workspace boundaries (configurable via arg_paths)
  • Write protection — path-like shell command regexes automatically generate write-deny rules for run_python, preventing write-then-execute privilege escalation
  • subprocess proxy — run_python code has access to subprocess.run() and subprocess.Popen(), both validated against the same shell policies; shell=True and string commands are always rejected; os.system() is blocked

Filesystem guards ([filesystem.guards]) provide regex-based path rules that apply to both reads and writes. Each pattern maps to a disposition: "deny-agent" (block agent code; trusted libraries bypass), "deny-all" (block unconditionally), "workspace" (allow only inside the workspace), "read-only" (writes not allowed), or "allow" (permit unconditionally). First match listed in the toml wins. Assorted secrets are blocked by default (~/.ssh/, ~/.aws/credentials, ~/.gnupg/, etc.). .env files are restricted to workspace-only access, while .git/ is read-only.

Writes are restricted to the workspace. The .pyddock/ directory, pyddock source, and the Python stdlib directory are always write-protected (.pyddock/tmp/ is the exception, used by tempfile). Reads are unrestricted by default but subject to filesystem guards.

Audit policy

The [audit] section maps CPython audit events (see the Audit events table) to a disposition. It is enforced by a sys.addaudithook that fires beneath the Python name bindings, so it cannot be bypassed by re-deriving a real primitive from a live object. Each key is an audit event name; a key ending in * is a prefix match (e.g. "ctypes.*"). Dispositions:

  • fs — open(): path-scoped read/write check (by mode/flags).
  • fs-write — single-path mutation (os.remove, os.mkdir, …): check the target.
  • fs-write-pair — two-path mutation (os.rename/replace, os.link, …): check both.
  • agent-deny — deny when the caller is agent code; allow trusted libraries, stdlib, and import machinery. For primitives with no sanctioned agent use (ctypes.*, marshal.loads, code.__new__, sys.settrace, …). Defense-in-depth behind the import allowlist.
  • network — network egress (socket.connect/bind/sendto, urllib.Request): same caller-scoped deny as agent-deny (agents have no socket path; trusted libraries are allowed).
  • observe — log only under serve --debug; no enforcement.
  • allow — explicit no-op (override a default to permit).

[audit] is the single source of truth — events not listed are ignored, and a config that omits the section entirely runs without the audit backstop. The five policy-bearing sections ([execution], [imports], [filesystem], [ast], [audit]) are required: pyddock halts at startup if any is absent (a present-but-empty section is a valid explicit opt-out).

Debug logging: pyddock serve --debug (or pyddock run --debug) records every observed event — filesystem, process, and network — as JSONL to .pyddock/tmp/audit.jsonl, with the allow/deny decision and a caller classification (AGENT / TRUSTED / IMPORT). Useful for auditing what agent code and dependencies actually do. Off by default with no overhead.

Configuration

Configuration is resolved in two steps:

  1. Base config: .pyddock/pyddock.toml in your workspace (full replacement of the bundled default), or default_config.toml if no workspace config exists.
  2. Overlay (optional): .pyddock/pyddock.override.toml is deep-merged on top of the base. Only include sections/keys you want to change — everything else is inherited.

See default_config.toml for the full annotated reference. Key sections:

[execution]
timeout = 30
max_timeout = 3600

[imports]
json = true
csv = true
pathlib = true
# ... (see default_config.toml for full list)
sys = true
os = true
subprocess = true
dateutil = "python-dateutil"
polars = true
boto3 = true

[filesystem]
writable_paths = ["."]   # "." = workspace directory; "*" = unrestricted
readable_paths = ["*"]

[filesystem.guards]      # regex → disposition (first match wins)
"/\\.ssh/" = "deny-agent"
"/\\.aws/credentials$" = "deny-agent"
"/\\.npmrc$" = "deny-all"
"/\\.env(\\.[^/]*)?$" = "workspace"

[ast]
block_calls = ["eval", "exec", "compile", "breakpoint", "__import__"]
block_attributes = ["__subclasses__", "__globals__", "__code__", "__bases__", "__mro__", "__closure__"]

[audit]                  # audit event → disposition (see "Audit policy" below)
"open" = "fs"
"os.rename" = "fs-write-pair"
"ctypes.*" = "agent-deny"
"marshal.loads" = "agent-deny"
"socket.connect" = "network"
"subprocess.Popen" = "observe"

[restrictions.polars]
mode = "allow"
module_deny = ["write_.*", "sink_.*"]
class_deny = ["write_.*", "sink_.*"]

[restrictions.boto3]
mode = "deny"
module_allow = ["client"]
class_allow = ["list_.*", "describe_.*", "get_.*", "head_.*", "scan", "query", "start_query", "stop_query"]

[shell.p4]
mode = "deny"
allow = ["filelog.*", "files.*", "describe.*", "changes.*", "print.*", "where.*", "info"]

[shell.git]
mode = "deny"
allow = ["status.*", "log.*", "diff.*", "show.*", "branch.*", "rev-parse.*"]

[shell.kiro-scripts]
command = "\\.kiro/scripts/.*"
mode = "allow"
deny = []
arg_paths = "protected"

Import values

Modules are configured in [imports] by setting the import name (what you'd write after import) to a value:

[imports]
json = true
pathlib = true
dateutil = "python-dateutil"                          # pip name differs from import name
invoice_parser = ".kiro/scripts/invoice-parser"       # import invoice_parser
metrics_client = ".kiro/scripts/reporting/metrics-client"  # import metrics_client
  • true — allowed module (import name == pip package name)
  • false — revoked module
  • "<pip-name>" — allowed module where the PyPI package name differs from the import name (e.g. dateutil = "python-dateutil")
  • "<path>" — workspace package installed via pip install -e at boot (string containing / or \, or starting with .)

Each workspace package must contain a pyproject.toml. Dependencies declared in pyproject.toml are resolved automatically during installation — transitive deps are available to the package at runtime (including lazy/deferred imports), but not directly importable by agent code unless explicitly listed.

Source code changes in workspace packages are picked up immediately (editable install). Metadata changes (e.g. adding new dependencies in pyproject.toml) require a server restart.

Workspace module directories are automatically write-protected to prevent code modification from within run_python.

Import overrides

Some modules are unsafe as-is but useful with a narrower surface. An import override replaces a real module with a shim file, so agent code sees only the shim's API. The override is enabled per name, and the bundled sqlite3 override is enabled in the shipped default:

[import_overrides]
sqlite3 = true

Enabling a name also allows it in [imports], and removes any [restrictions.<name>] for it (with a warning). The shim file is looked up in this order, and the first hit wins:

  1. <workspace>/.pyddock/import-overrides/<name>.py — a workspace override. The sandbox subprocess reads it during apply_all() and exec's it once the import hook and filesystem scoping are active, so it runs as untrusted code (its own imports are checked). .pyddock/ is always write-protected, so agent code cannot create or replace one at run time.
  2. ~/.pyddock/import-overrides/<name>.py — your own file, exec'd the same untrusted way.
  3. The bundled import_overrides/<name>.py that ships with pyddock.

Package-form overrides (<name>/__init__.py) and dotted submodule names are rejected. While any override is enabled, importlib and pkgutil must be false, because they can reach the real module behind the shim. An override never falls back to the real module: if the file is missing or fails to load, the run fails closed.

Bundled sqlite3 shim. The shipped default enables it, so a run with no user config gets the read-only shim. sqlite3.connect from agent code:

  • opens only plain filesystem paths inside readable_paths, through a read-only file:...?mode=ro URI;
  • reads WAL-mode databases, including rows still committed to the -wal file; SQLite may create -wal/-shm sidecars beside the database, which the shim accepts as logically non-destructive (the committed database is unchanged);
  • denies writes, DDL, temp objects, ATTACH, VACUUM INTO and PRAGMA writes, through an authorizer and an attach limit of zero;
  • exposes no load_extension, backup, serialize, set_authorizer or row_factory, and no sqlite3.dbapi2 submodule.

Limits. The shim guards against ordinary misuse. It is not a hard boundary against code that deliberately uses reflection. Reflection can reach the real sqlite3 module, and a real connection obtained that way can attach and create files, including outside the workspace. The same is true without the override, through the real _sqlite3 module. Because the override ships enabled, a default run gets this read-only surface. To use the real module instead — and, in particular, to ATTACH and populate a separate database, which the shim does not expose — disable the override with sqlite3 = false under [import_overrides] and add sqlite3 = true under [imports]. That alone still does not let agent code write the database it opens: the shipped [audit] rule "sqlite3.connect" = "sqlite-connect" pins every agent-code connect to a read-only file:...?mode=ro URI, so writing that database additionally requires relaxing the rule (for example, setting the sqlite3.connect disposition to allow in your own [audit] table — see Audit policy).

Configuration override

A minimal .pyddock/pyddock.override.toml example — only include what you want to change:

# .pyddock/pyddock.override.toml — only include what you want to change
[imports]
requests = true    # add a module
boto3 = false      # revoke a module

[execution]
timeout = 120      # override default 30s

[shell.npm]
command = "^npm$"
mode = "deny"
allow = ["run build.*", "test.*"]

Merge semantics:

  • Scalars and lists (timeout, writable_paths, block_calls): overlay value replaces base value.
  • Tables (imports, restrictions, shell): merge by key — new keys are added, existing keys are overwritten, unmentioned keys are inherited.

Restrictions

Two modes for per-library restrictions:

  • deny (with module_allow and class_allow): blocks all module-level access except functions matching module_allow patterns. Wraps allowed callables with a proxy so objects they return are restricted to methods matching class_allow patterns. Use for client-producing libraries (boto3, botocore).
  • allow (with module_deny and class_deny): blocks specific module-level functions matching module_deny patterns and methods on classes matching class_deny patterns. Use for libraries where most operations are safe (polars, pandas).

Shell Policies

Each [shell.<name>] section defines a command execution policy:

  • command: regex pattern matched against the command string (defaults to ^<name>$ if omitted)
  • mode: "deny" (block all args except those matching allow) or "allow" (permit all args except those matching deny)
  • allow: list of regex patterns for permitted argument strings (used with deny mode)
  • deny: list of regex patterns for blocked argument strings (used with allow mode)
  • arg_paths: controls path scanning for arguments that look like filesystem paths:
    • "workspace" (default) — blocks args resolving outside the workspace or into protected dirs (.pyddock/, workspace modules, script dirs)
    • "protected" — only blocks args targeting protected dirs; allows paths outside the workspace
    • "none" — no path scanning (fully trusts the command)

Args are space-joined before matching: ["filelog", "//depot/..."] becomes "filelog //depot/...". All regexes use re.match() (implicitly anchored at start).

When subprocess is enabled in [imports], run_python code can call subprocess.run([...]) with the same policy enforcement and interpreter mapping. This is useful for composing multiple commands, processing output between calls, or passing environment variables dynamically.

MCP Policies

JSON says what can be launched; TOML says what may be called. Nothing in a JSON file ever grants anything.

What may be called comes only from [mcp.<name>] sections in your config:

[mcp.github]
description = "Read-only GitHub queries. Tools include get_issue, get_pull_request, list_commits."
tools = { "get_.*" = true, "list_.*" = true, "search_.*" = true }
  • tools: pattern → bool, full-match anchored. Deny always wins (every false is checked before any true), and an unmatched tool is denied. Keep these read-only-shaped: nothing structurally prevents this policy from exceeding what your host permits.
  • description: surfaced in the tool description. Descriptions are policy-only and never fetched from the server, so this is where you tell the agent what the patterns cover — saving it a discovery call, and keeping third-party tool descriptions out of your agent's always-present context.

Presence of the section is the only switch. Omitting [mcp] entirely disables the feature and run_mcp is not registered.

What gets launched comes from JSON definition files, scanned in this order (later wins): ~/.kiro/settings/mcp.json, ~/.cursor/mcp.json, <workspace>/.kiro/settings/mcp.json, .vscode/mcp.json, .cursor/mcp.json, .mcp.json, .pyddock/mcp.json.

Only command, args, and env are read. Host settings such as disabled and autoApprove are ignored — pyddock does not inherit another agent's trust decisions. A server disabled in your host config is still reachable via run_mcp if a [mcp.*] section enables it, which is deliberate: disabling a chatty MCP for direct agent use while keeping it available from Python is the point.

A definition file that agent code could write is refused with a warning, because it would let a snippet redirect an already-permitted server. This is checked against your actual filesystem policy, so widening writable_paths deactivates a location on its own. .pyddock/mcp.json always qualifies; .kiro/settings and .claude/settings qualify via their default read-only guards. Add a read-only guard to load definitions from anywhere else.

Servers are launched inside the sandbox as descendants of the snippet process, and the spawn allowlist is derived from (TOML-enabled servers ∩ scanned definitions) — never written by hand as [shell.*] sections. Commands are resolved to an absolute path at load time, so the directory the executable lives in becomes write-protected, same as a path-like shell policy. An MCP server is otherwise an ordinary unconstrained process, and npx -y @foo/server fetches and executes registry code, so pinned versions in definitions are worth having.

Current limits: stdio servers only (no url), tool-name gating only (no argument-level policy), no ${VAR} interpolation in definition env, and the snippet timeout is the outer bound. Connections are request-scoped — one invocation, then torn down.

CLI

pyddock serve --workspace /path/to/project   # MCP server (stdio)
pyddock serve --workspace /path/to/project --debug   # + JSONL audit trail to .pyddock/tmp/audit.jsonl
pyddock run "2 + 2"                          # Direct execution
pyddock run script.py -- arg1 arg2           # File execution with args

Development

uv sync
uv run pytest

Metadata

Release files for pyddock 0.4.3

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pyddock 0.4.3
File Size Uploaded Provenance Actions
pyddock-0.4.3.tar.gz 13.9 MB Without provenance Details for pyddock-0.4.3.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for pyddock 0.4.3
File Interpreter ABI Platform Actions
pyddock-0.4.3-py3-none-any.whl Python 3 none any Details for pyddock-0.4.3-py3-none-any.whl

Total release size: 14.0 MB

Release files / pyddock-0.4.3.tar.gz

Download URL pyddock-0.4.3.tar.gz
Size 13.9 MB
Tags Source
SHA-256 checksum
How to use checksums
2a9744a2db3c64dc11890174c960189ee711cfe82eb6dc550c1768719df7e7a5
BLAKE2b-256 checksum
How to use checksums
107784855fbb3da5e0acf6513871b468329a57319bd0e5cbf5eb6b606e8511b5
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.3

Release files / pyddock-0.4.3-py3-none-any.whl

Download URL pyddock-0.4.3-py3-none-any.whl
Size 166.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
e637ab17768bb65b59fdb83ee6798fd5b8a20c2e81a1a9e3594b0a49bd0b647a
BLAKE2b-256 checksum
How to use checksums
194907e6cef19de6b4c303cc214215b17ba18ab1bb67524c35cf0d01e5a910f6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.3

Security for pyddock 0.4.3

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page