Metadata-Version: 2.5
Name: persona-mcp-workspace
Version: 0.4.0
Summary: Per-job shared workspace (files) exposed as MCP tools for persona agents and the workflow engine
Requires-Python: >=3.10
Requires-Dist: fastmcp>=2.8.0
Requires-Dist: pillow>=12.3.0
Requires-Dist: python-dotenv>=1.0.0
Requires-Dist: pyyaml>=6.0
Description-Content-Type: text/markdown

> # ⚠️ Questo repository è stato migrato
>
> Lo sviluppo continua su GitHub:
> **https://github.com/applica-software-guru/persona-mcp-workspace**
>
> Questa copia su Bitbucket è **congelata** e non riceve più commit,
> release o pipeline. Aggiorna i tuoi remote:
>
> ```bash
> git remote set-url origin git@github.com:applica-software-guru/persona-mcp-workspace.git
> ```
>
> Il pacchetto resta su PyPI (`persona-mcp-workspace`); l'immagine Docker
> è su `ghcr.io/applica-software-guru/persona-mcp-workspace`.

# persona-mcp-workspace

Full-machine access exposed as MCP tools: `read`, `write`, `edit`, `bash`.
This server is meant to be installed on a real machine so an agent can
operate it remotely — the remote equivalent of a local coding agent's own
tools, with no folder confinement. Paths are used exactly as given
(absolute, or resolved against the server process's own working
directory); `bash` runs with no forced cwd, inheriting the server
process's own.

## Tools

| tool | description |
|---|---|
| `read` | read a file — text with offset/limit, or images returned as image content a vision model can see |
| `write` | create or overwrite a file, parents auto-created |
| `edit` | precise text replacement — one or more disjoint edits per call |
| `bash` | run a shell command, no working directory imposed — plain-text output (stdout+stderr merged; a JSON-object output also comes back as structuredContent), non-zero exit or timeout returned as a tool error; pi's exact result contract |
| `skill` | load one skill's full instructions + bundled file paths |

