Skip to main content
Glama
JawadAhbab

glm-workflow-mcp

by JawadAhbab
README.md
# glm-workflow-mcp

A zero-dependency MCP server that delegates real, autonomous coding work to Z.ai's GLM models.

`glm_agent` is not a prompt wrapper. It runs a genuine agent loop: GLM reads files, searches the tree, edits, writes, runs your test suite, reads the failures, fixes them, and re-runs — for 3–15 minutes — then hands back a structured report whose **file list and exit codes are computed from git and from real processes, not from the model's claims**.

It exists so that a Claude Code session can fan work out: dispatch a dozen scoped implementation tasks to GLM, keep orchestrating while they run, and collect verified diffs.

- **No runtime dependencies.** Node 20.19+/22.12+/24+ built-ins only (`node:*` and global `fetch`).
- **No build step.** Plain ESM, published as source.
- **macOS and Linux only.** WSL works. The shell tool spawns `/bin/bash` directly and kills by POSIX process group, so `package.json` declares `"os": ["darwin", "linux"]` and npm refuses the install on Windows with `EBADPLATFORM` rather than letting every `bash` call fail at run time.
- **stdio transport.** One MCP client spawns one process, so the concurrency semaphore, circuit breaker and spend quota are genuinely process-wide across every caller sharing that client.

---

## Contents

