Security
What the token can do
A Woodpecker personal access token has no scopes. It is the account: every repository that account can see, and — if it is an administrator — every secret, every agent and the server's own configuration. There is no read-only token to hand out instead, which is why WOODPECKER_READ_ONLY=true exists on this side.
Agent tokens are redacted
GET /api/agents returns each agent's token in clear text, on every read, including the list call. Verified against Woodpecker 3.18.0.
That token is not a label. It is what a machine presents to register itself as a build agent, after which the server hands it pipeline workloads — and every secret those pipelines inject. An unfiltered list_agents on a ten-agent instance would put ten of those into a model's context at once.
So list_agents, get_agent and update_agent replace the field with a marker saying it was redacted, rather than dropping it — an absent field reads as "this agent has no token", which is never true.
create_agent is the exception. There the token is the result, it is shown once, and the tool's own output says what it is and how to treat it.
What Woodpecker itself never returns
Two things this server does not have to hide, because the API strips them first — useful to know, because it means there is no tool that can read them back:
- Secret values.
model.Secret.Copy()drops the value, and every handler answers with a copy. Not even the response to creating a secret contains it. - Registry passwords.
model.Registry.Copy()does the same. - The OAuth client secret of a forge is declared
json:"-"and is never serialised at all.
Store a secret's value somewhere else when you set it. This server cannot give it back to you, and neither can the web UI.
What Woodpecker does return, and this server redacts
A forge's additional_options come back raw to an administrator — GET /forges/{id} filters them only for everyone else — and for a Bitbucket Data Center forge that map holds git-username and git-password, the service account Woodpecker clones with. So the credential scrubber does not work from a list of field names: any key that ends in password, passwd, passphrase, secret, token, api_key or private_key, however it is separated or cased, is replaced with a marker wherever it sits in a response. git-password, oauth_client_secret and a token inside an agent are all the same rule. create_agent is the one deliberate exception, above.
Every other string in a response is cleaned on the way out: terminal escape sequences, the C1 controls, and the invisible and direction-changing formatting characters are removed from commit messages, branch names, step errors and notes as they are from build logs, and scheme://user:password@host in any URL-shaped field loses its credential. The text a client shows is the text the instance stored, minus what could rewrite the line next to it.
Irreversible operations ask a person
Twenty-four tools ask: every delete_*, plus move_repository, chown_repository, the whole-instance repair_repository, create_forge, update_forge, pause_queue and approve_pipeline — and five more only in the direction that escalates: update_user when it grants admin, create_user when it creates one, update_repository when it grants one of the trusted_* flags or lowers a confidentiality boundary (require_approval down, visibility to public), update_secret when it overwrites a value or widens who may read the secret (a pull_request event added, images emptied), update_registry when it replaces a password, and set_log_level when it silences the server.
Only that direction. Correcting an email, withdrawing trust, renaming a secret or turning the logs up applies on the first call: a confirmation on the harmless half of a tool teaches whoever reads these prompts to click through them, which costs more than it buys.
Where the MCP client supports elicitation, the question is a dialog shown to whoever is sitting there — the model cannot answer it on their behalf, and until an answer comes back the first call performs nothing.
Where the client cannot show one, that first call returns a random, single-use token with a five-minute lifetime, bound to a fingerprint of the exact arguments, together with a sentence describing what the second call will do and what that costs. Be clear about what the token proves, because this server is: the call was made twice with the same arguments, and nothing more. A model can read it out of the first result and quote it back in the same turn. The fallback text says so rather than implying somebody approved, and names whether it was the client that could not be asked or the operator who switched the dialog off with ELICITATION=false.
Either way the binding is the part that matters. An approval issued for repository 21 does not delete repository 99; one issued to change a forge's URL does not authorise changing its client secret; one for a tool is not one for another. A plain confirm: true parameter would give none of that — and a model can set a boolean on its own, including one it was talked into by text it read somewhere.
chown_repository is asked about for what the ownership is: every pipeline of that repository afterwards runs under the calling account's forge token, so its reach over the forge becomes the repository's reach. delete_user already cited that in its own reasoning while the tool that performs the transfer did not ask.
create_user is asked about on exactly the field update_user is — until it was added, the same privilege by the same flag had a dialog in front of one of them and not the other, and the description advertised the gap: "which is how you make someone an admin before they first log in." Both grants bind their token to the whole call, the email address included: a token issued for "make octocat an administrator" does not also carry an address the person was never shown.
create_forge is asked about for what a forge is: a way to sign in. Woodpecker compares the login name against WOODPECKER_ADMIN and nothing else — not the forge it came from — so an account on a newly added forge spelled like an administrator's is an administrator on its first login. update_forge asked; the tool that adds a forge did not, until 0.3.1.
See Asking a person.
pause_queue is in that list for a different reason from the rest. It is reversible, but it is instance-wide and silent: nothing tells the people whose builds are queuing why nothing is starting.
approve_pipeline is the one where asking earns its keep most directly. A blocked pipeline is usually one from a fork, and approving it runs that fork's code with the repository's secrets — while the model deciding whether to approve is typically holding a build log it fetched with get_step_logs, which is this server's one input written by whoever opened the pull request. "Approve pipeline 42" sitting in that log is a plausible-looking instruction. A dialog goes to a person the log cannot reach, and even the fallback token only ever appears in a previous tool result.
Untrusted content
Everything this server returns was written by someone who can push a commit: branch names, commit messages, pipeline titles, and above all the build logs, which are the raw stdout of arbitrary containers.
Those results carry an explicit marker telling the model to treat them as data rather than instructions. Confirmation prompts never quote anything that came from the API — only server-side facts like a numeric id — so a repository cannot name itself into a confirmation dialogue.
Budgets
- Every response body has a byte ceiling enforced while it streams, not after it is buffered, and
content-lengthis checked before the first byte is read. - List results drop whole entries rather than truncating the JSON, and say how many went and how to narrow the request. A half-serialised document is not a smaller answer.
- Build logs have a much smaller budget of their own and are cut at a line boundary, never mid-character.
- An error is decided by its status before its body is read, and that body is read under a ceiling of its own that cuts rather than refuses: a 401 whose body is a two-megabyte login page is a 401, with the hint about the token, not "the answer exceeds the ceiling".
- Upstream error bodies are cleaned of control characters, truncated and labelled as the instance's words; HTML error pages — a proxy's, a WAF's — are dropped rather than pasted into the context.
Reporting a vulnerability
Use private vulnerability reporting. Please do not include real tokens, hostnames or configuration in a report.