The identity forwarded to opt-in MCP servers was a one-dimensional string
(ChatOrigin.requesterId): a MateClaw username for web logins, but a webchat
visitorId for visitors and an IM sender id for IM — indistinguishable to the
REST backend. The signed-token mode (d204b702) made this worse: an RS256
signature over an unauthenticated visitorId reads as "MateClaw authenticated
this user" to any backend that trusts the signature.
Introduce an identity-typing dimension at McpIdentityForwardService:
- classify() branches on ChatOrigin: authenticated (web login, sub=immutable
userId), anonymous (webchat visitor, trust=anonymous), external (IM sender,
trust=external), or none (cron/system → nothing injected, fail-closed).
- mint() adds `trust` and `channel_type` claims; plaintext value is prefixed
`trust:subject` so backends can tell the kinds apart without a JWT.
The immutable userId reaches resolve() without coupling it to the user store:
JwtAuthFilter stamps user.id into auth.setDetails() (both JWT and PAT paths),
and ChatController.memoryOrigin carries it on a new ChatOrigin.requesterUserId
field (only-add, per the record's evolution rule).
Resolves the webchat semantic mismatch raised in #459 and the "sub should be
an immutable user id" follow-up. 82 tests green (4 identity classes covered
with claim assertions + full ChatOrigin/MCP regression).
(cherry picked from commit b5d2cfbf98b39848d7139c743a0b81fea71e8ffe)
Replace java.util.Set.of / java.util.Map.of inline fully-qualified calls with
top-of-file imports per code style (test sources sync to the open-source repo).
* feat(mcp): forward authenticated user identity to opt-in STDIO MCP servers
A STDIO MCP server is one shared subprocess per configuration; its env is fixed
at spawn and STDIO has no per-request header channel, so per-user identity must
travel in-band with each tool call. Previously nothing carried it, so an MCP
server could not call its downstream REST backend on behalf of the acting user.
Inject the authenticated username (from ToolExecutionContext) into each tool
call's JSON arguments under the reserved key `__mateclaw_user__`, for servers an
operator explicitly opts in via `mateclaw.mcp.identity-forward.servers` (by name
or id). The MCP server reads/strips it and forwards on-behalf-of alongside its
own backend API key.
- McpIdentityForwardProperties: per-server opt-in allowlist (name or id).
- IdentityForwardingToolCallback: wraps an MCP callback, merges the username
into the args; injected by trusted code, overwrites any LLM-supplied value
(no spoofing); forwards unchanged when there is no user or args aren't an
object/are malformed.
- McpClientManager: captures server names; wraps opt-in servers' callbacks
inside the prefix wrapper (so name-prefixing / return-direct still see the raw
delegate). Non-opt-in servers are untouched — username never leaks to them.
- Tests: injection, LLM-value overwrite, empty/non-object/malformed inputs,
no-user passthrough, opt-in matching by id/name.
- Docs (zh/en mcp.md): opt-in config, `__mateclaw_user__` contract, FastMCP
Python skeleton, trust model.
Default off (empty allowlist) — zero behavior change for existing servers.
Plaintext username suits a trusted-network REST backend keyed by an API key;
a signed short-lived token is noted as the stronger-isolation follow-up.
* feat(mcp): add signed-token trust model for MCP identity forwarding
Plaintext username forwarding makes the REST backend trust an unverifiable
assertion from the (shared, LLM-adjacent) MCP service — a confused-deputy model.
Add an opt-in signed-token mode so identity crosses the trust boundary as a
short-lived RS256 JWT the backend can verify with a public key.
- McpIdentityForwardProperties: nested `token` config (enabled, issuer,
ttl-seconds, key-id, private-key-pem, audiences) + USER_ARG/TOKEN_ARG keys.
- McpIdentityForwardService: resolves the injection — plaintext username
(__mateclaw_user__) when token mode off, else a minted RS256 JWT
(__mateclaw_token__) with sub=user, aud=server, short exp, jti. Lazy key
parse; fail-closed when token mode is on but the key is missing/unparseable
(no silent downgrade to plaintext). Signs with MateClaw's private key so the
backend only needs the public key (cannot mint/impersonate).
- IdentityForwardingToolCallback: now delegates the what-to-inject decision to
the service (keyed by per-server audience); static withClaim() keeps the
JSON-merge logic (overwrites LLM-supplied key, leaves non-object/malformed
args untouched).
- McpClientManager: injects the service; passes service + audience through the
wrap path for opt-in servers only.
- Tests: token mint+verify (with an in-test RSA keypair, asserting sub/aud/iss/
exp/jti), plaintext mode, no-user and no-key fail-closed, audience resolution.
- Docs (zh/en): token config, key generation, claims, REST-side verification
example, public-key distribution + JWKS-endpoint follow-up.
Default unchanged: token.enabled=false → plaintext (back-compat); whole feature
still opt-in per server and off by default.
- explicit SecurityConfig authorization for /swagger-ui*, /v3/api-docs*, /webjars/**
- public for local/default profile; admin-only (ROLE_ADMIN) by default in production DB profiles
- override via MATECLAW_OPENAPI_EXPOSE_UI; add RANDOM_PORT integration tests and docs
execute_code (bash/sh/shell) bypassed the workspace boundary guard, so shell
code run through it could read/write/delete paths outside the workspace sandbox
(e.g. cat /etc/passwd) while the same paths were blocked for read_file and the
shell tools. Bring execute_code under the guard (scan only shell-language code,
report the code param), and trust the tool-result spill roots so a legitimate
spilled result stays readable. Adds regression tests.
- Fail closed to a global fallback sandbox root when a conversation has no
per-workspace base path, instead of leaving file/shell tools unconstrained
- Refuse shell commands that delete the workspace root directory itself
- Block workspace-boundary escapes at the policy layer before the approval
prompt, not only at execution time
- Approval bar now shows the actual command / target path being approved
Add an execute_code built-in tool that runs python/bash/node code the agent
writes on the fly, so a documentation-only skill (a SKILL.md with no bundled
scripts) can be acted on. Scoped runs inject the skill's secrets and run in
the skill directory; otherwise a private scratch directory is used. Host
secret env vars are scrubbed from the subprocess. execute_code is an
agent-wide capability, registered in the tool catalog (V143), and screened
by the tool guard with a dedicated set of destructive-pattern rules.
Tests cover python/bash/node execution, scratch-dir fallback, env scrubbing,
argument decoding, and guard gating.
Streaming chat ran render tools on an async thread with no bound request,
so download links lost their host and arrived without a domain. Resolve the
host on the request thread and carry it through ChatOrigin/ToolContext;
falls back to a configurable public-base-url, then a relative path.
1. Relative parent traversal in shell commands (HIGH)
validateShellCommand only scanned absolute path tokens, so commands
like `cat ../mateclaw/CLAUDE.md`, `cd .. && cat foo`, or
`ln -sf ../bar breakout` had no absolute path to trip the check.
From a workspace cwd that's a real escape — `..` segments resolve
against the JVM cwd at file-tool time and reach anywhere the user
can read.
Add a second pass: any token containing `..` as a path segment is
resolved against the workspace root via root.resolve(token).
normalize(); reject when the result falls outside. In-workspace
traversal like `subdir/../sibling` normalizes back inside and
passes. Identifiers without slashes (e.g. version strings with
`1.2..3`) are not treated as paths.
2. Shell validation and process working directory used different
context sources (MEDIUM)
execute_shell_command validated with the explicit ToolContext, but
buildShellProcess called WorkspacePathGuard.getWorkingDirectory()
(no-arg), which only sees the ThreadLocal fallback. Today the
ToolExecutionExecutor sets both so the discrepancy is latent, but
a future direct Spring AI invocation passing only ToolContext would
validate against one basePath and exec against another. Thread ctx
through buildShellProcess and call getWorkingDirectory(ctx) so
validation and execution agree on a single source of truth.
3. Absolute agent override could disable workspace scoping (MEDIUM)
resolveAgentBasePath accepted an absolute override verbatim, even
when it pointed outside the workspace root. An admin (or any
account with agent-edit permission) could set workspaceBasePath="/"
or another team's repo and bypass workspace boundaries entirely.
When a workspace has its own basePath, require absolute overrides
to sit underneath it. The caller in build() catches the rejection,
logs WARN, and falls back to the workspace basePath so chat stays
available rather than crashing agent construction. When the
workspace has no basePath there's no boundary to enforce, so legacy
behavior is preserved.
Test coverage: WorkspacePathGuardShellTest grows from 17 to 23 (six
new cases for `cd ..`, relative parent traversal, relative symlink
escape, deeper traversal, in-workspace normalization, and the
identifier false-positive guard). AgentGraphBuilderBasePathResolutionTest
grows from 7 to 10 (three new cases for in-workspace absolute,
outside-workspace absolute rejection, and no-workspace legacy
behavior). All 45 sandbox-area tests pass with no regressions.
The PRIVATE_ITEMS list contained the bare 'test' entry, which rsync
interprets as 'any directory named test at any depth' — so it caught
the root-level /test/ scratch directory (intended) AND every src/test/
under each module (not intended).
Pattern is already anchored to /test (root-only). This commit rsyncs
the accumulated src/test/ tree forward so opensource has the unit tests
that have been written / updated against existing src/main/ code since
the pattern regression. Going forward each per-commit sync will carry
src/test/ files along with the main change.