diff --git a/CHANGELOG.md b/CHANGELOG.md index 4525819da..f3d2a526a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased - Patch] +### Changed + +- Every `agent-relay` MCP tool description now states what the call returns, so an agent can tell from the tool list whether `post_message` hands back a message ID, whether `spawn` means the worker is running, and what an empty `list_agents` result implies. `register_agent` also explains that the registered name can differ from the requested one. + ### Fixed - CLI output no longer disappears when stdout or stderr is a pipe instead of a terminal. Node's stdio writes are asynchronous for pipes on macOS, so exiting in the same tick as the write discarded whatever was still buffered — `agent-relay cloud session --json | parser` and `$(agent-relay …)` could come back with empty stdout _and_ empty stderr, hiding the payload and the error that explained the failure. Every hard-exit path now drains stdio first. diff --git a/README.md b/README.md index 36e1ec044..a24648c3a 100644 --- a/README.md +++ b/README.md @@ -348,6 +348,7 @@ References: - [TypeScript SDK README](./packages/sdk/README.md) - [CHANGELOG.md](./CHANGELOG.md) +- [SECURITY.md](./SECURITY.md) — reporting a vulnerability - [GitHub Issues](https://github.com/AgentWorkforce/relay/issues) ## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..a3ca6d2c2 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,119 @@ +# Security Policy + +Agent Relay moves messages, credentials, and tool invocations between +autonomous agents. A defect here can expose a workspace to agents — or people — +that should never have reached it, so we treat security reports as a priority +over feature work. + +## Reporting a vulnerability + +**Report privately through GitHub Security Advisories:** + +> https://github.com/AgentWorkforce/relay/security/advisories/new + +That form is visible only to the maintainers until an advisory is published, +and it lets us open a private fix branch and credit you on release. + +**Please do not** report vulnerabilities through public GitHub issues, pull +requests, or the Discord server. Those are public the moment you post, which +puts every workspace running the affected version at risk before a fix exists. + +If GitHub Security Advisories is unavailable to you, open a public issue that +says only that you have a security report and asks for a private channel — +no details, no reproduction steps — and a maintainer will follow up. + +### What to include + +The more of this you can provide, the faster we can confirm and fix: + +- The affected component and version (`agent-relay --version`, the npm package + and version, or the broker build). +- The type of issue (authentication bypass, credential disclosure, injection, + privilege escalation across agents, denial of service, and so on). +- Step-by-step reproduction, ideally as a minimal workspace or script. +- The impact: what an attacker gains, and what access they need to start. +- Any proof-of-concept code, logs, or transcripts. + +**Redact credentials before you send them.** Workspace keys, broker keys, +observer tokens, API keys, and OAuth tokens frequently appear in Relay logs and +terminal transcripts. Replace them with placeholders. If a report requires a +live credential to demonstrate, say so and we will arrange a channel for it +rather than having it sit in an advisory thread. + +### What to expect + +- **Acknowledgement:** we aim to confirm receipt within 3 business days. +- **Assessment:** we aim to confirm or dispute the report, with a severity + assessment, within 10 business days. +- **Updates:** we will keep you posted on remediation progress, and will tell + you if a fix is going to take longer than expected. +- **Disclosure:** we publish an advisory once a fixed version is available. We + will credit you by name or handle unless you prefer otherwise. + +We ask that you give us a reasonable opportunity to ship a fix before +disclosing publicly. We do not run a paid bug bounty. + +## Supported versions + +We investigate reports against any release on the lines below. Fixes ship only +at the head of that line: we do not patch earlier minors in place, and we do +not backport across majors. If you are running an older 11.x, upgrading to the +current 11.x is how you receive the fix. + +| Component | Reports investigated | Fix delivered in | +| ----------------------------------------------- | -------------------- | ---------------- | +| `agent-relay` CLI and `@agent-relay/*` packages | any 11.x | latest 11.x | +| `agent-relay-broker` crate | any 3.x | latest 3.x | + +Releases before 11.0 (CLI and packages) and before 3.0 (broker) are +unsupported — we will not investigate a report that reproduces only there. +Upgrade before reporting against an older release; the issue may already be +fixed. + +## Scope + +**In scope:** + +- The `agent-relay` CLI and the published `@agent-relay/*` npm packages. +- The `agent-relay-broker` Rust crate and its prebuilt platform binaries. +- The message protocol itself: authentication, authorization between agents, + workspace and channel isolation, delivery integrity, action routing. +- Credential handling: how keys and tokens are stored on disk, passed to + spawned harnesses, and surfaced in output, logs, and error text. +- Agent-to-agent trust boundaries, including prompt or tool injection that + crosses from message content into another agent's privileged actions. + +**Out of scope:** + +- Vulnerabilities in third-party agent harnesses (Claude Code, Codex, Gemini + CLI, and others). Report those to their maintainers; tell us if Relay's + integration makes an existing harness issue materially worse. +- Findings that require an attacker to already hold the same local user account + as the agent process. Weaknesses that let a _different_ local user or process + reach agent state — group- or world-readable key files, permissive directory + modes, predictable paths in shared temp directories — are in scope. +- Dependency advisories with no demonstrated exploit path through Relay. We + track these through automated scanning; a report is welcome if you can show + the path. +- Missing hardening headers, TLS configuration, or similar findings on the + marketing site, absent a concrete impact. +- Reports generated by automated scanners with no validation or reproduction. + +## Security tooling + +`.github/workflows/security.yml` runs on pushes to `main`, on pull requests +targeting `main`, and weekly. Coverage is not uniform, so it is worth being +precise about what actually runs: + +- **Gitleaks secret scanning** runs on every trigger. +- **CodeQL, `npm audit`, and license compliance** run only when a change + touches the Node toolchain, so docs-only and Swift-only changes skip them. +- **Dependency review** runs on pull requests only. +- **CodeQL analyzes JavaScript and TypeScript only.** The `agent-relay-broker` + Rust crate is in scope for this policy but is not covered by CodeQL. +- Several of these jobs are advisory rather than blocking, so a green run does + not by itself mean no findings. + +Automated scanning catches regressions in the paths it covers. It is not a +substitute for the reports we get from you, and the gaps above are exactly +where your reports matter most. diff --git a/packages/cli/src/cli/agent-relay-mcp.ts b/packages/cli/src/cli/agent-relay-mcp.ts index 0125de853..10b5ff401 100644 --- a/packages/cli/src/cli/agent-relay-mcp.ts +++ b/packages/cli/src/cli/agent-relay-mcp.ts @@ -231,7 +231,8 @@ function registerAgentResultTool(server: McpServer, config: AgentResultCallbackC { title: 'Submit Result', description: - 'Submit the structured result for this spawned Agent Relay task. Call this when the requested work is complete and the result object is ready.' + + 'Submit the structured result for this spawned Agent Relay task. Call this when the requested work is complete and the result object is ready. ' + + 'Returns the acknowledgement payload from the spawning caller. Throws if the caller rejects the submission, in which case the result was not recorded. A timeout leaves the outcome unknown — the request may have been recorded before the client stopped waiting — so treat a retry as a possible duplicate rather than a safe repeat.' + schemaText, inputSchema: { data: z.unknown().describe('The JSON result payload requested by the spawning SDK caller.'), @@ -406,7 +407,9 @@ function registerAgentRelayTools( 'create_workspace', { title: 'Create Workspace', - description: 'Explicitly start a new Agent Relay workspace session and persist it for this project.', + description: + 'Explicitly start a new Agent Relay workspace session and persist it for this project. ' + + 'Returns the new workspace key and its resolved name. A `warning` field is present only when the workspace was created but its session could not be saved to disk, meaning the key must be kept and re-supplied to reconnect.', inputSchema: { name: z.string().describe('Human-readable workspace name'), }, @@ -454,7 +457,9 @@ function registerAgentRelayTools( 'set_workspace_key', { title: 'Set Workspace Key', - description: 'Join this MCP session to an existing Agent Relay workspace using a shared workspace key.', + description: + 'Join this MCP session to an existing Agent Relay workspace using a shared workspace key. ' + + 'Returns a confirmation message stating whether the key was persisted for this project, and whether "register_agent" must be called to claim an identity in the newly joined workspace.', inputSchema: { workspace_key: z.string().optional().describe('Workspace key starting with "rk_live_"'), api_key: z.string().optional().describe('Deprecated alias for workspace_key'), @@ -513,7 +518,11 @@ function registerAgentRelayTools( 'register_agent', { title: 'Register Agent', - description: 'Register an agent identity in the current workspace and obtain an agent token.', + description: + 'Claim a named identity in the current workspace so this session can post messages, read channels, and be addressed by other agents. ' + + 'Required before any messaging tool will work. ' + + 'Returns the agent token and the registered name, which can differ from the requested `name` when that name is already taken and the session rebinds to an available one. ' + + 'The token is stored in this session, so later tool calls do not need to pass it.', inputSchema: { name: z.string().describe('Unique agent name within the workspace'), type: z.enum(['agent', 'human']).optional().describe('Whether this identity is an AI agent or human'), @@ -561,7 +570,9 @@ function registerAgentRelayTools( 'list_agents', { title: 'List Agents', - description: 'List agents registered in the current workspace.', + description: + 'List agents registered in the current workspace. ' + + 'Returns an `agents` array of registered identities, narrowed to only online or only offline agents when `status` is supplied. An empty array means the workspace has no agent matching the filter.', inputSchema: { status: z.enum(['online', 'offline']).optional().describe('Optional status filter'), }, @@ -581,7 +592,9 @@ function registerAgentRelayTools( 'query_nodes', { title: 'Query Fleet Nodes', - description: 'Query registered fleet nodes by capability or name.', + description: + 'Query registered fleet nodes by capability or name. ' + + 'Returns a `nodes` array of the fleet nodes matching every supplied filter; an empty array means no node matched. Use it to find a node name to pass as `target_node` when spawning.', inputSchema: { capability: z.string().optional().describe('Optional capability name filter'), name: z.string().optional().describe('Optional node name filter'), @@ -611,7 +624,8 @@ function registerAgentRelayTools( 'Examples: "spawn a codex agent" → cli:"codex"; ' + '"spawn an opus claude agent" → cli:"claude", model:"claude-opus-4-8"; ' + '"spawn a sonnet claude agent" → cli:"claude", model:"claude-sonnet-4-6". ' + - 'Do NOT use the built-in Agent/Task tool for relay workers.', + 'Do NOT use the built-in Agent/Task tool for relay workers. ' + + 'Returns the spawn record for the new worker, including the name it registered under. The worker boots asynchronously, so a successful return means the spawn was accepted, not that the worker is ready — watch for its messages or poll "list_agents" to confirm it came online.', inputSchema: { name: z.string().describe('Worker agent name'), cli: z @@ -669,7 +683,9 @@ function registerAgentRelayTools( 'spawn', { title: 'Spawn Agent', - description: 'Invoke the fleet spawn action. Optionally target a specific node.', + description: + 'Invoke the fleet spawn action, optionally targeting a specific node. ' + + 'Returns an `invocation` record acknowledging the request. The action runs asynchronously, so this confirms the spawn was queued, not that the worker is running.', inputSchema: { name: z.string().describe('Agent name'), cli: z @@ -713,7 +729,9 @@ function registerAgentRelayTools( 'remove_agent', { title: 'Remove Agent', - description: 'Release a worker agent from active duty.', + description: + 'Release a worker agent from active duty, optionally deleting it outright. ' + + 'Returns an `invocation` record acknowledging the request, which is processed asynchronously. Releasing keeps the agent registered and re-spawnable; passing `delete_agent` removes the identity permanently.', inputSchema: { name: z.string().describe('Agent name'), reason: z.string().optional().describe('Removal reason'), diff --git a/packages/cli/src/cli/mcp/action-tools.ts b/packages/cli/src/cli/mcp/action-tools.ts index 78d017507..500f8306e 100644 --- a/packages/cli/src/cli/mcp/action-tools.ts +++ b/packages/cli/src/cli/mcp/action-tools.ts @@ -88,7 +88,9 @@ export function registerAgentRelayActionTools( 'list_actions', { title: 'List Actions', - description: 'List Agent Relay actions available to this agent.', + description: + 'List Agent Relay actions available to this agent. ' + + 'Returns an `actions` array of action descriptors, each with its name, description, and input schema. Use it to discover what can be passed to "invoke_action".', inputSchema: {}, outputSchema: jsonResult, annotations: { diff --git a/packages/cli/src/cli/mcp/messaging-tools.ts b/packages/cli/src/cli/mcp/messaging-tools.ts index e6b9772c8..5eec8a7f5 100644 --- a/packages/cli/src/cli/mcp/messaging-tools.ts +++ b/packages/cli/src/cli/mcp/messaging-tools.ts @@ -34,7 +34,9 @@ export function registerMessagingTools( 'create_channel', { title: 'Create Channel', - description: 'Create a new workspace channel.', + description: + 'Create a new workspace channel. ' + + 'Returns the created channel record, including the name other agents use to join or post to it.', inputSchema: { name: z.string().describe('Unique channel name'), topic: z.string().optional().describe('Optional channel topic'), @@ -55,7 +57,9 @@ export function registerMessagingTools( 'list_channels', { title: 'List Channels', - description: 'List channels available in the workspace.', + description: + 'List channels available in the workspace. ' + + 'Returns a `channels` array. Archived channels are excluded unless `include_archived` is set.', inputSchema: { include_archived: z.boolean().optional().describe('Include archived channels'), ...identityOverrideInputShape, @@ -77,7 +81,9 @@ export function registerMessagingTools( 'join_channel', { title: 'Join Channel', - description: 'Join an existing channel.', + description: + 'Join an existing channel so its messages reach this agent. ' + + 'Returns a confirmation message naming the channel joined.', inputSchema: { channel: z.string().describe('Channel name'), ...identityOverrideInputShape, @@ -95,7 +101,9 @@ export function registerMessagingTools( 'leave_channel', { title: 'Leave Channel', - description: 'Leave a channel.', + description: + "Stop receiving a channel's messages without archiving it for anyone else. " + + 'Returns a confirmation message naming the channel left.', inputSchema: { channel: z.string().describe('Channel name'), ...identityOverrideInputShape, @@ -113,7 +121,9 @@ export function registerMessagingTools( 'invite_to_channel', { title: 'Invite to Channel', - description: 'Invite another agent to a channel.', + description: + 'Invite another agent to a channel. ' + + 'Returns a confirmation message naming the invited agent and the channel.', inputSchema: { channel: z.string().describe('Channel name'), agent: z.string().describe('Agent name to invite'), @@ -132,7 +142,9 @@ export function registerMessagingTools( 'set_channel_topic', { title: 'Set Channel Topic', - description: 'Update a channel topic.', + description: + "Replace a channel's topic with new text. " + + 'Returns the updated channel record carrying the new topic.', inputSchema: { channel: z.string().describe('Channel name'), topic: z.string().describe('New topic'), @@ -148,7 +160,9 @@ export function registerMessagingTools( 'archive_channel', { title: 'Archive Channel', - description: 'Archive a channel.', + description: + 'Archive a channel for the whole workspace, closing it to new messages. ' + + 'Returns a confirmation message naming the archived channel. Archived channels stay readable and are surfaced by "list_channels" only when `include_archived` is set.', inputSchema: { channel: z.string().describe('Channel name'), ...identityOverrideInputShape, @@ -166,7 +180,9 @@ export function registerMessagingTools( 'post_message', { title: 'Post Message', - description: 'Post a new message to a channel as the current agent.', + description: + 'Post a new message to a channel as the current agent. ' + + 'Returns the created message record, including the message ID needed to reply to it with "reply_to_thread" or react to it with "add_reaction".', inputSchema: { channel: z.string().describe('Channel name'), text: z.string().describe('Message text'), @@ -190,7 +206,9 @@ export function registerMessagingTools( 'list_messages', { title: 'Get Messages', - description: 'Retrieve message history from a channel.', + description: + 'Retrieve message history from a channel. ' + + 'Returns a `messages` array. Pass a message ID from a previous call as `before` or `after` to page through history beyond a single `limit`.', inputSchema: { channel: z.string().describe('Channel name'), limit: z.number().optional().describe('Maximum messages to return'), @@ -213,7 +231,9 @@ export function registerMessagingTools( 'reply_to_thread', { title: 'Reply to Thread', - description: 'Reply to an existing message thread.', + description: + 'Post a threaded reply under an existing message instead of to the channel at large. ' + + 'Returns the created reply record with its own message ID.', inputSchema: { message_id: z.string().describe('Parent message ID'), text: z.string().describe('Reply text'), @@ -234,7 +254,9 @@ export function registerMessagingTools( 'get_message_thread', { title: 'Get Thread', - description: 'Retrieve a message thread.', + description: + 'Retrieve a message thread. ' + + 'Returns the parent message together with its replies, capped by `limit` when a positive value is supplied. A `limit` of 0 is ignored and returns the full thread.', inputSchema: { message_id: z.string().describe('Parent message ID'), limit: z.number().optional().describe('Maximum replies to return'), @@ -251,7 +273,9 @@ export function registerMessagingTools( 'send_dm', { title: 'Send Direct Message', - description: 'Send a private direct message to another agent.', + description: + 'Send a private direct message visible only to the recipient and this agent. ' + + 'Returns the created message record, including its message ID.', inputSchema: { to: z.string().describe('Recipient agent name'), text: z.string().describe('DM text'), @@ -275,7 +299,9 @@ export function registerMessagingTools( 'list_dms', { title: 'List DM Conversations', - description: 'List direct message conversations for the current agent.', + description: + 'List direct message conversations for the current agent. ' + + 'Returns a `conversations` array covering both one-to-one and group DMs. Each conversation ID addresses the `relay://dm/{conversation_id}` resource, which holds that conversation\'s messages; no tool sends a follow-up message by conversation ID, so reply with "send_dm" to the recipient by name.', inputSchema: { ...identityOverrideInputShape, }, @@ -291,7 +317,9 @@ export function registerMessagingTools( 'send_group_dm', { title: 'Send Group DM', - description: 'Create a group DM and send the first message.', + description: + 'Open a private group conversation with several agents and post its first message. ' + + 'Returns the created `conversation` and the `message` posted to it.', inputSchema: { participants: z.array(z.string()).describe('Participant agent names'), name: z.string().optional().describe('Optional group name'), @@ -318,7 +346,9 @@ export function registerMessagingTools( 'add_reaction', { title: 'Add Reaction', - description: 'Add an emoji reaction to a message.', + description: + 'Add an emoji reaction to a message. ' + + 'Accepts a raw emoji or a shortcode such as `:+1:` or `rocket`. Returns a confirmation message showing the emoji the shortcode resolved to.', inputSchema: { message_id: z.string().describe('Message ID'), emoji: z.string().describe('Emoji character or shortcode'), @@ -338,7 +368,9 @@ export function registerMessagingTools( 'remove_reaction', { title: 'Remove Reaction', - description: 'Remove an emoji reaction from a message.', + description: + "Remove one of this agent's emoji reactions from a message. " + + 'Accepts the same raw emoji or shortcode forms as "add_reaction". Returns a confirmation message showing the resolved emoji.', inputSchema: { message_id: z.string().describe('Message ID'), emoji: z.string().describe('Emoji character or shortcode'), @@ -358,7 +390,9 @@ export function registerMessagingTools( 'search_messages', { title: 'Search Messages', - description: 'Search messages across the workspace.', + description: + 'Search messages across every channel this agent can see. ' + + 'Returns a `results` array of matching messages, narrowed by the optional `channel` and `from` filters. An empty array means nothing matched.', inputSchema: { query: z.string().describe('Text search query'), channel: z.string().optional().describe('Optional channel filter'), @@ -379,7 +413,9 @@ export function registerMessagingTools( 'check_inbox', { title: 'Check Inbox', - description: 'Check unread messages, mentions, DMs, and reactions for the current agent.', + description: + 'Check unread messages, mentions, DMs, and reactions for the current agent. ' + + 'Returns the inbox payload grouping unread items by kind. Reading the inbox does not clear it — call "mark_message_read" to do that.', inputSchema: { limit: z.number().optional().describe('Maximum inbox items'), ...identityOverrideInputShape, @@ -395,7 +431,9 @@ export function registerMessagingTools( 'mark_message_read', { title: 'Mark as Read', - description: 'Mark a message as read for the current agent.', + description: + "Clear a message from this agent's unread inbox. " + + 'Returns a confirmation message naming the message ID marked read.', inputSchema: { message_id: z.string().describe('Message ID'), ...identityOverrideInputShape, @@ -413,7 +451,9 @@ export function registerMessagingTools( 'get_message_readers', { title: 'Get Readers', - description: 'List agents who have read a message.', + description: + 'Check which agents have read a message, to confirm delivery before acting on silence. ' + + 'Returns a `readers` array of the agents that have read it; an empty array means nobody has.', inputSchema: { message_id: z.string().describe('Message ID'), ...identityOverrideInputShape,