Any of these can be switched off, and their reach narrowed, from a
config file — see [Permissions](#permissions).

Listing, searching, deleting and moving files are **not** separate
tools — `bash` covers them (`ls`, `grep`, `find`, `rm`, `mv`). One fewer
surface to keep in sync with the agent-facing API, and the agent already
knows how to use a shell.

## Images

`read` detects images by magic bytes (never by extension) and returns
them as MCP image content — `[text note, image]` — so a vision-capable
model sees the actual image instead of base64 text. Supported: jpg,
png, gif, webp inline; bmp (and anything Pillow can decode) converted to
png; animated PNGs rejected. Images already within 2000x2000 and 4.5MB
of base64 pass through byte-for-byte; larger ones are resized (EXIF
orientation applied) with a note telling the model how to map displayed
coordinates back to the original. Pass `binary_base64=true` to get the
raw bytes without processing. Limits are env-tunable:
`WORKSPACE_IMAGE_MAX_DIMENSION`, `WORKSPACE_IMAGE_MAX_ENCODED_BYTES`,
`WORKSPACE_MAX_IMAGE_READ_BYTES`.

There is no dedicated node in persona-workflows for these tools: a
workflow mounts this server on an agent step (or calls it) through the
existing generic `tool` node, which is agnostic to whatever tools a given
MCP server exposes — it never needs updating when this server's tool
surface changes.

## Skills

Skills are installed on disk under `.agents/skills/<name>/SKILL.md`
(override the root with `WORKSPACE_SKILLS_DIR`) — the same on-disk Agent
Skills convention used by Claude Code and other harnesses: YAML
frontmatter (`name`, `description`, optional `allowed-tools`) followed by
the skill's instructions body, plus optional `scripts/`, `references/`,
`assets/` subfolders. Drop a new skill folder in and it's picked up
immediately, no restart needed.

The catalog (name, description and SKILL.md location of every installed
skill) is exposed as
the `resource://skills` MCP resource, not a tool — see below for why
and how to wire it into the system prompt. `skill(name)` is the one
remaining tool: it loads one skill's full instructions plus the relative
paths of its bundled files — fetch those with `read`, same reasoning as
above (no separate content-fetch tool).

## MCP resources

| resource | returns |
|---|---|
| `resource://cwd` | this server's working directory |
| `resource://skills` | the skill catalog — one `<skill>` element (name, description, location) per installed skill, wrapped in `<available_skills>` |
| `resource://system` | OS/Python version and which shell `bash` runs (bash vs PowerShell) |
| `resource://system-prompt` | the complete system prompt — the bundled template (or `WORKSPACE_SYSTEM_PROMPT_TEMPLATE`) with `{{skills}}`, `{{cwd}}`, `{{now}}` and `{{system}}` already resolved |

`resource://cwd` reflects `WORKSPACE_CWD` if set at startup (the process
`chdir`s there before doing anything else, so `read`/`write`/`edit`
relative paths and `bash`'s cwd all agree with what's reported);
otherwise it's whatever directory the process was launched from.

### Getting these into the system prompt

**MCP resources are not read automatically by persona-core** — there's
no handshake-time mechanism that pulls them in for you. To wire one in,
configure a resource-backed `Variable` on the agent:

```json
{
  "name": "cwd",
  "source": {
    "type": "resource",
    "mcpServer": "<this feature's registered name>",
    "resourceUri": "resource://cwd"
  }
}
```

(same shape for `resourceUri: "resource://skills"`) and reference
`{{ cwd }}` / `{{ skills }}` inside the agent's `systemInstructions`.
persona-core resolves each fresh on every turn (no caching), so a skill
added after the agent was created still shows up. Without this wiring,
`skill(name)` still works as an explicit tool call — you'd just be
missing the ambient catalog telling the agent what's available to load.

Or skip the plumbing entirely: `resource://system-prompt` returns the
whole prompt ready to use — template bundled with the package (or your
own file via `WORKSPACE_SYSTEM_PROMPT_TEMPLATE`), variables resolved
fresh on every read. One Variable, no placeholders to keep in sync:

```json
{
  "name": "systemPrompt",
  "source": {
    "type": "resource",
    "mcpServer": "<this feature's registered name>",
    "resourceUri": "resource://system-prompt"
  }
}
```

and use it verbatim as the agent's `systemInstructions`.

### `edit` semantics

`edit(path, edits: [{old_string, new_string, replace_all?}])`. Each
`old_string` must match exactly once in the file unless `replace_all` is
set, in which case every occurrence is replaced. Edits in one call are
applied in order against the progressively-edited content. All edits are
validated before anything is written — if one fails (not found, or
ambiguous without `replace_all`), the file is left untouched.

## Permissions

By default nothing is restricted (see "No confinement" below). Pass
`--config` (or set `WORKSPACE_CONFIG`) to point at a YAML or JSON file
and the server narrows its surface along four independent dimensions:

```yaml
tools:                        # which tools get registered at all
  deny: [write, edit]         # a read-only server

paths:                        # globs on what read/write/edit may touch
  allow: ["/workspace/**"]    # applies to both reads and writes
  deny: ["**/.ssh/**", "**/*.pem", "**/.env"]
  write:                      # read:/write: narrow one direction only
    deny: ["/workspace/.agents/skills/**"]

bash:                         # globs on the commands bash may run
  allow: ["git *", "ls *", "cat *", "python3 *"]
  deny: ["git push *"]

skills:                       # globs on skill directory names
  deny: [send-email]
```

Same rules in every section:

- **`deny` always wins** over `allow`.
- **An omitted or empty `allow` list means no restriction** for that
  dimension — a config that only fills in `paths.write` leaves reads,
  commands and skills wide open. The startup log prints one line per
  dimension, saying `unrestricted` where that's the case, so a
  half-filled config is visible rather than assumed.
- **Unknown keys and unknown tool names are startup errors**, as is a
  missing or unparseable config file. A typo in a permission file must
  stop the server, never silently widen what's allowed.

### Ready-made examples

Five profiles live in [`examples/permissions/`](examples/permissions),
each commented for use as a starting point:

| file | what it gives an agent |
|---|---|
| `guardrails.yaml` | full machine access minus the footguns — no `sudo`, no `rm -rf /`, no reading `~/.ssh`, `.env` or `*.pem`. The recommended default |
| `read-only.yaml` | investigation only: `write`/`edit` unregistered, `bash` held to an allow list of observing commands (`ls`, `cat`, `grep`, `find`, `git status/log/diff`, `ps`, `df`…) |
| `workspace-sandbox.yaml` | files confined to `/workspace`, matching how the Docker image runs; skills readable but not rewritable |
| `ci-runner.yaml` | the toolchain (`uv`, `pytest`, `npm`, `make`, `git`) on the checkout, with pushing, publishing and `curl`/`wget` denied |
| `unrestricted.yaml` | nothing narrowed — the explicit form of the default, for deployments that always mount a config file and want the policy off |

`tests/test_permissions_examples.py` checks each of these files against a
table of concrete commands and paths, so the examples can't drift from
what they claim — it's also the quickest way to see which command
patterns cover which real command lines.

### Turning it off

Full access is the default, so the shortest answer is to pass no
`--config` and leave `WORKSPACE_CONFIG` unset. When a deployment always
mounts a config file, either of these gets you back to unrestricted:

```bash
uv run persona-mcp-workspace --config examples/permissions/unrestricted.yaml
WORKSPACE_CONFIG= uv run persona-mcp-workspace    # neutralise an env-set config
```

An empty file, or one holding only comments, is unrestricted too —
remember that an empty `allow` list means "no restriction", not "nothing
permitted". Either way the startup log states what is in force, one line
per dimension; don't take it on trust.

A tool denied under `tools` is **not registered**, so it never appears in
the agent's tool list — no prompt budget spent on a tool that would only
refuse. A skill denied under `skills` is hidden from `resource://skills`
too, so the catalog never advertises something `skill()` won't load
(rules match the skill's **directory name**).

Path rules are matched against the **resolved** path — symlinks followed,
`..` collapsed, relative paths anchored to the server's cwd — so neither
`../../etc/passwd` nor a symlink pointing out of an allowed tree gets
through; deny rules are matched against the unresolved path as well, so
denying a symlink by its own name works too. In the globs, `*` and `?`
stop at `/` while `**` crosses it; a pattern with no wildcard at all
(`/workspace`) covers that path and everything under it; a pattern
starting with a wildcard (`**/.ssh/**`, `*.pem`) is deliberately left
unanchored, meaning "wherever this sits on the machine". `~` is expanded
and relative patterns resolve against the server's cwd, evaluated after
`WORKSPACE_CWD` has been applied.

`bash` rules are checked against **every** command in a line, not just
the first: `ls && rm -rf /` is split on `;`, `&&`, `||`, `|`, `&` and
newlines (quoted operators left alone, `(...)`/`{...}` groups unwrapped)
and each part matched separately. Patterns are matched whole, with `*`
crossing spaces, and `git *` also covers a bare `git`. That means an
allow list has to cover **every** command in a pipeline — with only
`ls *` allowed, `ls -la | grep foo` is refused for the `grep`. While an
allow list is in force, command substitution (`$(...)`, backticks,
`<(...)`) is refused outright, since what it runs cannot be checked
against the patterns.

**Command rules are a guardrail against mistakes, not a sandbox.** An
allowed command can still redirect output anywhere (`cat x > ~/.bashrc`),
read anything it likes, or be a shell of its own (`python3 -c ...`) —
`bash` cannot be made safe by pattern matching. Path rules are enforced
by this server and hold for `read`/`write`/`edit`, but `bash` does not
route its filesystem access through them. If you need real confinement,
that's the OS user or the container the server runs under; the policy
file is for shaping what an otherwise-trusted agent is *meant* to do.

## No confinement, by design

With no config file there is no per-job workspace folder and no path
jail: `read`/`write`/`edit` accept any path this process can reach, and
`bash` has no forced working directory. Running this server that way
means trusting the project's agents with the machine under the server's
user account — the **only** gate is authentication (JWT), not a
filesystem boundary. Install it under a dedicated user with exactly the
permissions you want to grant.

`project_id` (from the verified JWT) and `execution_id`/`workspace_id`
(when the caller sends them in the MCP request `_meta` — persona-core
forwards the `AgentContext` there automatically, persona-workflows sets
`workspace_id` to the root execution id) are logged with every call for
audit/traceability, but they never restrict what a call can touch.

`bash` shells out through each host's native shell: `bash` on POSIX,
Windows PowerShell (`powershell.exe`, built in, no Git Bash/WSL needed)
on Windows. Command syntax has to match the host, so wire
`resource://system` into the agent's system prompt the same way as
`resource://cwd` if it may run against either OS.

### `bash` result contract (pi parity)

The `bash` tool returns the command's output as **plain text**, with
stdout and stderr **merged in arrival order** (like watching a real
terminal), and "(no output)" when empty — not a JSON envelope. A
**non-zero exit or timeout fails the call as a tool error** (`isError`
in MCP terms) whose message is the output plus a status line
(`Command exited with code N` / `Command timed out after N seconds`):
the error channel is a signal models react to reliably, instead of an
`exitCode` field they may forget to check. Output is truncated to the
last 2000 lines / 50KB, whichever hits first — the full output is
persisted to a temp file and a trailing note (`[Showing lines X-Y of Z.
Full output: <path>]`) points at it. These semantics are deliberately
identical to pi's bash tool, so models behave the same against both.

One addition over pi: when a successful command's whole output is a
single **JSON object**, it is also returned as MCP `structuredContent`
(the text content then carries the same object, re-serialized), so a
workflow can read fields without parsing text. Arrays, scalars, invalid
or truncated JSON stay plain text; `bash` declares no output schema.

`WORKSPACE_SHELL_MAX_TIMEOUT` (default 600s) caps per-command timeouts.
`WORKSPACE_SHELL_MAX_LINES` (default 2000) and `WORKSPACE_SHELL_MAX_OUTPUT`
(default 51200 — **bytes**, not characters as before 0.3) cap bash
output; whichever hits first wins.
`WORKSPACE_CWD`, if set, is `chdir`'d into at startup, before anything
else — otherwise the process just keeps whatever cwd it was launched
with. `WORKSPACE_SKILLS_DIR` (default `.agents/skills`) points at the
skills root. `WORKSPACE_SYSTEM_PROMPT_TEMPLATE` overrides the template
behind `resource://system-prompt` (default: the one bundled with the
package); an unreadable override falls back to the bundled template.
`WORKSPACE_CONFIG` is the env-var equivalent of `--config` (the flag
wins); with neither set, no permission policy is applied.

## Running

```bash
uv sync
uv run persona-mcp-workspace --port 9100    # JWT auth on (default)
uv run persona-mcp-workspace --no-auth      # local development

# with a permission policy (see Permissions above)
uv run persona-mcp-workspace --config examples/permissions/read-only.yaml

# or, from any machine with no local checkout:
uvx persona-mcp-workspace --port 9100
```

Register it in persona as a global feature template (type `mcp`,
transport `http`, `enablePersonaAuth: true`) named `workspace`; mount it
on an agent step via its per-step `toolsets`, or invoke it directly with
a generic `tool` node.

### Docker

```bash
docker run -p 9100:9100 \
  -v persona-workspace:/workspace \
  ghcr.io/applica-software-guru/persona-mcp-workspace:latest
```

The image starts the server on `0.0.0.0:9100` with JWT auth on, and
`WORKSPACE_CWD=/workspace` — mount a volume there to keep files across
restarts. Append flags to override (`--no-auth`, `--port`, …) and pass
`-e JWKS_URI=… -e JWT_ISSUER=… -e JWT_AUDIENCE=…` to point the verifier
at your own persona instance instead of the defaults.

To run the container under a policy, mount the file and point at it:

```bash
docker run -p 9100:9100 -v persona-workspace:/workspace \
  -v ./workspace-policy.yaml:/etc/persona/workspace.yaml:ro \
  -e WORKSPACE_CONFIG=/etc/persona/workspace.yaml \
  ghcr.io/applica-software-guru/persona-mcp-workspace:latest
```

The container runs as root and `bash` executes whatever the agent sends,
so it is as unconfined as the rest of this server — the container is the
boundary. Don't mount host paths you aren't willing to hand over, and
don't expose the port publicly with `--no-auth`.

#### The `-playwright` variant

`:<version>-playwright` / `:latest-playwright` is the same server plus
browser automation: Node, [`@playwright/cli`][pwcli] and **Google Chrome**
(the branded stable build, not bundled Chromium — sites that sniff the
browser behave as they do for real users), with the `playwright-cli`
skill already installed, so `skill` lists it and `bash` can drive a
browser out of the box.

```bash
docker run -p 9100:9100 -v persona-workspace:/workspace \
  ghcr.io/applica-software-guru/persona-mcp-workspace:latest-playwright
```

[pwcli]: https://www.npmjs.com/package/@playwright/cli

Because `/workspace` is a volume mount point, anything written there at
build time would vanish under a mount. The initialized workspace is
therefore baked into `/opt/persona/seed` and copied in by the entrypoint
at startup, **never overwriting what is already there** — an existing
volume keeps the agent's own files and its edited skills. Chromium lives
in `/opt/ms-playwright`, outside both the volume and `$HOME`.

The seeded `.playwright/cli.config.json` pins `channel: chrome` and sets
`chromiumSandbox: false`: branded Chrome refuses to start as root, which
is what every process in this container is. Delete the file from the
volume (or edit it) to choose differently.

amd64 only — Google Chrome ships no arm64 Linux build — and ~1.5GB
against the base image's ~344MB.

## Releasing

Push a bare semver tag and GitHub Actions does the rest — tests, then
the PyPI publish and the multi-arch (amd64/arm64) image push to GHCR in
parallel:

```bash
git tag -a 0.2.0 -m 0.2.0 && git push origin 0.2.0
```

The tag name *is* the version: it gets stamped into `pyproject.toml` for
both the wheel and the image, which is published as
`ghcr.io/applica-software-guru/persona-mcp-workspace:0.2.0` and
`:latest`.

No publishing secrets are stored in the repo: PyPI uses trusted
publishing (OIDC, configured on the PyPI project against this repo and
`release.yml`) and GHCR authenticates with the built-in `GITHUB_TOKEN`.