- [Install](#install)
- [The five tools](#the-five-tools)
- [Worked example: parallel fan-out](#worked-example-parallel-fan-out)
- [Configuration](#configuration)
- [The safety model](#the-safety-model) ← **read this before enabling bash**
- [How a run works](#how-a-run-works)
- [The return envelope](#the-return-envelope)
- [Cost and rate-limit control](#cost-and-rate-limit-control)
- [Verified against GLM-5.2](#verified-against-glm-52)
- [Troubleshooting](#troubleshooting)
- [Known limitations](#known-limitations)

---

## Install

You need a Z.ai API key from <https://z.ai>. Note **which product the key is billed against** — a coding-plan subscription and pay-as-you-go credit use different base URLs and are not interchangeable. The default here is the coding-plan endpoint; see [Endpoint and model](#endpoint-and-model) and the [`1113`](#troubleshooting) entry in troubleshooting.

### `.mcp.json`

Drop this in your project root:

```json
{
  "mcpServers": {
    "glm": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "glm-workflow-mcp@0.1.1", "--root", "${PWD}"],
      "timeout": 1800000
    }
  }
}
```

Three things about that snippet are load-bearing:

1. **Pin the version.** A bare `npx -y glm-workflow-mcp` silently upgrades a package that writes files and runs shell commands, on every single launch. Pin it and bump deliberately.
2. **`timeout` must exceed `GLM_MAX_RUNTIME_SECONDS`** (default 720 s). `1800000` ms gives plenty of head-room. The server emits MCP progress notifications on every agent iteration, which is what stops Claude Code's idle timer firing during a 12-minute run.
3. **`--root` is mandatory and there is no working-directory fallback.** `npx` inherits the MCP client's cwd, which is frequently `$HOME`. Refusing to guess is the point.

There is deliberately **no `env:` block** in that snippet. Supply the key out-of-band instead — see [Provide your key](#provide-your-key) immediately below. The common `"env": { "GLM_API_KEY": "${GLM_API_KEY}" }` line is a trap: when `GLM_API_KEY` is not set in the MCP client's *own* environment, Claude Code substitutes nothing and hands the server the **literal string** `${GLM_API_KEY}`. That literal is not a key — the server ignores it (it does not shadow a valid credentials file), but it does not supply one either, so with `env:` as your only channel you get a degraded server and a `.mcp.json` that is silently wrong. Only add that `env:` line if you have actually exported `GLM_API_KEY` in the shell that launches your client.

### Provide your key

You need a Z.ai API key from <https://z.ai> (see the note above about which billing product it is for). Pick **one** of these two methods — the server reads them in this order, and a value that is not a credential (empty, an unexpanded `${VAR}`, or shorter than 16 characters) is treated as absent and does not shadow the next source.

**Method A — the credentials file (preferred, and required if you enable bash).** A key passed through the environment stays readable in `/proc/<pid>/environ` on Linux; a `0600` file does not. This is why [Key custody](#key-custody) recommends it and why the `.mcp.json` above has no `env:` block.

```bash
mkdir -p ~/.config/glm-workflow-mcp
printf '{"apiKey":"YOUR_KEY"}\n' > ~/.config/glm-workflow-mcp/credentials.json
chmod 600 ~/.config/glm-workflow-mcp/credentials.json
```

The file must contain exactly `{"apiKey":"..."}` and must be mode **`0600`** (owner read/write only) — the server **refuses** to read it at any looser mode rather than use a group- or world-readable key file, and names `chmod 600` on stderr if it finds one. Its default location is `$XDG_CONFIG_HOME/glm-workflow-mcp/credentials.json`, falling back to `~/.config/glm-workflow-mcp/credentials.json`; override it with `GLM_CREDENTIALS_FILE` or `--credentials-file <path>`. With the file in place, leave the `env:` block out of `.mcp.json` entirely.

**Method B — an environment variable.** Export the key in the shell that launches your MCP client, *before* the client starts:

```bash
export GLM_API_KEY=YOUR_KEY   # ZAI_API_KEY / ZHIPU_API_KEY are also accepted
```

Only if you use Method B does the `"env": { "GLM_API_KEY": "${GLM_API_KEY}" }` line belong in `.mcp.json` — and it works solely because `GLM_API_KEY` is now set in the client's own environment for `${GLM_API_KEY}` to expand against. The key is deleted from `process.env` immediately after boot reads it and is held in memory only, but on Linux with bash enabled it remains recoverable from `/proc/<pid>/environ` (Method A avoids that).

Either way, restart your MCP client after supplying the key. Then jump to [Verify it works](#verify-it-works). If you skip this step, the server still starts — degraded:

### If the key is missing

**The server does not exit when no key can be found. It starts in a degraded mode instead**, exit code 0, and writes a full diagnosis to stderr — every source it checked, in order, with the resolved path and what each one said, then the two ways to fix it:

```
glm-workflow-mcp: no usable GLM API key was found. Sources checked, in order:

  1. environment variable GLM_API_KEY   -> the literal string "${GLM_API_KEY}" — your MCP client never expanded the variable, so this is not a key and is ignored
  2. environment variable ZAI_API_KEY   -> not set
  3. environment variable ZHIPU_API_KEY -> not set
  4. credentials file                    /Users/you/.config/glm-workflow-mcp/credentials.json
      -> no such file
  ...
  (a) the credentials file ...   (b) export the variable ...

glm-workflow-mcp: starting anyway — glm_health reports this, the initialize instructions
glm-workflow-mcp: carry it, and every model-calling tool will refuse with this reason
glm-workflow-mcp: until a key is configured.
```

That is deliberate: `initialize`, `tools/list` and `glm_health` need no key, and a server that exits at boot appears to the client as "failed to start" with the reason buried in a log nobody opens. The practical consequence for you is that **your MCP client will show the server as connected and healthy while it is not usable.** `glm_health` reports `api_key_present: false` and `ok: false`; `glm_agent` and `glm_ask` return `isError: true` with a message naming the credentials file. The full diagnosis above is also prepended to the **first tool result** and carried in the server's connect-time `instructions`, so you see it without hunting for the stderr log. Check `glm_health` first, not the process list — and see [Where are the logs?](#troubleshooting) for where your client keeps that stderr.

### Global install

```bash
npm i -g glm-workflow-mcp
glm-workflow-mcp --version
```

Then point `command` at `glm-workflow-mcp` instead of `npx` in the config above. Running `glm-workflow-mcp --root /path/to/project` by hand does **not** give you a REPL — see below.

### Verify it works

This is a stdio JSON-RPC server, not a CLI. Launched from an interactive shell it waits on a stdin that never reaches EOF, prints a few lines to stderr, and appears to hang. There is nothing to type at it. Two checks that do terminate:

```bash
# 1. the binary resolves and runs at all (prints the version to stderr, exits 0)
npx glm-workflow-mcp@0.1.1 --version

# 2. it speaks MCP — pipe one request in; closing stdin exits the process
printf '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{}}}\n' \
  | GLM_API_KEY=... npx glm-workflow-mcp@0.1.1 --root "$PWD"
```

The second prints a single JSON-RPC line on stdout containing `"serverInfo":{"name":"glm-workflow-mcp",…}` and exits 0. It does not call the API, so it proves wiring, not credentials — and because of the degraded-start behaviour above it succeeds even with a bad key.

To verify the key and the endpoint, ask Claude Code — **against the server Claude Code spawned from your `.mcp.json`**, which is a different process from anything you start in a terminal:

```
call glm_health with probe: true
```

Look for `"probe": { "ok": true, … }`. A failing probe prints the error `kind` (`auth_error`, `quota_exhausted`, `api_contract_error`) — start from [Troubleshooting](#troubleshooting).

---

## The five tools

| Tool | Blocking | What it is for |
|---|---|---|
| `glm_agent` | yes, 3–15 min | One autonomous coding task, start to verified finish. |
| `glm_agent_start` | no, <1 s | Same parameters; returns a `run_id` immediately. Use for fan-out. |
| `glm_agent_status` | optional | Poll a run, or block on it with `wait_seconds`. Returns the full envelope once finished. |
| `glm_ask` | yes, 5–40 s | One question, one answer. No tools, no filesystem. |
| `glm_health` | yes, <1 s | Config, caps, bash confinement posture, semaphore, breaker, quota, live runs, recent errors. |

### `glm_agent`

| Parameter | Type | Default | Notes |
|---|---|---|---|
| `task` | string, 20–20000 | — | **Required.** Self-contained brief: goal, constraints, acceptance criterion, how to verify. No questions — nobody answers them. |
| `cwd` | string | server root | Absolute path; must resolve inside the root. |
| `context` | string, ≤20000 | — | Facts you already know: file paths, framework, conventions, a prior run's blockers. Strongly recommended; it directly buys back iterations. |
| `max_iterations` | int 4–80 | server's configured value (30 by default) | **A request for *less*, never more** — see [Per-run budgets](#per-run-budgets). 10–15 for a small localised change, 40–60 for a multi-file feature. |
| `max_runtime_seconds` | int 60–1500 | server's configured value (720 by default) | Wall clock. Also clamped down to the operator's ceiling. |
| `model` | enum | `glm-5.2` | `glm-5.2`, `glm-5.1`, `glm-5-turbo`, `glm-5`, `glm-4.7`, `glm-4.6`, `glm-4.5`, `glm-4.5-air`. `glm-5-turbo` / `glm-4.5-air` for trivial mechanical work. |
| `allow_bash` | bool | server default | `false` means it cannot run tests, and will report `partial`. |
| `isolation` | `none` \| `worktree` | `none` | `worktree` runs in a throwaway `git worktree` so several agents can share one repo. |

### `glm_agent_status`

| Parameter | Type | Default | Notes |
|---|---|---|---|
| `run_id` | string | — | Omit to list every run in this session. |
| `wait_seconds` | int 0–300 | 0 | Block for up to this long instead of busy-polling. |
| `include_log` | bool | false | Last 200 log lines from the run transcript. |
| `log_grep` | string | — | Filter those lines, e.g. `"edit_file"` or `"ERROR"`. |

### `glm_ask`

| Parameter | Type | Default |
|---|---|---|
| `prompt` | string, 1–400000 | **required** |
| `system` | string, ≤8000 | — |
| `model` | enum | `glm-5.2` |
| `reasoning_effort` | `none`\|`low`\|`high`\|`max` | `high` (`low` is sent as `high`; the API has three real behaviours) |
| `temperature` | number 0–1 | 0.3 |
| `max_tokens` | int 64–32000 | 4096 — the schema accepts the whole range, and anything below 4096 is raised to 4096 on the wire, see [below](#endpoint-and-model) |
| `json` | bool | false |

`glm_ask` cannot see your repository. Paste in whatever it needs. The whole call is abandoned after 300 s, including time spent waiting for a model-call slot or the circuit breaker.

### `glm_health`

`probe: true` additionally makes one real minimal request to confirm the key and endpoint work end to end (30 s deadline). Call it before a large fan-out to check quota, and after an unexplained failure.

Read `bash_confinement` if you care about the security posture:

- `cwd_confined` is always `false` — the shell is not confined to the root;
- `path_deny_backend` names the OS mechanism actually protecting the credential paths: `sandbox-exec` on macOS, `none` where there is no backend;
- `protected_paths` is the count of paths that deny covers.

`state_dir` is deliberately *not* reported, because a tool result is readable by anything that can influence the transcript; it is in the server's boot log instead.

---

## Worked example: parallel fan-out

The pattern this server was built for. Suppose you are adding six REST endpoints to a WordPress plugin and you want them written in parallel.

**1 — Check you have headroom.**

```
glm_health {}
```

Look at `scheduler.quota.remaining` and `scheduler.runs`. Each agent iteration costs one call from the hourly bucket, so a six-way fan-out at 30 iterations is up to 180 calls out of the 600/hour default.

**2 — Dispatch all six without waiting.**

Because they all touch one repository, use `isolation: "worktree"` so they do not serialise behind the per-directory lock:

```
glm_agent_start {
  "task": "Add a REST route `GET /wp-json/acme/v1/settings` in inc/class-rest-settings.php returning the plugin options as JSON. Register it on `rest_api_init`. Follow the existing controller pattern in inc/class-rest-products.php exactly: same namespace constant, same permission_callback using current_user_can('manage_options'), same schema shape. Acceptance: `composer exec phpunit -- --filter Settings` passes, and `php -l` is clean on every file you touch.",
  "context": "PHP 8.2, WordPress 6.6. Autoloading is PSR-4 via composer.json under the Acme\\ namespace. Tests live in tests/ and use WP_UnitTestCase. inc/class-rest-products.php is the canonical example of a controller in this codebase.",
  "isolation": "worktree",
  "max_iterations": 35
}
```

…and five more like it. Each returns in under a second:

```
GLM agent dispatched: run 20260722T143108Z-a3f9c1d2
running. Poll with glm_agent_status.
```

**3 — Do other work, then collect.** Poll with a long wait rather than a busy loop — one call per run, each blocking up to two minutes:

```
glm_agent_status { "run_id": "20260722T143108Z-a3f9c1d2", "wait_seconds": 120 }
```

**4 — Triage on facts, not prose.** For each returned envelope:

- `status: "success"` → read `verification`; every entry has a real `exit_code` (or `null`, which means the harness could not match the model's claimed command to a command it actually ran — treat that as unverified).
- `status: "partial"` → read `blockers` first. A blocker is usually answerable ("could not find the test command"), so resolve it and re-dispatch with the answer in `context`.
- `status: "failed"` → read `blockers` and `stats.recovered_errors`.
- `retryable: true` → the failure was environmental (quota, queue, provider tool-call parser); re-dispatch as-is later.

Never trust `summary` over `files_changed`. `files_changed` comes from `git diff --numstat`; `summary` is model prose. If the model claimed a file that git says is unchanged, it has already been dropped and counted in `stats.recovered_errors.hallucinated_files`.

**5 — Apply the worktree patches.** Each run's `diff_path` points at a real patch file:

```bash
git apply ~/.glm-workflow-mcp/runs/20260722T143108Z-a3f9c1d2/diff.patch
```

**6 — Debug a bad run.**

```
glm_agent_status { "run_id": "...", "include_log": true, "log_grep": "ERROR" }
```

### Writing a good task brief

The single biggest quality lever. The agent has no human to ask, so an ambiguity becomes an assumption.

- **Do** state the acceptance criterion as a command: *"Acceptance: `npx vitest run src/cart` passes."*
- **Do** name the file to imitate: *"follow the pattern in `src/lib/session.ts`."*
- **Do** put known paths, the framework and the conventions in `context`. Every fact you supply is an iteration it does not spend rediscovering.
- **Don't** ask questions. Don't say "let me know if…". Nobody is listening.
- **Don't** bundle three unrelated changes. Dispatch three runs.
- **Don't** ask for anything that needs a long-lived background process. Nothing survives a single `bash` call — see [Shell](#shell-a-deliberate-judgment-call).

---

## Configuration

CLI flags beat environment variables. Everything is read once at boot into a frozen config. Two exceptions read `process.env` directly at the moment they are used, and are therefore environment-only with no flag: `GLM_KEEP_WORKTREE` and `GLM_TOOLCHAIN_ENV`.

### Required

| Variable | Flag | Notes |
|---|---|---|
| `GLM_API_KEY` | — | Or `ZAI_API_KEY` / `ZHIPU_API_KEY`, or the credentials file below. Deleted from `process.env` immediately after boot reads it, along with `ANTHROPIC_API_KEY` and `OPENAI_API_KEY`. Held in memory only. Rejected if it is a literal unexpanded `${VAR}`, or shorter than 16 characters — and a rejection starts the server degraded rather than exiting. |
| `GLM_ROOT` | `--root <path>` | The directory the server's **file tools** are confined to. `realpath`'d at boot; must be a directory. **No cwd fallback.** Note this does not confine `bash`. |

| Variable | Flag | Default |
|---|---|---|
| `GLM_CREDENTIALS_FILE` | `--credentials-file <path>` | `$XDG_CONFIG_HOME/glm-workflow-mcp/credentials.json`, else `~/.config/glm-workflow-mcp/credentials.json`. Must be mode `0600`; contains `{"apiKey":"..."}`. See [Key custody](#key-custody). |

### Endpoint and model

| Variable | Flag | Default |
|---|---|---|
| `GLM_BASE_URL` | `--base-url` | `https://api.z.ai/api/coding/paas/v4/chat/completions` (`ZAI_BASE_URL` is accepted as an alias) |
| `GLM_MODEL` | `--model` | `glm-5.2` |
| `GLM_TEMPERATURE` | — | `0.3` (clamped to 0–1; this API's ceiling is 1.0, not 2.0) |
| `GLM_REASONING_EFFORT` | — | `max` (`none\|low\|high\|max`; only sent for `glm-5.2`) |
| `GLM_MAX_TOKENS_PER_TURN` | — | `8192` (clamped 256–65536, then floored at 4096 and capped at the model's own output ceiling) |

**Get the base URL right before anything else.** Z.ai serves two OpenAI-shaped chat-completions paths and they are billed against different products:

| Base URL | Billed against |
|---|---|
| `https://api.z.ai/api/coding/paas/v4/chat/completions` | a **Z.ai coding-plan subscription** — **the default here** |
| `https://api.z.ai/api/paas/v4/chat/completions` | **pay-as-you-go** API credit |

A key issued for one returns error `1113` on the other, for *every* model — and it arrives with HTTP `200`, so anything that only checks the status code reads it as success. The default is the coding-plan path because that is what a Z.ai coding-plan key needs; set `GLM_BASE_URL` to the `paas` path if you are on pay-as-you-go credit instead. Boot logs a `warn` to stderr when the configured URL is the pay-as-you-go one, so the mismatch is visible before the first failed run. See [Troubleshooting](#troubleshooting).

You may give either form, with or without the `/chat/completions` suffix — a bare base such as `https://api.z.ai/api/coding/paas/v4` (the form the docs and the Z.ai tooling use) gets the suffix appended rather than being POSTed to as-is.

Boot also rejects any URL containing `/api/openai/` — that path answers HTTP `200` with `{"code":500,"msg":"404 NOT_FOUND"}`, which looks like success to naive clients and is a genuinely nasty misconfiguration to debug.

The model list is hardcoded, because the agent must validate a model name before it spends a call and this API has no models endpoint. An unknown name fails at boot naming the eight valid ones. There are no `-flash`/`-flashx` variants of the 4.7 or 5.x lines.

`GLM_MAX_TOKENS_PER_TURN` deserves a word: GLM-5.2 is a reasoning model, and reasoning tokens are spent from the same budget before a single output token appears. A small cap does not give you a short answer, it gives you `finish_reason: "length"` with `content: ""` and no tool call at all — verified: `max_tokens: 20` produced 15 reasoning tokens and nothing else. Because `max_tokens` is a *cap* rather than an allocation, a floor costs nothing when the turn is short, so **the client raises any request below 4096 to 4096 before sending**, then caps it at the model's own output ceiling. This applies to `glm_ask`'s `max_tokens` too: its schema accepts the documented 64–32000 range (rejecting a value the docs call valid would cost the caller a turn for nothing), and the raise happens on the wire. Setting either to 256 therefore does not actually get you 256.

### Shell access

| Variable | Flag | Default |
|---|---|---|
| `GLM_BASH` | `--bash off\|restricted\|unrestricted`, `--no-bash`, `--allow-bash`, `--bash-unrestricted` | `restricted` |
| `GLM_BASH_ALLOWLIST` | `--bash-allow <csv>` | see below — **replaces**, does not extend |
| `GLM_BASH_TIMEOUT_MS` | — | `120000` (clamped 1000–600000). A per-call `timeout_ms` may lower it; 600000 is the hard cap. |
| `GLM_TOOLCHAIN_ENV` | — | unset. `1` passes 26 toolchain **locator** variables through the HOME scrub — see [Shell](#shell-a-deliberate-judgment-call). Never any credential config. |

### State

| Variable | Flag | Default |
|---|---|---|
| `GLM_STATE_DIR` | `--state-dir` | `~/.glm-workflow-mcp` |
| `GLM_RUN_TTL_SECONDS` | — | `7200` (clamped 60–86400) |
| `GLM_KEEP_WORKTREE` | — | unset. Any non-empty value keeps a run's throwaway worktree on disk for debugging. |

Run directories, diffs and transcripts live under `$GLM_STATE_DIR/runs/<run_id>/` — **never inside your repository**. That keeps `git status` clean, keeps diff attribution honest, and stops the inner agent from reading its own transcript.

**`isolation:"worktree"` checkouts do *not* live in the state directory.** They are created with `mkdtemp` under `os.tmpdir()` — `/var/folders/…/glm-worktree-XXXXXX/w` on macOS, `/tmp/glm-worktree-XXXXXX/w` on Linux — and removed with `git worktree remove --force` when the run ends. That move is what allows the *entire* state directory to be denied to the agent; see [Protected paths](#protected-paths).

The server refuses to boot if `$GLM_STATE_DIR` contains `--root`, because the whole state directory is denied and nothing in the workspace would then be readable.

### Per-run budgets

Every one of these is **clamped, not rejected**: a value outside the range is silently pulled to the nearest bound, so `GLM_MAX_READ_BYTES=104857600` gives you 64 MiB and `GLM_MAX_COST_USD=5000` gives you $1000. Check `glm_health` → `caps` for what the server actually settled on.

| Variable | Default | Clamped to |
|---|---|---|
| `GLM_MAX_ITERATIONS` | `30` | 4–80 |
| `GLM_MAX_RUNTIME_SECONDS` | `720` | 60–1500 |
| `GLM_TOKEN_BUDGET` | `400000` | 10000–1000000 |
| `GLM_MAX_COST_USD` | `2.00` | 0.01–1000 |
| `GLM_MAX_READ_BYTES` | `1048576` | 4096–67108864 (64 MiB) |
| `GLM_MAX_WRITE_BYTES` | `1048576` | 4096–67108864 (64 MiB) |
| `GLM_MAX_FILES_WRITTEN` | `200` | 1–10000 |
| `GLM_MAX_BASH_CALLS` | `100` | 1–10000 |

**`GLM_MAX_ITERATIONS` and `GLM_MAX_RUNTIME_SECONDS` are ceilings, not defaults.** They also have flags (`--max-iterations <n>`, `--max-runtime <seconds>`). The effective budget for a run is:

```
effective = min(tool argument, operator's configured value)
```

so a caller can only ask for **less** than the operator allowed. If you set `--max-iterations 10`, every run on this server gets at most 10 iterations no matter what `glm_agent` was called with. The clamp is silent but visible after the fact: the effective value is echoed in every envelope's `stats.max_iterations`, and `glm_health` reports the ceilings as `caps.max_iterations` / `caps.max_runtime_seconds`. The tool schema separately rejects arguments outside 4–80 and 60–1500 as invalid, and boot clamps the operator's own value into the same ranges.

This matters because the section below calls the hourly quota the only thing standing between a runaway fan-out and a large bill. It is not quite the only thing — these two ceilings and `GLM_MAX_COST_USD` bound a single run — but the quota is the only *server-wide* one.

### Server-wide concurrency and quota

Clamped the same way.

| Variable | Default | Clamped to | Notes |
|---|---|---|---|
| `GLM_MAX_CONCURRENT_MODEL_CALLS` | `5` | 1–8 | Starting value only; AIMD then moves it between 1 and 8 from observed rate limits. |
| `GLM_MAX_CONCURRENT_RUNS` | `8` | 1–64 | Beyond this, runs queue (10-minute cap → `queue_timeout`, `retryable: true`). |
| `GLM_MAX_CALLS_PER_HOUR` | `600` | 1–100000 | Leaky bucket across the whole server, refilling continuously. |

Not configurable: at most **6 `bash` children run concurrently** across the whole server, whatever the run concurrency is. Further calls queue.

### Misc

| Variable | Flag | Default |
|---|---|---|
| `GLM_LOG_LEVEL` | `--log-level` | `info` (`error\|warn\|info\|debug`) — all to **stderr** |
| `GLM_ALLOW_SECRET_FILES` | `--allow-secret-files` | unset (blocked) |

---

## The safety model

Be precise about what this does and does not give you. Short version: **the file tools have a real boundary; the shell does not.** If you need one for the shell, run the server in a container or a VM.

### Key custody

At boot, before anything else can observe the environment, the server takes the key and then deletes `GLM_API_KEY`, `ZAI_API_KEY`, `ZHIPU_API_KEY`, `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` from `process.env`. It is held in memory only.

Child shells get an allowlisted environment built from scratch — `process.env` is never spread into it. Only these pass through: `PATH`, `LANG`, `LC_ALL`, `LC_CTYPE`, `TZ`, `TERM`, `TMPDIR`, `USER`, `LOGNAME`, `SHELL`, `COLUMNS`, `LINES`. Any variable whose *name* matches `KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|_AUTH|SESSION|COOKIE` is dropped, and a kill list removes the loader- and shell-hijacking variables (`BASH_ENV`, `ENV`, `SHELLOPTS`, `BASHOPTS`, `LD_PRELOAD`, `LD_LIBRARY_PATH`, `DYLD_INSERT_LIBRARIES`, `DYLD_LIBRARY_PATH`, `NODE_OPTIONS`, `PYTHONSTARTUP`, `PYTHONPATH`, `PERL5OPT`, `RUBYOPT`, `GIT_SSH_COMMAND`, `GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM`). `BASH_ENV` in particular was verified to re-source rc files under `bash -c` and hand a scrubbed key straight back.

`HOME` and every `XDG_*` variable are then repointed at a scratch directory — see [The scratch HOME](#the-scratch-home) — and `GIT_CONFIG_GLOBAL` / `GIT_CONFIG_SYSTEM` are pinned to `/dev/null`, after the merge, so no caller can put the real ones back.

**Environment scrubbing is incomplete on Linux when bash is enabled.** `delete process.env.X` does not rewrite `/proc/<pid>/environ`, so a key handed in through `.mcp.json`'s `env:` block stays recoverable by any command the agent runs. There is a tripwire that refuses commands mentioning `/proc/*/environ`, but a tripwire on a shell string is not a boundary.

**If you enable bash, prefer the credentials file over the environment:**

```bash
mkdir -p ~/.config/glm-workflow-mcp
printf '{"apiKey":"YOUR_KEY"}' > ~/.config/glm-workflow-mcp/credentials.json
chmod 600 ~/.config/glm-workflow-mcp/credentials.json
```

The server refuses to read that file at any mode looser than `0600`. With it in place you can omit the `env:` block from `.mcp.json` entirely.

Its location is `$XDG_CONFIG_HOME/glm-workflow-mcp/credentials.json`, falling back to `~/.config/glm-workflow-mcp/credentials.json`; override with `GLM_CREDENTIALS_FILE` or `--credentials-file <path>`. It lives **outside** `$GLM_STATE_DIR` on purpose, and both it and its directory are on the deny list below.

`$GLM_STATE_DIR/config.json` is a **legacy** key location and is not supported for new installs. A key found there on boot is copied into the credentials file, stripped from the old file (which is deleted if nothing else was in it), and a note is written to stderr. If the migration fails the server says so loudly and tells you to run with `--bash off` until you move it yourself. Do not put a key there.

### Protected paths

Before the key is even loaded, the server registers a deny list that no tool — file tool *or* shell — may read, write **or list**:

- the credentials file and its directory
- **the entire `$GLM_STATE_DIR`** — transcripts, logs, manifests, diffs, and the legacy `config.json`
- the operator's own credential stores: `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.kube`, `~/.docker`, `~/.config/gh`, `~/.npmrc`, `~/.netrc`, `~/.git-credentials`, `~/.pypirc`, `~/.config/glm-workflow-mcp`

Each entry is registered both lexically and by `realpath`, so a symlinked parent is not a free bypass. On a typical macOS install that comes to 17 registered paths; `glm_health` → `bash_confinement.protected_paths` reports the live count.

The whole state directory can be denied because `isolation:"worktree"` checkouts moved out of it and into `os.tmpdir()`. Earlier versions had to leave `$GLM_STATE_DIR/worktrees/` readable — it *was* the agent's working directory — which put a `../..` traversal path from the agent's own cwd to the key file. **That carve-out no longer exists.** If you are reading an older description of this project that says the worktree subtree is deliberately unprotected, it is describing code that is gone.

This list is checked **before** `GLM_ALLOW_SECRET_FILES`. That flag exists so a run can edit the project's own `.env`; it must never unlock the key that pays for the run.

Enforcement differs by tool, and the difference is the whole point:

- **File tools** (`read_file`, `write_file`, `edit_file`, `list_dir`, `search`): an ordinary path check, applied to reads, writes, directory listings and filename-mode searches alike. `list_dir` does not descend into a protected directory and reports what it withheld as a count with no names (`[3 entries withheld: credentials paths are not listed]`); `search` with `mode:"files"` applies the same filter its content mode always did, and refuses a `path` argument that points at a credentials path rather than returning a misleading "no matches".
- **`bash`**: string tripwires everywhere, **plus a kernel-level deny only where the platform provides a backend — which today means macOS only.** On macOS the shell child is wrapped in `sandbox-exec` with a profile that denies `file-read*`/`file-write*` on those paths (and, per-command, every *other* run's scratch home). **On Linux there is no path-deny backend at all**, so the credentials file and the operator's credential stores are guarded by string tripwires only — a substring match over the command text, which `cut`, `rev`, `xxd`, a `python -c`, or a hundred other splitters defeat. **That is not a boundary.** On Linux, treat `bash` as able to read this server's own key file and your `~/.ssh`, `~/.aws`, `~/.npmrc` and the rest: if that is unacceptable for your repository, run with `--bash off` or run the server in a container. `glm_health` reports `path_deny_backend` — the honest answer, which is `none` on Linux.

Three honest caveats about the kernel deny, even where it exists:

- **`sandbox-exec` is deprecated by Apple.** The `sandbox-exec(1)` command has carried a deprecation warning for years; it still works on current macOS and is the only user-space path-deny mechanism Apple exposes, but it is not a contract Apple has promised to keep. If a future macOS removes it, this backend becomes `none` — the same posture as Linux — and the deny falls back to tripwires only.
- **It fails open.** The wrapper is probed once at boot with `/usr/bin/true`. If that probe stops succeeding, commands run unwrapped rather than every command breaking. That is reported as `path_deny_backend: "none"` and nothing else fails loudly. The smoke test asserts the backend is `sandbox-exec` on darwin precisely so a regression here cannot pass silently.
- **It covers those paths only.** It is **not** general containment, and `bash` is **not** a sandbox. The first-word allowlist and the tripwires are footgun-catchers; a permitted command (`cat`, `node`, `python`, `find`, all allowlisted) can read anything the user running the server can read. The kernel deny narrows that to *everything except* the credentials file, the operator's credential stores, and other runs' scratch homes. Everything else on the machine your account can read, `bash` can read. For real containment, use `--bash off` or a container.

### Filesystem confinement — file tools only

This applies to the agent's five **file** tools (`read_file`, `write_file`, `edit_file`, `list_dir`, `search`). It does **not** apply to `bash`; see [Shell](#shell-a-deliberate-judgment-call) below, and do not conflate the two.

The boundary is the **run's working directory**, not the server root: for `isolation:"none"` that is the run's `cwd` (itself validated to resolve inside `--root` before the run starts), and for `isolation:"worktree"` it is the throwaway checkout under `os.tmpdir()`. Every path the model supplies must be **relative** and must resolve inside it. Absolute paths are rejected outright — that kills a whole bug class rather than trying to adjudicate it.

- Containment is tested with `path.relative`, never `startsWith`. The usual `startsWith(root + sep)` fix for CVE-2025-53110 breaks on a root with a trailing slash; `path.relative` handles both, and correctly denies the sibling-prefix escape (`/repo` must not contain `/repo_secrets`).
- The root is `realpath`'d at boot. Without that, a root of `/tmp` on macOS (which is really `/private/tmp`) denies every legitimate operation underneath it.
- NUL bytes are rejected **before** any path arithmetic, because `path.resolve('/a\0b')` happily succeeds and carries the NUL through to a syscall that truncates at it.
- `~` and `~/…` are refused rather than expanded.
- For a path that does not exist yet, the *nearest existing ancestor* is `realpath`'d and re-checked. Validating the merely lexical dirname here was CVE-2025-53109.
- All I/O opens with `O_NOFOLLOW`, and reads `fstat` the already-open descriptor rather than `stat`-ing the path and then opening it. `realpath` covers intermediate directory symlinks; `O_NOFOLLOW` covers the final component. Both are needed.
- Non-regular files (fifos, devices, directories) are rejected.

### Credential files

Refused by the **file tools** for read, write and listing, regardless of containment, unless `GLM_ALLOW_SECRET_FILES=1`:

```
.env, .env.*, .envrc, .envrc.*, .npmrc, .netrc, _netrc, .git-credentials,
.pypirc, .htpasswd, .pgpass, .my.cnf
id_rsa, id_dsa, id_ecdsa, id_ed25519, id_*, *.pem, *.key, *.p12, *.pfx,
*.keystore, *.jks, *.tfvars, *.tfvars.json
credentials, credentials.json, service-account*.json, wp-config.php
anything under .ssh/, .aws/, .gnupg/, .docker/, .kube/, .config/gh/
```

Those directory names are matched against the path *relative to the root*, not the absolute path — a checkout that happens to live under a directory called `.docker` used to block its own entire tree with a misleading message.

This is what stops the *file tools* reading the `.env` you sourced the key from. It is a filename policy, not a boundary: `bash` does not consult it, and only the [protected paths](#protected-paths) above are denied to the shell.

### The scratch HOME

Every child process — the agent's `bash` calls and the harness's own `git` invocations — runs with `HOME` and `XDG_*` pointed at a throwaway scratch directory, mode `0700`, never the operator's real home. Without that, `~/.ssh/id_ed25519`, `~/.npmrc` and `~/.aws/credentials` are one shell expansion away, and `~` is expanded by bash itself so no tripwire ever sees the real path.

There are **three kinds** of scratch home, and the split is itself a control:

1. **One per run.** Each run gets its own home, **torn down when the run ends**.
2. **One server-private "harness" home**, used by the harness's own `git` subprocesses and by nothing the agent influences. This is why the git-derived change report is trustworthy: an agent cannot plant a `.gitconfig` where the reporting `git` will read it.
3. A per-call, deliberately **non-existent** path if directory creation itself fails. An unset HOME is survivable; a shared or leaked one is not.

This directory is **writable by the run that owns it** — it is a real HOME, agent-writable for the life of the run, not "an empty scratch dir" after the first command. A single shared scratch HOME was a genuine privilege escalation: run A plants `$HOME/.gitconfig` with `diff.external`, run B — even one dispatched with `allow_bash:false`, which has no shell at all — executes it during change reporting. `GIT_CONFIG_GLOBAL` / `GIT_CONFIG_SYSTEM` are additionally pinned to `/dev/null` so "which gitconfig does this git read" does not depend on HOME at all.

**Distinct is not isolated — and three things, not distinctness, are what make these a boundary.** Every scratch home lives inside one shared parent directory, and:

1. **The parent is mode `0300` (`-wx`, deliberately not `r`).** Nothing — including the very uid that owns it — can *list* it: `ls`, a glob, `find` and `readdir()` all fail with `EACCES`, because POSIX enforces the owner-class bits against the owner. Traversal *into* a home whose exact name you already hold still works, which is all a child needs. This is the only layer present on **every** platform, and it is what kills enumeration.
2. **The names are unguessable** — 32 hex characters of CSPRNG output, not a run-id-derived tag. With enumeration blocked by (1), a run cannot name another run's home at all.
3. **A per-command kernel deny, where the platform has one.** On macOS each `bash` call's `sandbox-exec` profile denies the whole scratch parent, then re-allows only the calling run's *own* home (and the server's protected paths, which win last). Verified: under that profile another run's home is unreadable and unwritable, and the caller's own home is fully usable.

Honest limits: running the server **as root** defeats layer (1), because root ignores the permission bits. And on a platform with **no kernel backend — Linux today** — layers (1) and (2) are the whole story, so a sibling process that learns a home path by some *other* channel (Linux `/proc`, which this README already documents as unclosable) is not stopped by a permission bit it does not need. If that matters, run in a container.

Lifetime, stated plainly: a run's home is **torn down at the end of that run**. Two backstops sit behind that primary teardown: any home left behind is reaped once it has been idle for 45 minutes (comfortably above the 1500 s hard runtime ceiling, so a live run's home can never be collected), the live set is capped, and everything — run homes, the harness home and the parent — is removed on server exit. What the parent's `0300` mode and the unguessable names buy is a containment boundary; the teardown is disk hygiene on top of it.

**The cost is real and you should expect it.** Every run starts with cold npm/pip/cargo caches, and toolchains that resolve their *installation* through `$HOME` — rustup, pyenv, nvm, sdkman, volta, rbenv — will not find themselves. That can make a verification command fail for a reason that has nothing to do with the agent's change, which would poison the one part of the envelope that is supposed to be trustworthy. Two mitigations:

- When a command exits non-zero with an error characteristic of a scrubbed HOME, the harness appends a plain-language note to its stderr naming the scratch directory and telling the agent this is a harness limitation to report, not a defect to edit the project around.
- `GLM_TOOLCHAIN_ENV=1` passes 26 toolchain **locator** variables through: `CARGO_HOME`, `RUSTUP_HOME`, `GOPATH`, `GOROOT`, `GOCACHE`, `GOMODCACHE`, `NVM_DIR`, `VOLTA_HOME`, `PNPM_HOME`, `BUN_INSTALL`, `DENO_DIR`, `PYENV_ROOT`, `PIPX_HOME`, `VIRTUAL_ENV`, `CONDA_PREFIX`, `RBENV_ROOT`, `GEM_HOME`, `GEM_PATH`, `JAVA_HOME`, `GRADLE_USER_HOME`, `M2_HOME`, `MAVEN_HOME`, `SDKMAN_DIR`, `ANDROID_HOME`, `ANDROID_SDK_ROOT`, `DOTNET_ROOT`. Each value is validated first: it must be absolute, must not be the real home, must not be a protected path, and must contain no `.ssh` / `.aws` / `.gnupg` / `.docker` / `.kube` / `.config/gh` segment or secret basename — checked structurally, so `GLM_ALLOW_SECRET_FILES=1` cannot reach this decision.

**No credential-bearing config is ever passed through, opt-in or not.** `NPM_CONFIG_USERCONFIG`, `COMPOSER_HOME` and friends are exactly what the HOME scrub exists to hide. If you need a private registry inside a run, put the token in the project's own configuration where the task can legitimately see it, or run the server in a container with the credentials you are willing to expose.

### Shell: a deliberate judgment call

**`restricted` is the default — bash is ON.** This is a considered deviation from "secure by default", and you should know why before you accept it.

**`bash` is not confined to the root.** It is a real `/bin/bash` running as the user account that started the server, so it can read and write anything that user can, inside the root or anywhere else on the machine. The root confinement and the credential-filename policy above are properties of the *file* tools; they say nothing about the shell. The only hard guarantees for the shell are the scrubbed environment with a per-run scratch `HOME`, and the [protected paths](#protected-paths) — kernel-enforced only where the platform provides a backend.

The product contract is *delegate real coding work and verify it*. With bash off, the agent cannot run your tests, so every run comes back `partial / unverified` and the tool is close to worthless. The risk is bounded instead by defence in depth:

- an `argv[0]` allowlist (restricted mode)
- a pattern tripwire list that applies in every mode
- a scrubbed, allowlisted environment with `HOME`/`XDG_*` at a per-run scratch directory and git's global/system config pinned to `/dev/null`
- a kernel path-deny on the credential stores where the platform offers one
- `/bin/bash --noprofile --norc` — never a login shell
- no stdin, so nothing can hang on an interactive prompt
- **process-group kill on every exit path**, not only on timeout (see below)
- output caps (a stream is killed after 512 KiB; what you see is the first 8 KiB and last 16 KiB with the middle elided), a per-run command budget, and at most 6 concurrent shell children server-wide
- every command logged to stderr before it runs

Set `GLM_BASH=off` (or per-call `allow_bash:false`) if you want none of it, or `GLM_BASH=unrestricted` if you have containment elsewhere. **If a repository contains secrets that must not reach a model, that decision belongs to whoever starts the server** — `--bash off`, or run the whole thing in a container.

#### No process survives a bash call

The child is spawned `detached: true` and its entire process group is `SIGKILL`ed the moment the call returns — on a clean exit 0 just as much as on a timeout or an abort. `&`, `nohup`, `disown` and double-forking do not defeat this: a double-forked grandchild stays in the process group and is reaped with everything else. Verified: `(python3 -m http.server 8731 &)` in one call, then a fetch 700 ms later in the next call, gets connection refused.

This is deliberate — the alternative leaked orphaned servers past the end of the run and past server shutdown — but silent reaping would make an agent burn its whole iteration budget debugging a server the harness killed. So when a command exits cleanly and the harness finds its process group still populated, the result carries `leftBehind: true` and an explicit note in `stderr` saying the processes were killed and that a server needed for a test must be started, used and stopped inside a single command. The system prompt and the `bash` tool description say the same thing in the same words.

**The command policy is not a security boundary, and this README will not pretend otherwise.** Any denylist applied to a shell string can be defeated — `find -delete`, `python -c`, a base64 heredoc, a hundred other ways. The `argv[0]` allowlist is a *footgun catcher*: it stops the model casually running `curl | sh` or `rm -rf /`, not a determined adversary. If you need real containment, **run the whole MCP server in a container or VM.** That is the boundary.

Three specific non-claims:

- **This is not filesystem isolation.** The allowlist gates the *first word* of each segment; it does not gate what that program then reads. `cat`, `find`, `node` and `python` are all on the allowlist.
- **This is not network isolation.** `curl`, `wget`, `nc`, `ssh`, `scp` and friends are merely *off the allowlist*, which only gates the first word of a segment — `$(curl …)`, `xargs curl` and `find -exec curl` all still run. And `npm`, `pip`, `composer`, `go` and `git` reach the network legitimately and are all on the allowlist. Treat the agent as having network access.
- **The tripwire denylist applies in every mode, including `unrestricted`** (`rm -rf /`, fork bombs, `sudo`, `doas`, `mkfs`, `dd of=/dev/…`, recursive `chown`/`chmod 777`, pipe-to-shell, `crontab`, `launchctl`, `systemctl`, `shutdown`/`reboot`, `/proc/*/environ`, `~/.ssh` and `$HOME/.ssh`-style expansions, `/etc/`, shell `history`, force-push, …). It catches accidents. It does not stop attacks.

One more honest note: a segment whose first word cannot be identified — a long chain of leading `VAR=value` assignments, or stacked `env`/`nice`/`command` wrappers — is **refused**, not waved through. That path used to fail open.

Restricted mode checks the **first word of each command**. Only that word has to be on the allowlist; the shell grammar around it is not restricted. Redirections (`2>&1`, `>f`, `>>f`, `&>f`, `<f`), heredocs (`<<EOF`, `<<<`), the shell keywords (`if/then/else/fi`, `for/while/until/do/done`, `case/esac`), grouping (`{ …; }`, `( … )`), negation (`!`), the separators `&&` / `||` / `|` / `;`, a trailing `&`, and leading `VAR=value` prefixes all work — but the allowlist still applies to every command *inside* them, so `for f in *.js; do curl "$f"; done` is refused on `curl`. A segment whose first word cannot be identified (a long chain of `VAR=value` or `env`/`nice` wrappers) and unbalanced quotes/parens/braces are **refused**, not waved through.

Allowlisted first words:

```
node npm npx pnpm yarn bun deno tsc vitest jest mocha eslint prettier
php composer wp phpunit pest
python python3 pip pip3 pytest ruff mypy black
go cargo rustc rustup make cmake gradle mvn dotnet ruby bundle rake
git ls cat head tail wc sort uniq cut tr sed awk grep egrep rg find fd
mkdir cp mv touch ln diff patch stat file basename dirname realpath
echo printf pwd true false test [ which type env date sleep tee xargs cd
tar unzip gzip gunzip jq yq
read set unset export shift exit wait seq :
```

The last line is shell builtins the loop/condition forms need (e.g. `while read line; do …`); `eval`, `source` and `.` are deliberately **not** on the list, because they execute text the first-word check never sees. The `bash` tool description shows the model this same set, generated from the same constant `checkPolicy` enforces, so the tool description and the enforcement cannot drift — this README block is a hand-maintained copy of that constant, so trust `glm_health` → `bash_allowlist` if you need the live value.

### Redaction

Every string leaving the process — toward the model, toward Claude, or into the run log — passes through a redactor: the literal API key, `sk-…` tokens, the BigModel `<id>.<secret>` form, `Bearer …` headers, GitHub PATs (`ghp_`, `github_pat_`) and AWS access-key ids.

**Redaction is telemetry hygiene, not a control.** It is trivially defeated by `cut`, `rev`, `xxd` or any other splitter, which is exactly why the credential paths get a kernel deny rather than relying on this.

### Process hygiene

Children spawn `detached: true` and are killed with `process.kill(-pid, …)` — the whole process group, `SIGTERM` then `SIGKILL` after 2 s when a kill is initiated by timeout/abort/output-limit, and an immediate unconditional group `SIGKILL` when the call returns. This matters: `child.kill()` and `spawn`'s own `timeout`/`killSignal` both leak orphans when the command backgrounded anything. On shutdown every live child is group-killed, every run aborted, and every scratch HOME removed.

---

## How a run works

1. **Confine.** Resolve `cwd`, check it resolves inside the root, then take a lock **before** competing for a global run slot (the other order starved runs aimed at unrelated directories).
   - `isolation:"none"` takes an exclusive lock on `cwd` for the whole run. The lock matches by path containment in both directions, so a run in `/repo` blocks one in `/repo/src` and vice versa.
   - `isolation:"worktree"` takes a short per-repository lock named `<repoRoot>#glm-worktree-add`, held only while `git worktree add` runs, then released. That key is a *sibling* of the repository in the lock's path space, so it never contends with a real working directory — a worktree run does not queue behind a plain run in the same repo, which is the whole reason the option exists. Two worktree creations in one repo still serialise against each other, which is what `.git/worktrees` requires.
2. **Snapshot.** Record `HEAD` + `git status` (or, outside a repo, a hashed file manifest, capped at 20000 files).
3. **Loop**, up to `max_iterations`: stream a turn from GLM, execute *every* tool call in the turn, in array order, feed the results back. GLM-5.2 does emit parallel tool calls — two `read_file`s in one `tool_calls` array is routine — and the API requires exactly one `role:"tool"` reply per `tool_call_id`, so a harness that handled only the first would drop work and then desync the transcript. Progress notifications fire every iteration.
4. **Manage context.** Over ~120K estimated tokens, stale large tool results have their *bodies* replaced in place — the message, its role and its `tool_call_id` all stay, because deleting a tool message orphans the preceding `tool_calls` entry and earns a deterministic 400 that no retry fixes. Over ~200K, the agent writes itself a handoff note and the transcript is rebuilt around it (max 2 compactions).
5. **Converge.** At 75% and 90% of the iteration budget a warning is appended to the last tool result. On exhaustion the tool list is narrowed to `[finish]` and the model is told to wrap up, with a fresh 90 s budget. If that fails, the harness synthesises the report from the diff and the command log and marks it `degraded`.
6. **Verify from facts.** Compute the diff from git, pair each claimed verification command with its real exit code, drop hallucinated files, apply the downgrade rules.

The agent gets seven tools — `read_file`, `write_file`, `edit_file`, `list_dir`, `search`, `bash`, `finish` (six with bash off). Every schema is flat: no nested objects anywhere, because escaping depth is what breaks GLM's argument serialisation.

An identical tool call with identical arguments is not re-executed: the second identical call returns the cached result with a note, and a third is refused.

### Auto-downgrades

The harness does not take `status: "success"` at face value:

1. `success` + empty `verification` + files changed → **`partial`**, with a follow-up saying so.
2. `success` + any verification command exited non-zero → **`partial`**.
3. Claimed files that git reports unchanged → dropped, counted in `stats.recovered_errors.hallucinated_files`.
4. `success` + git reports zero changes on a task that did write work → **`failed`**.
5. Any `exit_reason` other than `model_called_finish` adds a blocker, and downgrades `success` to `partial`.

Rule 1 exists specifically to mitigate GLM-5.2's disclosed tendency to over-report success.

### Malformed tool arguments

GLM-5.2 truncating or mangling tool-call arguments is treated as a certainty, not a risk. Arguments go through a repair ladder numbered 0–9 — rung 0 an already-parsed object, rung 1 the string as-is, rungs 2–8 the repairs (trim, strip fences, slice to the outermost object, close unbalanced brackets and string literals, escape raw newlines, undo double-encoding, strip trailing commas), each tried both cumulatively and individually. Nothing throws.

Two rungs are **lossy**: slicing to the outermost object cuts at the last `}` in the text — for a code payload that brace is usually inside a string value — and closing unbalanced literals then tidily terminates the string that was cut. The result parses, validates, and is short. That is silent source corruption reported as a successful edit, so those two rungs are **refused outright for `write_file`, `edit_file` and `finish`**: a truncated write comes back as a failure telling the model to resend smaller. When a lossy rung is used anywhere else it is counted in `stats.recovered_errors.lossy_args`.

If the arguments are unrecoverable the model gets a concrete instruction back — *split the payload, do not resend it* — and only the broken call in the batch fails; the rest execute.

A last-resort schema-aware salvage (rung 9) applies **only** to `read_file`, `search` and `list_dir`, where a single plausible token can be read as a `path` or a `pattern`. It never applies to `write_file`, `edit_file`, `bash` or `finish`, because guessing a `content` argument corrupts a file.

Six consecutive unparseable calls ends the run with `tool_call_serialization_failure` and `retryable: true` — at that point the provider's tool-call parser is broken, not the model, and you should fail over.

---

## The return envelope

One text block: a human header, then a fenced JSON object.

```
GLM agent: PARTIAL (23/30 iterations, 6m12s, $0.42)
7 files changed (+284/-51). Verification: 1 passed, 1 failed.
Full diff: ~/.glm-workflow-mcp/runs/20260722T143108Z-a3f9c1d2/diff.patch
```

```json
{
  "status": "partial",
  "exit_reason": "model_called_finish",
  "run_id": "20260722T143108Z-a3f9c1d2",
  "degraded": false,
  "retryable": false,
  "task": "…first 400 chars…",
  "cwd": "/Users/you/project",
  "isolation": "none",
  "summary": "…model prose, redacted, ≤4000 chars…",
  "files_changed": [
    { "path": "inc/class-rest-settings.php", "action": "created", "added": 148, "removed": 0, "note": "new REST controller" }
  ],
  "diff_path": "…",
  "diff_preview": "…≤2000 chars…",
  "verification": [
    { "command": "composer exec phpunit", "exit_code": 1, "outcome": "11 passed, 1 failed: settings_update_rejects_invalid_nonce" }
  ],
  "follow_ups": ["…"],
  "blockers": ["…"],
  "stats": {
    "iterations_used": 23, "max_iterations": 30, "duration_ms": 372410,
    "prompt_tokens": 486210, "completion_tokens": 31844, "cost_usd": 0.42,
    "model": "glm-5.2",
    "tool_calls": { "read_file": 14, "search": 8, "edit_file": 6, "bash": 9 },
    "recovered_errors": {
      "malformed_args": 2, "repaired_at_rung": [5,5], "lossy_args": 1,
      "tool_failures": 3, "api_retries": 1, "nudges": 0, "hallucinated_files": 0
    },
    "compactions": 0
  },
  "log_path": "…/transcript.jsonl"
}
```

Every field shown is always present. Notes on the ones that are easy to misread:

- **`verification[].exit_code` may be `null`.** That means the model named a command the harness has no record of running — with `allow_bash:false` that is every entry. `null` is reported rather than a guessed result.
- **`recovered_errors.lossy_args`** counts calls where a repair rung that can discard content was used. It is the only signal that a tool ran on arguments that were not exactly what the model sent.
- **`degraded: true`** means the harness, not the model, wrote the report.
- The whole text block is capped at 48 KiB; over that, `diff_preview` is dropped first, then `summary` and `files_changed` are trimmed, so the JSON always stays parseable.

`blockers` (why it stopped — often answerable, then re-dispatchable) is kept separate from `follow_ups` (optional future work) so an orchestrator can act on the difference.

### Errors: three layers

| Layer | Looks like | When |
|---|---|---|
| JSON-RPC error | `MCP error -32602: …` | Structural only: parse error, unknown method, unknown tool name. |
| Tool error (`isError: true`) | `ERROR: …` | Harness misconfiguration: missing key, `cwd` outside the root, invalid arguments, unknown `run_id`, worktree creation failure. |
| Successful call, failed task | `status: "failed"` | Everything the agent run itself can produce. |

**A failed task is a successful tool call.** It is never thrown, because a thrown error makes the calling agent abandon its plan instead of reading the diagnostics.

`exit_reason` values: `model_called_finish`, `iteration_budget_exhausted`, `runtime_budget_exhausted`, `token_budget_exhausted`, `cost_budget_exhausted`, `local_quota_exhausted`, `queue_timeout`, `no_tool_call`, `tool_call_serialization_failure`, `content_filter`, `context_overflow`, `auth_error`, `api_unavailable`, `api_contract_error`, `quota_exhausted`, `cancelled`, `harness_error`.

`retryable: true` is set for exactly five of them: `local_quota_exhausted`, `queue_timeout`, `tool_call_serialization_failure`, `api_unavailable`, `quota_exhausted`.

---

## Cost and rate-limit control

Z.ai publishes no rate-limit headers and no concurrency ceiling, so the server assumes nothing and drives everything from what it observes.

- **AIMD semaphore.** Starts at `GLM_MAX_CONCURRENT_MODEL_CALLS` (default 5); every rate-limited response drops it by one (floor 1), and after a clean minute a success raises it by one (ceiling 8).
- **Shared circuit breaker, two states — no half-open probe.** Five consecutive rate-limited responses across all runs open it for 30 s. When the window elapses it simply closes; there is no probe request deciding anything. Because the consecutive counter is cleared only by a success, the *next* rate limit after a window elapses re-trips immediately with the window doubled, up to a 300 s cap; a success resets the window to 30 s. Blocked callers each wake at the deadline plus their own jitter (up to 1.5 s), so a 16-way fan-out is smeared rather than released on one millisecond. `glm_health` reports `breaker.state` as `closed` or `open` only — if you are looking for a half-open state or a probe result, they do not exist.
- **Hourly quota bucket.** `GLM_MAX_CALLS_PER_HOUR` (default 600), refilling continuously. Exhaustion ends runs with `local_quota_exhausted` and `retryable: true`. This is the only *server-wide* thing standing between a runaway fan-out and a several-hundred-dollar bill. **Lower it before you trust a new workflow**, and set `--max-iterations` / `--max-runtime` too — those are hard ceilings a caller cannot exceed.
- **Per-run cost stop** at `GLM_MAX_COST_USD` (default $2.00) triggers a forced wrap-up, so you get the partial work rather than losing it. `GLM_TOKEN_BUDGET` does the same on tokens.
- Retries use full-jitter exponential backoff (`min(60 s, 2 s × 2ⁿ) × [0.5, 1.5)`), capped at 300 s of combined retrying per turn, with a 90 s inter-chunk stall watchdog on the stream. Authentication failures and quota walls are never blind-retried — with a wide fan-out, retrying a bad key just burns quota.

Costs in `stats.cost_usd` are computed from a local price table and are **estimates for budget enforcement**, not billing.

---

## Verified against GLM-5.2

The live end-to-end group (`npm run test:live`) was run against `glm-5.2` on the coding-plan endpoint. Stated factually, without extrapolation:

- GLM read files with `read_file` and located code with `search` before editing.
- It emitted **four tool calls in a single `tool_calls` array**, and the harness executed all four in array order and returned exactly one `role:"tool"` message per `tool_call_id`. This is the case a first-call-only harness gets wrong, and it desyncs the transcript rather than failing visibly.
- It applied a one-line fix with `edit_file` and then ran the project's test command with `bash` to confirm it, reading the real exit code.
- The envelope was truthful. In the `allow_bash:false` variant the model still listed a verification command; because the harness had no record of running it, the entry came back with `exit_code: null` rather than an invented pass, and the run was auto-downgraded to `partial`.

That is one model on one set of tasks. It is evidence that the loop, the parallel-tool-call handling and the fact-derived reporting work as described — not a benchmark, and not a claim about any other model in the list.

The free test suite (`npm test`, 157 assertions, no network and no key) covers the protocol framing, path confinement, the bash policy and restricted-mode grammar, the JSON repair ladder, the kernel path-deny, per-run **and cross-run** scratch-HOME isolation (including that one run cannot enumerate another's), credential resolution and precedence, unknown-flag rejection, source-text integrity, the instructions budget and the endpoint constants.

---

## Troubleshooting

**`glm-workflow-mcp: a root directory is required`** — pass `--root`. Deliberate; there is no fallback. This one *does* exit 1.

**The server looks connected but every model call fails** — it started degraded because there was no usable key. Call `glm_health`: `api_key_present: false`. The full reason — every credential source it checked, with paths — is prepended to your **first tool result**, carried in the connect-time `instructions`, and written to the server's **stderr** log (see [Where are the logs?](#troubleshooting) for that log's location).

**`GLM_API_KEY is the literal string "${GLM_API_KEY}"`** — your MCP client substitutes `${VAR}` only when `VAR` is set in *its* environment. Export it before launching, or use the `0600` credentials file. The server starts anyway; it does not exit.

**`… is mode 0644 but must be 0600`** — the credentials file is world- or group-readable and the server refuses to read it rather than use it anyway. `chmod 600` the path it names.

**Error `1113` — "insufficient balance or no resource package"** (surfaced as `kind: "auth_error"` / `exit_reason: "auth_error"`, never retried) — read this before you top up. It almost always means **the wrong base URL for your key's billing type**, not an empty wallet. Z.ai has two chat-completions paths and a key works on exactly one of them:

| You have | `GLM_BASE_URL` must be |
|---|---|
| a Z.ai **coding-plan** subscription | `https://api.z.ai/api/coding/paas/v4/chat/completions` (the default) |
| **pay-as-you-go** API credit | `https://api.z.ai/api/paas/v4/chat/completions` |

Cross the streams and every model fails identically — including the cheapest ones, which is the tell: if `glm-4.5-air` also returns `1113`, it is not a balance problem. The response arrives with **HTTP 200** and a `{"error":{"code":"1113",…}}` body (`code` is a string, not a number), so a client that trusts the status code sees a successful call. This server checks the body explicitly. Confirm with `glm_health { "probe": true }` after changing the URL.

**`wrong base URL — check GLM_BASE_URL`** — a different failure: you are pointing at a path that answers HTTP 200 with a non-conforming JSON envelope, almost always `/api/openai/v1/`, which boot refuses outright. Use one of the two URLs above.

**Runs come back `partial` with "ran no verification commands"** — either bash is off, or the agent could not find your test command. Put it in `context`: *"Run the tests with `npm test`."*

**A test that needs a dev server fails with connection refused** — nothing survives a bash call. The agent must start the server, wait for its port, run the test and kill it **inside one command**. If a run left processes behind, the tool result says so explicitly (`leftBehind: true` plus a note in stderr).

**`npm install` fails with `E401`, or `cargo`/`rustup`/`pyenv` "not found" inside a run** — that is the scratch `HOME`, not your project. The failing command's stderr carries a harness note saying so. Start the server with `GLM_TOOLCHAIN_ENV=1` to pass toolchain locator variables through; registry credentials are never passed through by design.

**Everything queues and nothing runs** — two runs want the same directory. Only one run per directory at a time; use `isolation: "worktree"`, which does not contend with plain runs. Check with `glm_health` → `scheduler.runs`.

**`exit_reason: "api_contract_error"`** — the request was rejected as malformed and is never retried. The full request body is logged to stderr. Usually an orphaned `tool_call_id`.

**The model returns nothing, and the log shows `finish_reason: "length"` with empty content** — the turn was spent entirely on hidden reasoning tokens, which come out of the same `max_tokens` budget as the answer. The client already floors every request at 4096 to prevent this, so if you still see it the transcript is simply too long for the remaining budget: raise `GLM_MAX_TOKENS_PER_TURN`, or split the task.

**`npm error code EBADPLATFORM`, or every `bash` call returns `spawn /bin/bash ENOENT`** — you are on Windows. Use WSL. The package declares `"os": ["darwin", "linux"]`; the second symptom means it was force-installed past that.

**Where are the logs?** The server writes to **stderr**, and your MCP client captures that stream into a per-server log. A few plain-text lines during boot carry the config, the resolved `state_dir`/`credentials_file`/`root`, and any key diagnostics; after that it is one JSON object per line.

In **Claude Code**, that stderr log lives under a per-project cache directory:

```
macOS:  ~/Library/Caches/claude-cli-nodejs/<url-escaped-project-path>/mcp-logs-<server-name>/
Linux:  ~/.cache/claude-cli-nodejs/<url-escaped-project-path>/mcp-logs-<server-name>/
```

`<server-name>` is the key you gave the server in `.mcp.json` (`glm` in the examples here), and `<url-escaped-project-path>` is your project's absolute path with the separators escaped. The newest file in that directory holds the most recent boot — this is where the degraded-start reason lands if you did not see it on the first tool result. (Other MCP clients keep their own server logs; consult your client if you are not using Claude Code.)

Per-run transcripts are separate, at `$GLM_STATE_DIR/runs/<run_id>/transcript.jsonl`, or via `glm_agent_status { include_log: true }`. Note that the state directory is denied to the agent, so it cannot read its own transcript — you read it, not it.

---

## Known limitations

- **`bash` is not a sandbox, is not confined to the root, and has no kernel path-deny backend on Linux.** A permitted command can read anything the user running the server can read. On macOS the credential paths are denied at the kernel via `sandbox-exec` — which Apple has **deprecated**, so it is not a guarantee for future macOS releases; on Linux they are protected by string tripwires only, which any splitter defeats, so that is **not a boundary**. The macOS backend also fails open if its boot probe stops succeeding, reported as `path_deny_backend: "none"`. If you need real containment, run with `--bash off` or run the server in a container.
- **The `/proc/<pid>/environ` key exposure on Linux with bash enabled is not closable** by this server. Use the credentials file instead of `env:` in `.mcp.json`.
- **The change report runs `git` in the repository the agent is editing, and honours that repository's own `.git/config`.** The global and system gitconfigs are pinned to `/dev/null` and the harness's `git` uses a HOME no agent can write, but a repo-local `diff.external`, `core.pager` or `alias.*` — which an agent may legitimately write, since it is a file inside its own working directory — is still in force during change detection. If you are running untrusted tasks, that is another reason for a container.
- **Per-run scratch HOMEs are torn down at the end of each run.** A 45-minute idle reaper, a cap on the live set, and a full sweep at server exit are backstops behind that, so a crashed or leaked run cannot accumulate directories under `os.tmpdir()` indefinitely.
- **Cold caches, every run.** The per-run `HOME` means no shared npm/pip/cargo cache between runs. That is the price of one run not being able to write dotfiles the next one executes.
- **`.gitignore` support is minimal by design.** Only the repository-root `.gitignore` is read, supporting `name`, `name/`, `*.ext`, `dir/**` and a leading `/`. Negations (`!`) and nested `.gitignore` files are ignored. A hardcoded ignore list (`.git`, `node_modules`, `vendor`, `dist`, `build`, `.next`, `out`, `__pycache__`, `.venv`, `venv`, `target`, `.cache`, `coverage`, `.turbo`, `.parcel-cache`, `.glm-agent`) always applies.
- **`search` uses JavaScript regular expressions**, not ripgrep syntax. It is pure JS — no `rg`, no `grep`, no spawn — so behaviour is identical on every machine. Files over 2 MiB and files whose first 4 KiB contain a NUL byte are skipped. `list_dir` shows at most 400 entries and descends at most 5 levels.
- **Outside a git repository**, change detection falls back to a hashed manifest (max 20000 files). New files get a real unified diff; modified files get accurate metadata but no before/after patch, because there is no object store to recover the previous bytes from. Run agents inside a git repo.
- **Cost figures are estimates**, computed from a local price table for budget enforcement. They are not billing.
- **The command allowlist is a footgun catcher, not a sandbox.** Said three times because it matters.

---

## Development

```bash
npm test              # 157 assertions: groups A-D, F-N and the lint checks — no key, no network, free
npm run test:live     # adds group E: real, metered calls against your key
npm run lint:stdout   # proves nothing outside src/mcp.js writes to stdout
```

| Group | Covers |
|---|---|
| A | MCP protocol framing, `initialize`, `tools/list`, error codes |
| B | Path confinement, symlink escapes, NUL bytes, secret-file policy |
| C | Bash policy: tripwires, allowlist chaining, `--noprofile/--norc` |
| D | The JSON repair ladder and the API error taxonomy |
| F | The kernel path-deny, with six evasion attempts (`cut`, `rev`, globs, `node -e` assembly, `find -exec`) |
| G | Per-run scratch HOME isolation, driven through `runBash` rather than the export |
| H | The `initialize` instructions budget, derived from `mcp.js`'s own constant |
| I | The endpoint default constants, plus one end-to-end read-back from `glm_health` |
| J | Cross-run scratch HOME isolation — one run cannot read, write **or enumerate** another run's home or the harness's |
| K | Source-text integrity: no NUL bytes and valid UTF-8 across `src/` (so every file stays greppable and diffable) |
| L | Restricted-mode grammar: `2>&1`, redirections, heredocs and shell keywords work while the allowlist still gates every command inside them |
| M | Credential resolution and precedence: env vs. the `0600` file, and that a non-credential value (empty, unexpanded `${VAR}`, too-short) never shadows a real one |
| N | Unknown CLI flags are a startup error, and the correctly spelled ceilings still reach `glm_health` → `caps` |
| Lint | Nothing outside `src/mcp.js` writes to stdout, and every relative import ends in `.js` with `node:` builtins |
| E | Live API: a `glm_health` probe, a `glm_ask`, and two full `glm_agent` runs |

The smoke test spawns the real server and speaks JSON-RPC to it over stdio. Its most important assertion is that *every byte* the server writes to stdout parses as exactly one JSON object per line — anything else silently destroys the MCP session.

Group E costs money and takes minutes, so `npm test` clears `GLM_API_KEY` / `ZAI_API_KEY` / `ZHIPU_API_KEY` for the child and group E always skips — including under `prepublishOnly`, where an exported key would otherwise have made `npm publish` quietly spend money. Opt in explicitly with `npm run test:live`, which sets `GLM_LIVE_TESTS=1` and passes your key through.

## License

MIT © Jawad Ahbab