Add GitHub tool and Discord channel (#34)

* Add GitHub tool for IronClaw - manage repos, issues, PRs, and workflows

* Add Discord channel for IronClaw - slash commands and button interactions

* Security fixes: URL encoding, secret validation, Discord button handler

- Add URL encoding for all path segments and query parameters (P1)
- Add path segment validation to prevent path traversal
- Add secret_exists check for better error messages (P2)
- Fix http_request signature to use 5 args (P2)
- Fix Discord button handler to check member field (P2)
- Fix typo in Discord slash command format (P2)
- Add github.capabilities.json and discord.capabilities.json (Blocker)
- Add Cargo.toml for Discord channel (Blocker)
- Add limit caps (max 100) for all list operations (P3)
- Remove debug logging

* Apply Copilot review fixes

Security & Code Quality:
- Use secret_get instead of workspace_read for GitHub token
- Remove manual Authorization header (host injects via capabilities)
- Add validation for file paths (reject path traversal)
- Add validation for workflow_id and git refs
- Fix url_encode_query comment
- Add release profile optimizations to Cargo.toml files
- Fix package names to match conventions (github-tool, discord-channel)
- Add metadata fields to Cargo.toml
- Fix rate limits to be consistent (60/min, 3600/hr)
- Fix Discord user_name to filter empty global_name
- Fix Discord metadata serialization error handling
- Update Discord README to clarify which secrets are used by host vs WASM
- Better formatting for Discord command option values

* applied all PR change requests and comments

* cleaned up workspace

* Adding validation for empty path segments and event enum in GitHub tool

* addedvalidation for events and vaidation to reject empty file path in github tools and implemented safe UTF-8 trunacating

* added codegen units and updated truncating logic also update capabilities.json as requested by copilot review

* added codegen units and updated truncating logic also update capabilities.json as requested by copilot review

* fixed message trucating and remove url_encode alias, also appled all requested changes from last PR comment

---------

Co-authored-by: root <root@cafx>
Co-authored-by: Peni <[email protected]>
Co-authored-by: Illia Polosukhin <[email protected]>
Co-authored-by: firat.sertgoz <[email protected]>
This commit is contained in:
Peniel Ben
2026-02-16 15:58:13 +04:00
committed by GitHub
co-authored by root Peni Illia Polosukhin firat.sertgoz
parent 0e981429ee
commit 61a123a746
8 changed files with 1756 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "discord-channel"
version = "0.1.0"
edition = "2021"
description = "Discord channel for IronClaw"
license = "MIT OR Apache-2.0"
publish = false
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
wit-bindgen = "0.41.0"
[lib]
crate-type = ["cdylib"]
[profile.release]
strip = true
opt-level = "s"
lto = true
codegen-units = 1
+121
View File
@@ -0,0 +1,121 @@
# Discord Channel for IronClaw
WASM channel for Discord integration - handle slash commands and button interactions via webhooks.
## Features
- **Slash Commands** - Process Discord slash commands
- **Button Interactions** - Handle button clicks
- **Thread Support** - Respond in threads
- **DM Support** - Handle direct messages
## Setup
1. Create a Discord Application at <https://discord.com/developers/applications>
2. Create a Bot and get the token
3. Set up Interactions URL to point to your IronClaw instance
4. Copy the Application ID and Public Key
5. Store in IronClaw secrets:
```bash
ironclaw secret set discord_bot_token YOUR_BOT_TOKEN
```
**Note:** The `discord_bot_token` secret is the only value read directly by this
Discord channel WASM component. The `discord_app_id` and `discord_public_key`
secrets are used by the IronClaw host (for example, to verify Discord
interaction signatures and manage slash command registration) and are not
accessed from the WASM module itself.
## Discord Configuration
### Register Slash Commands
```bash
curl -X POST \
-H "Authorization: Bot YOUR_BOT_TOKEN" \
-H "Content-Type: application/json" \
https://discord.com/api/v10/applications/YOUR_APP_ID/commands \
-d '{
"name": "ask",
"description": "Ask the AI agent",
"options": [{
"name": "question",
"description": "Your question",
"type": 3,
"required": true
}]
}'
```
### Set Interactions Endpoint
In your Discord app settings, set:
- Interactions Endpoint URL: `https://your-ironclaw.com/webhook/discord`
## Usage Examples
### Slash Command
User types: `/ask question: What is the weather?`
The agent receives:
```text
User: @username
Content: /ask question: What is the weather?
```
### Button Click
When a user clicks a button in a message, the agent receives:
```text
User: @username
Content: [Button clicked] Original message content
```
## Error Handling
If an internal error occurs (e.g., metadata serialization failure), the tool attempts to send an ephemeral message to the user:
```text
❌ Internal Error: Failed to process command metadata.
```
Check the host logs for detailed error information.
## Advanced Usage
### Embeds
To send embeds, include an `embeds` array in the `metadata_json` field of the agent's response. The structure should match the Discord API `embed` object.
## Troubleshooting
### "Invalid Signature"
- Check that `discord_public_key` is set correctly in IronClaw secrets.
- This validation happens on the host before reaching the WASM.
### "401 Unauthorized"
- Check that `discord_bot_token` is set correctly in IronClaw secrets.
- Ensure the bot is added to the server.
### "Interaction Failed"
- The interaction might have timed out (Discord requires a response within 3 seconds).
- The `interactions_endpoint_url` might be unreachable.
## Building
```bash
cd channels-src/discord
cargo build --target wasm32-wasi --release
```
## License
MIT/Apache-2.0
@@ -0,0 +1,39 @@
{
"type": "channel",
"name": "discord",
"description": "Discord Gateway/Webhook channel for handling slash commands, buttons, and messages",
"capabilities": {
"http": {
"allowlist": [
{ "host": "discord.com", "path_prefix": "/api/v10" }
],
"credentials": {
"discord_bot_token": {
"secret_name": "discord_bot_token",
"location": { "type": "header", "header_name": "Authorization", "prefix": "Bot " },
"host_patterns": ["discord.com"]
}
},
"rate_limit": {
"requests_per_minute": 60,
"requests_per_hour": 3600
}
},
"secrets": {
"allowed_names": ["discord_bot_token", "discord_*"]
},
"channel": {
"allowed_paths": ["/webhook/discord"],
"allow_polling": false,
"callback_timeout_secs": 45,
"workspace_prefix": "channels/discord/",
"emit_rate_limit": {
"messages_per_minute": 100,
"messages_per_hour": 5000
}
}
},
"config": {
"require_signature_verification": true
}
}
+476
View File
@@ -0,0 +1,476 @@
//! Discord Gateway/Webhook channel for IronClaw.
//!
//! This WASM component implements the channel interface for handling Discord
//! interactions via webhooks and sending messages back to Discord.
//!
//! # Features
//!
//! - URL verification for Discord interactions
//! - Slash command handling
//! - Message event parsing (@mentions, DMs)
//! - Thread support for conversations
//! - Response posting via Discord Web API
//! - Automatic message truncation (> 2000 chars)
//!
//! # Security
//!
//! - Signature validation is handled by the host (webhook secrets)
//! - Bot token is injected by host during HTTP requests
//! - WASM never sees raw credentials
wit_bindgen::generate!({
world: "sandboxed-channel",
path: "../../wit/channel.wit",
});
use serde::{Deserialize, Serialize};
use exports::near::agent::channel::{
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
OutgoingHttpResponse, StatusUpdate,
};
use near::agent::channel_host::{self, EmittedMessage};
/// Discord interaction wrapper.
#[derive(Debug, Deserialize)]
struct DiscordInteraction {
/// Interaction type (1=Ping, 2=ApplicationCommand, 3=MessageComponent)
#[serde(rename = "type")]
interaction_type: u8,
/// Interaction ID
id: String,
/// Application ID
application_id: String,
/// Guild ID (if in server)
#[allow(dead_code)] // Part of API payload, currently unused
guild_id: Option<String>,
/// Channel ID
channel_id: Option<String>,
/// Member info (if in server)
member: Option<DiscordMember>,
/// User info (if DM)
user: Option<DiscordUser>,
/// Command data (for slash commands)
data: Option<DiscordCommandData>,
/// Message (for component interactions)
message: Option<DiscordMessage>,
/// Token for responding
token: String,
}
#[derive(Debug, Deserialize, Clone)]
struct DiscordMember {
user: DiscordUser,
#[allow(dead_code)] // Part of API payload, currently unused
nick: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
struct DiscordUser {
id: String,
username: String,
global_name: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
struct DiscordCommandData {
#[allow(dead_code)] // Part of API payload, currently unused
id: String,
name: String,
options: Option<Vec<DiscordCommandOption>>,
}
#[derive(Debug, Deserialize, Clone)]
struct DiscordCommandOption {
name: String,
value: serde_json::Value,
}
#[derive(Debug, Deserialize, Clone)]
struct DiscordMessage {
#[allow(dead_code)] // Part of API payload, currently unused
id: String,
content: String,
channel_id: String,
#[allow(dead_code)] // Part of API payload, currently unused
author: DiscordUser,
}
/// Metadata stored with emitted messages for response routing.
#[derive(Debug, Serialize, Deserialize)]
struct DiscordMessageMetadata {
/// Discord channel ID
channel_id: String,
/// Interaction ID for followups
interaction_id: String,
/// Interaction token for responding
token: String,
/// Application ID
application_id: String,
/// Thread ID (for forum threads)
thread_id: Option<String>,
}
struct DiscordChannel;
impl Guest for DiscordChannel {
fn on_start(_config_json: String) -> Result<ChannelConfig, String> {
channel_host::log(channel_host::LogLevel::Info, "Discord channel starting");
Ok(ChannelConfig {
display_name: "Discord".to_string(),
http_endpoints: vec![HttpEndpointConfig {
path: "/webhook/discord".to_string(),
methods: vec!["POST".to_string()],
require_secret: true,
}],
poll: None,
})
}
fn on_http_request(req: IncomingHttpRequest) -> OutgoingHttpResponse {
let body_str = match std::str::from_utf8(&req.body) {
Ok(s) => s,
Err(_) => {
return json_response(400, serde_json::json!({"error": "Invalid UTF-8 body"}));
}
};
let interaction: DiscordInteraction = match serde_json::from_str(body_str) {
Ok(i) => i,
Err(e) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Failed to parse Discord interaction: {}", e),
);
return json_response(400, serde_json::json!({"error": "Invalid interaction"}));
}
};
match interaction.interaction_type {
// Ping - Discord verification
1 => {
channel_host::log(channel_host::LogLevel::Info, "Responding to Discord ping");
json_response(200, serde_json::json!({"type": 1}))
}
// Application Command (slash command)
2 => {
handle_slash_command(&interaction);
json_response(
200,
serde_json::json!({
"type": 5,
"data": {
"content": "🤔 Thinking..."
}
}),
)
}
// Message Component (buttons, selects)
3 => {
if let Some(ref message) = interaction.message {
handle_message_component(&interaction, message);
}
json_response(200, serde_json::json!({"type": 6}))
}
_ => {
channel_host::log(
channel_host::LogLevel::Warn,
&format!(
"Unknown Discord interaction type: {}",
interaction.interaction_type
),
);
json_response(200, serde_json::json!({"type": 6}))
}
}
}
fn on_poll() {}
fn on_respond(response: AgentResponse) -> Result<(), String> {
let metadata: DiscordMessageMetadata = serde_json::from_str(&response.metadata_json)
.map_err(|e| format!("Failed to parse metadata: {}", e))?;
// Use webhook endpoint for followup
let url = format!(
"https://discord.com/api/v10/webhooks/{}/{}",
metadata.application_id, metadata.token
);
// Truncate content to 2000 characters to comply with Discord limits
let content = truncate_message(&response.content);
let mut payload = serde_json::json!({
"content": content,
});
// Check for embeds in metadata
if let Ok(meta_json) = serde_json::from_str::<serde_json::Value>(&response.metadata_json) {
if let Some(embeds) = meta_json.get("embeds") {
payload["embeds"] = embeds.clone();
}
}
let payload_bytes =
serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?;
let headers = serde_json::json!({
"Content-Type": "application/json"
});
let result = channel_host::http_request(
"POST",
&url,
&headers.to_string(),
Some(&payload_bytes),
None,
);
match result {
Ok(http_response) => {
if http_response.status >= 200 && http_response.status < 300 {
channel_host::log(channel_host::LogLevel::Debug, "Posted followup to Discord");
Ok(())
} else {
let body_str = String::from_utf8_lossy(&http_response.body);
Err(format!(
"Discord API error: {} - {}",
http_response.status, body_str
))
}
}
Err(e) => Err(format!("HTTP request failed: {}", e)),
}
}
fn on_status(_update: StatusUpdate) {}
fn on_shutdown() {
channel_host::log(
channel_host::LogLevel::Info,
"Discord channel shutting down",
);
}
}
fn handle_slash_command(interaction: &DiscordInteraction) {
let user = interaction
.member
.as_ref()
.map(|m| &m.user)
.or(interaction.user.as_ref());
let user_id = user.map(|u| u.id.clone()).unwrap_or_default();
let user_name = user
.map(|u| {
u.global_name
.as_ref()
.filter(|s| !s.is_empty())
.unwrap_or(&u.username)
.clone()
})
.unwrap_or_default();
let channel_id = interaction.channel_id.clone().unwrap_or_default();
let command_name = interaction
.data
.as_ref()
.map(|d| d.name.clone())
.unwrap_or_default();
let options = interaction.data.as_ref().and_then(|d| d.options.clone());
let content = if let Some(opts) = options {
let opt_str = opts
.iter()
.map(|o| format!("{}: {}", o.name, o.value))
.collect::<Vec<_>>()
.join(", ");
format!("/{} {}", command_name, opt_str)
} else {
format!("/{}", command_name)
};
let metadata = DiscordMessageMetadata {
channel_id: channel_id.clone(),
interaction_id: interaction.id.clone(),
token: interaction.token.clone(),
application_id: interaction.application_id.clone(),
thread_id: None,
};
let metadata_json = match serde_json::to_string(&metadata) {
Ok(json) => json,
Err(e) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Failed to serialize metadata: {}", e),
);
// Attempt to notify user of internal error
let url = format!(
"https://discord.com/api/v10/webhooks/{}/{}",
interaction.application_id, interaction.token
);
let payload = serde_json::json!({
"content": "❌ Internal Error: Failed to process command metadata.",
"flags": 64 // Ephemeral
});
let _ = channel_host::http_request(
"POST",
&url,
&serde_json::json!({"Content-Type": "application/json"}).to_string(),
Some(&serde_json::to_vec(&payload).unwrap_or_default()),
None,
);
return;
}
};
channel_host::emit_message(&EmittedMessage {
user_id,
user_name: Some(user_name),
content,
thread_id: None,
metadata_json,
});
}
fn handle_message_component(interaction: &DiscordInteraction, message: &DiscordMessage) {
// Check member first (for server contexts), then user (for DMs)
let user = interaction
.member
.as_ref()
.map(|m| &m.user)
.or(interaction.user.as_ref());
let user_id = user.map(|u| u.id.clone()).unwrap_or_default();
let user_name = user
.map(|u| {
u.global_name
.as_ref()
.filter(|s| !s.is_empty())
.unwrap_or(&u.username)
.clone()
})
.unwrap_or_default();
let channel_id = message.channel_id.clone();
let metadata = DiscordMessageMetadata {
channel_id: channel_id.clone(),
interaction_id: interaction.id.clone(),
token: interaction.token.clone(),
application_id: interaction.application_id.clone(),
thread_id: None,
};
let metadata_json = match serde_json::to_string(&metadata) {
Ok(json) => json,
Err(e) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Failed to serialize metadata: {}", e),
);
return; // Don't emit message if metadata can't be serialized
}
};
channel_host::emit_message(&EmittedMessage {
user_id,
user_name: Some(user_name),
content: format!("[Button clicked] {}", message.content),
thread_id: None,
metadata_json,
});
}
fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse {
let body = serde_json::to_vec(&value).unwrap_or_default();
let headers = serde_json::json!({"Content-Type": "application/json"});
OutgoingHttpResponse {
status,
headers_json: headers.to_string(),
body,
}
}
export!(DiscordChannel);
fn truncate_message(content: &str) -> String {
if content.len() <= 2000 {
content.to_string()
} else {
let max_bytes = 1990;
let cutoff = content
.char_indices()
.map(|(i, c)| i + c.len_utf8())
.take_while(|&end| end <= max_bytes)
.last()
.unwrap_or(0);
let mut truncated = content[..cutoff].to_string();
truncated.push_str("\n... (truncated)");
truncated
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_truncate_message() {
let short = "Hello world";
assert_eq!(truncate_message(short), short);
let long = "a".repeat(2005);
let truncated = truncate_message(&long);
assert_eq!(truncated.len(), 2006); // 1990 + 16 chars suffix
assert!(truncated.ends_with("\n... (truncated)"));
// Test with multibyte characters (Euro sign is 3 bytes)
// 1000 chars * 3 bytes = 3000 bytes
let multi = "".repeat(1000);
let truncated_multi = truncate_message(&multi);
// 1990 bytes limit. 1990 / 3 = 663 with remainder 1.
// Should truncate at 663 chars (1989 bytes).
// Suffix is 16 bytes. Total: 1989 + 16 = 2005 bytes.
assert!(truncated_multi.len() <= 2006);
assert!(truncated_multi.len() >= 2006 - 4); // Allow for max utf8 char width variance
assert!(truncated_multi.ends_with("\n... (truncated)"));
let content_part = &truncated_multi[..truncated_multi.len() - 16];
assert!(content_part.chars().all(|c| c == '€'));
}
#[test]
fn test_metadata_serialization() {
let metadata = DiscordMessageMetadata {
channel_id: "123".into(),
interaction_id: "456".into(),
token: "abc".into(),
application_id: "789".into(),
thread_id: None,
};
let json = serde_json::to_string(&metadata).unwrap();
let parsed: DiscordMessageMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.channel_id, "123");
assert_eq!(parsed.interaction_id, "456");
}
}
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "github-tool"
version = "0.1.0"
edition = "2021"
description = "GitHub integration tool for IronClaw (WASM component)"
license = "MIT OR Apache-2.0"
publish = false
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
wit-bindgen = "0.41.0"
[lib]
crate-type = ["cdylib"]
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
+189
View File
@@ -0,0 +1,189 @@
# GitHub Tool for IronClaw
WASM tool for GitHub integration - manage repos, issues, PRs, and workflows.
## Features
- **Repository Info** - Get repo details, list user repos
- **Issues** - List, create, and get issue details
- **Pull Requests** - List PRs, get PR details, review files, create reviews
- **File Content** - Read files from repos
- **Workflows** - Trigger GitHub Actions, check run status
## Setup
1. Create a GitHub Personal Access Token at <https://github.com/settings/tokens>
2. Required scopes: `repo`, `workflow`, `read:org`
3. Store the token:
```
ironclaw secret set github_token YOUR_TOKEN
```
## Usage Examples
### Get Repository Info
```json
{
"action": "get_repo",
"owner": "nearai",
"repo": "ironclaw"
}
```
### List Open Issues
```json
{
"action": "list_issues",
"owner": "nearai",
"repo": "ironclaw",
"state": "open",
"limit": 10
}
```
### Create Issue
```json
{
"action": "create_issue",
"owner": "nearai",
"repo": "ironclaw",
"title": "Bug: Something is broken",
"body": "Detailed description...",
"labels": ["bug", "help wanted"]
}
```
### List Pull Requests
```json
{
"action": "list_pull_requests",
"owner": "nearai",
"repo": "ironclaw",
"state": "open",
"limit": 5
}
```
### Review PR
```json
{
"action": "create_pr_review",
"owner": "nearai",
"repo": "ironclaw",
"pr_number": 42,
"body": "LGTM! Great work.",
"event": "APPROVE"
}
```
### Get File Content
```json
{
"action": "get_file_content",
"owner": "nearai",
"repo": "ironclaw",
"path": "README.md",
"ref": "main"
}
```
### Trigger Workflow
```json
{
"action": "trigger_workflow",
"owner": "nearai",
"repo": "ironclaw",
"workflow_id": "ci.yml",
"ref": "main",
"inputs": {
"environment": "staging"
}
}
```
### Check Workflow Runs
```json
{
"action": "get_workflow_runs",
"owner": "nearai",
"repo": "ironclaw",
"limit": 5
}
```
### List Workflow Runs (Pagination)
```json
{
"action": "get_workflow_runs",
"owner": "nearai",
"repo": "ironclaw",
"limit": 5,
"page": 2
}
```
## Error Handling
Errors are returned as strings in the `error` field of the response.
### Rate Limit Exceeded
When the GitHub API rate limit is exceeded (and retries fail), you might see:
```text
GitHub API error 429: { "message": "API rate limit exceeded for user ID ...", ... }
```
The tool automatically logs warnings when the rate limit is low (<10 remaining) and retries on 429/5xx errors.
### Invalid Parameters
```text
Invalid event: 'INVALID'. Must be one of: APPROVE, REQUEST_CHANGES, COMMENT
```
### Missing Token
```text
GitHub token not found in secret store. Set it with: ironclaw secret set github_token <token>...
```
## Troubleshooting
### "GitHub API error 404: Not Found"
- Check that the `owner` and `repo` are correct.
- Ensure the `github_token` has access to the repository (especially for private repos).
- Verify the token scopes include `repo` and `read:org`.
### "GitHub API error 401: Bad credentials"
- The token might be invalid or expired.
- Update the token: `ironclaw secret set github_token NEW_TOKEN`.
### Rate Limiting
- The tool logs a warning when remaining requests drop below 10.
- Check logs for "GitHub API rate limit low".
- If you hit the limit, wait for the reset time (usually 1 hour).
## Building
```bash
cd tools-src/github
cargo build --target wasm32-wasi --release
```
## License
MIT/Apache-2.0
@@ -0,0 +1,41 @@
{
"capabilities": {
"http": {
"allowlist": [
{
"host": "api.github.com",
"path_prefix": "/",
"methods": [
"GET",
"POST"
]
}
],
"credentials": {
"github_token": {
"secret_name": "github_token",
"location": {
"type": "bearer"
},
"host_patterns": [
"api.github.com"
]
}
},
"rate_limit": {
"requests_per_minute": 60,
"requests_per_hour": 3600
}
},
"secrets": {
"allowed_names": [
"github_token",
"github_*"
]
}
},
"config": {
"default_limit": 30,
"max_limit": 100
}
}
+845
View File
@@ -0,0 +1,845 @@
//! GitHub WASM Tool for IronClaw.
//!
//! Provides GitHub integration for reading repos, managing issues,
//! reviewing PRs, and triggering workflows.
//!
//! # Authentication
//!
//! Store your GitHub Personal Access Token:
//! `ironclaw secret set github_token <token>`
//!
//! Token needs these permissions:
//! - repo (for private repos)
//! - workflow (for triggering actions)
//! - read:org (for org repos)
wit_bindgen::generate!({
world: "sandboxed-tool",
path: "../../wit/tool.wit",
});
use serde::Deserialize;
const MAX_TEXT_LENGTH: usize = 65536;
/// Validate input length to prevent oversized payloads.
fn validate_input_length(s: &str, field_name: &str) -> Result<(), String> {
if s.len() > MAX_TEXT_LENGTH {
return Err(format!(
"Input '{}' exceeds maximum length of {} characters",
field_name, MAX_TEXT_LENGTH
));
}
Ok(())
}
/// Percent-encode a string for safe use in URL path segments.
/// Encodes everything except alphanumeric, hyphen, underscore, and dot.
fn url_encode_path(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 2);
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' => {
out.push(b as char);
}
_ => {
out.push('%');
out.push(char::from(b"0123456789ABCDEF"[(b >> 4) as usize]));
out.push(char::from(b"0123456789ABCDEF"[(b & 0xf) as usize]));
}
}
}
out
}
/// Percent-encode a string for use as a URL query parameter value.
/// Currently identical to `url_encode_path`.
fn url_encode_query(s: &str) -> String {
url_encode_path(s)
}
/// Validate that a path segment doesn't contain dangerous characters.
/// Returns true if the segment is safe to use.
fn validate_path_segment(s: &str) -> bool {
!s.is_empty() && !s.contains('/') && !s.contains("..") && !s.contains('?') && !s.contains('#')
}
struct GitHubTool;
#[derive(Debug, Deserialize)]
#[serde(tag = "action")]
enum GitHubAction {
#[serde(rename = "get_repo")]
GetRepo { owner: String, repo: String },
#[serde(rename = "list_issues")]
ListIssues {
owner: String,
repo: String,
state: Option<String>,
page: Option<u32>,
limit: Option<u32>,
},
#[serde(rename = "create_issue")]
CreateIssue {
owner: String,
repo: String,
title: String,
body: Option<String>,
labels: Option<Vec<String>>,
},
#[serde(rename = "get_issue")]
GetIssue {
owner: String,
repo: String,
issue_number: u32,
},
#[serde(rename = "list_pull_requests")]
ListPullRequests {
owner: String,
repo: String,
state: Option<String>,
page: Option<u32>,
limit: Option<u32>,
},
#[serde(rename = "get_pull_request")]
GetPullRequest {
owner: String,
repo: String,
pr_number: u32,
},
#[serde(rename = "get_pull_request_files")]
GetPullRequestFiles {
owner: String,
repo: String,
pr_number: u32,
},
#[serde(rename = "create_pr_review")]
CreatePrReview {
owner: String,
repo: String,
pr_number: u32,
body: String,
event: String,
},
#[serde(rename = "list_repos")]
ListRepos {
username: String,
page: Option<u32>,
limit: Option<u32>,
},
#[serde(rename = "get_file_content")]
GetFileContent {
owner: String,
repo: String,
path: String,
r#ref: Option<String>,
},
#[serde(rename = "trigger_workflow")]
TriggerWorkflow {
owner: String,
repo: String,
workflow_id: String,
r#ref: String,
inputs: Option<serde_json::Value>,
},
#[serde(rename = "get_workflow_runs")]
GetWorkflowRuns {
owner: String,
repo: String,
workflow_id: Option<String>,
page: Option<u32>,
limit: Option<u32>,
},
}
impl exports::near::agent::tool::Guest for GitHubTool {
fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response {
match execute_inner(&req.params) {
Ok(result) => exports::near::agent::tool::Response {
output: Some(result),
error: None,
},
Err(e) => exports::near::agent::tool::Response {
output: None,
error: Some(e),
},
}
}
fn schema() -> String {
SCHEMA.to_string()
}
fn description() -> String {
"GitHub integration for managing repositories, issues, pull requests, \
and workflows. Supports reading repo info, listing/creating issues, \
reviewing PRs, and triggering GitHub Actions. \
Authentication is handled via the 'github_token' secret injected by the host."
.to_string()
}
}
fn execute_inner(params: &str) -> Result<String, String> {
let action: GitHubAction =
serde_json::from_str(params).map_err(|e| format!("Invalid parameters: {e}"))?;
// Pre-flight check: ensure token exists in secret store.
// We don't use the returned value because the host injects it into the request.
let _ = get_github_token()?;
match action {
GitHubAction::GetRepo { owner, repo } => get_repo(&owner, &repo),
GitHubAction::ListIssues {
owner,
repo,
state,
page,
limit,
} => list_issues(&owner, &repo, state.as_deref(), page, limit),
GitHubAction::CreateIssue {
owner,
repo,
title,
body,
labels,
} => create_issue(&owner, &repo, &title, body.as_deref(), labels),
GitHubAction::GetIssue {
owner,
repo,
issue_number,
} => get_issue(&owner, &repo, issue_number),
GitHubAction::ListPullRequests {
owner,
repo,
state,
page,
limit,
} => list_pull_requests(&owner, &repo, state.as_deref(), page, limit),
GitHubAction::GetPullRequest {
owner,
repo,
pr_number,
} => get_pull_request(&owner, &repo, pr_number),
GitHubAction::GetPullRequestFiles {
owner,
repo,
pr_number,
} => get_pull_request_files(&owner, &repo, pr_number),
GitHubAction::CreatePrReview {
owner,
repo,
pr_number,
body,
event,
} => create_pr_review(&owner, &repo, pr_number, &body, &event),
GitHubAction::ListRepos {
username,
page,
limit,
} => list_repos(&username, page, limit),
GitHubAction::GetFileContent {
owner,
repo,
path,
r#ref,
} => get_file_content(&owner, &repo, &path, r#ref.as_deref()),
GitHubAction::TriggerWorkflow {
owner,
repo,
workflow_id,
r#ref,
inputs,
} => trigger_workflow(&owner, &repo, &workflow_id, &r#ref, inputs),
GitHubAction::GetWorkflowRuns {
owner,
repo,
workflow_id,
page,
limit,
} => get_workflow_runs(&owner, &repo, workflow_id.as_deref(), page, limit),
}
}
fn get_github_token() -> Result<String, String> {
if near::agent::host::secret_exists("github_token") {
// Return dummy value since we only need to verify existence.
// The actual token is injected by the host.
return Ok("present".to_string());
}
Err("GitHub token not found in secret store. Set it with: ironclaw secret set github_token <token>. \
Token needs 'repo', 'workflow', and 'read:org' scopes.".into())
}
fn github_request(method: &str, path: &str, body: Option<String>) -> Result<String, String> {
let url = format!("https://api.github.com{}", path);
// Authorization header (Bearer <token>) is injected automatically by the host
// via the `http-wrapper` proxy based on the `github_token` secret.
let headers = serde_json::json!({
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "IronClaw-GitHub-Tool"
});
let body_bytes = body.map(|b| b.into_bytes());
// Simple retry logic for transient errors (max 3 attempts)
let max_retries = 3;
let mut attempt = 0;
loop {
attempt += 1;
let response = near::agent::host::http_request(
method,
&url,
&headers.to_string(),
body_bytes.as_deref(),
None,
);
match response {
Ok(resp) => {
// Log warning if rate limit is low
if let Ok(headers_json) =
serde_json::from_str::<serde_json::Value>(&resp.headers_json)
{
// Header keys are often lowercase in http libs, check case-insensitively if needed,
// but usually standard is lowercase/case-insensitive. Let's try lowercase.
if let Some(remaining) = headers_json
.get("x-ratelimit-remaining")
.and_then(|v| v.as_str())
{
if let Ok(count) = remaining.parse::<u32>() {
if count < 10 {
near::agent::host::log(
near::agent::host::LogLevel::Warn,
&format!("GitHub API rate limit low: {} remaining", count),
);
}
}
}
}
if resp.status >= 200 && resp.status < 300 {
return String::from_utf8(resp.body)
.map_err(|e| format!("Invalid UTF-8: {}", e));
} else if attempt < max_retries && (resp.status == 429 || resp.status >= 500) {
near::agent::host::log(
near::agent::host::LogLevel::Warn,
&format!(
"GitHub API error {} (attempt {}/{}). Retrying...",
resp.status, attempt, max_retries
),
);
// Minimal backoff simulation since we can't block easily in WASM without consuming generic budget?
// actually std::thread::sleep works in WASMtime if configured, but here we might just spin.
// ideally host exposes sleep. For now just retry immediately or rely on host timeout logic?
// Let's assume immediate retry for now as simple strategy.
continue;
} else {
let body_str = String::from_utf8_lossy(&resp.body);
return Err(format!("GitHub API error {}: {}", resp.status, body_str));
}
}
Err(e) => {
if attempt < max_retries {
near::agent::host::log(
near::agent::host::LogLevel::Warn,
&format!(
"HTTP request failed: {} (attempt {}/{}). Retrying...",
e, attempt, max_retries
),
);
continue;
}
return Err(format!(
"HTTP request failed after {} attempts: {}",
max_retries, e
));
}
}
}
}
// === API Functions ===
fn get_repo(owner: &str, repo: &str) -> Result<String, String> {
if !validate_path_segment(owner) || !validate_path_segment(repo) {
return Err("Invalid owner or repo name".into());
}
let encoded_owner = url_encode_path(owner);
let encoded_repo = url_encode_path(repo);
github_request(
"GET",
&format!("/repos/{}/{}", encoded_owner, encoded_repo),
None,
)
}
fn list_issues(
owner: &str,
repo: &str,
state: Option<&str>,
page: Option<u32>,
limit: Option<u32>,
) -> Result<String, String> {
if !validate_path_segment(owner) || !validate_path_segment(repo) {
return Err("Invalid owner or repo name".into());
}
let encoded_owner = url_encode_path(owner);
let encoded_repo = url_encode_path(repo);
let state = state.unwrap_or("open");
let limit = limit.unwrap_or(30).min(100); // Cap at 100
let encoded_state = url_encode_query(state);
let mut path = format!(
"/repos/{}/{}/issues?state={}&per_page={}",
encoded_owner, encoded_repo, encoded_state, limit
);
if let Some(p) = page {
path.push_str(&format!("&page={}", p));
}
github_request("GET", &path, None)
}
fn create_issue(
owner: &str,
repo: &str,
title: &str,
body: Option<&str>,
labels: Option<Vec<String>>,
) -> Result<String, String> {
if !validate_path_segment(owner) || !validate_path_segment(repo) {
return Err("Invalid owner or repo name".into());
}
validate_input_length(title, "title")?;
if let Some(b) = body {
validate_input_length(b, "body")?;
}
let encoded_owner = url_encode_path(owner);
let encoded_repo = url_encode_path(repo);
let path = format!("/repos/{}/{}/issues", encoded_owner, encoded_repo);
let mut req_body = serde_json::json!({
"title": title,
});
if let Some(body) = body {
req_body["body"] = serde_json::json!(body);
}
if let Some(labels) = labels {
req_body["labels"] = serde_json::json!(labels);
}
github_request("POST", &path, Some(req_body.to_string()))
}
fn get_issue(owner: &str, repo: &str, issue_number: u32) -> Result<String, String> {
if !validate_path_segment(owner) || !validate_path_segment(repo) {
return Err("Invalid owner or repo name".into());
}
let encoded_owner = url_encode_path(owner);
let encoded_repo = url_encode_path(repo);
github_request(
"GET",
&format!(
"/repos/{}/{}/issues/{}",
encoded_owner, encoded_repo, issue_number
),
None,
)
}
fn list_pull_requests(
owner: &str,
repo: &str,
state: Option<&str>,
page: Option<u32>,
limit: Option<u32>,
) -> Result<String, String> {
if !validate_path_segment(owner) || !validate_path_segment(repo) {
return Err("Invalid owner or repo name".into());
}
let encoded_owner = url_encode_path(owner);
let encoded_repo = url_encode_path(repo);
let state = state.unwrap_or("open");
let limit = limit.unwrap_or(30).min(100); // Cap at 100
let encoded_state = url_encode_query(state);
let mut path = format!(
"/repos/{}/{}/pulls?state={}&per_page={}",
encoded_owner, encoded_repo, encoded_state, limit
);
if let Some(p) = page {
path.push_str(&format!("&page={}", p));
}
github_request("GET", &path, None)
}
fn get_pull_request(owner: &str, repo: &str, pr_number: u32) -> Result<String, String> {
if !validate_path_segment(owner) || !validate_path_segment(repo) {
return Err("Invalid owner or repo name".into());
}
let encoded_owner = url_encode_path(owner);
let encoded_repo = url_encode_path(repo);
github_request(
"GET",
&format!(
"/repos/{}/{}/pulls/{}",
encoded_owner, encoded_repo, pr_number
),
None,
)
}
fn get_pull_request_files(owner: &str, repo: &str, pr_number: u32) -> Result<String, String> {
if !validate_path_segment(owner) || !validate_path_segment(repo) {
return Err("Invalid owner or repo name".into());
}
let encoded_owner = url_encode_path(owner);
let encoded_repo = url_encode_path(repo);
github_request(
"GET",
&format!(
"/repos/{}/{}/pulls/{}/files",
encoded_owner, encoded_repo, pr_number
),
None,
)
}
fn create_pr_review(
owner: &str,
repo: &str,
pr_number: u32,
body: &str,
event: &str,
) -> Result<String, String> {
if !validate_path_segment(owner) || !validate_path_segment(repo) {
return Err("Invalid owner or repo name".into());
}
validate_input_length(body, "body")?;
let valid_events = ["APPROVE", "REQUEST_CHANGES", "COMMENT"];
if !valid_events.contains(&event) {
return Err(format!(
"Invalid event: '{}'. Must be one of: {}",
event,
valid_events.join(", ")
));
}
let encoded_owner = url_encode_path(owner);
let encoded_repo = url_encode_path(repo);
let path = format!(
"/repos/{}/{}/pulls/{}/reviews",
encoded_owner, encoded_repo, pr_number
);
let req_body = serde_json::json!({
"body": body,
"event": event,
});
github_request("POST", &path, Some(req_body.to_string()))
}
fn list_repos(username: &str, page: Option<u32>, limit: Option<u32>) -> Result<String, String> {
if !validate_path_segment(username) {
return Err("Invalid username".into());
}
let encoded_username = url_encode_path(username);
let limit = limit.unwrap_or(30).min(100); // Cap at 100
let mut path = format!("/users/{}/repos?per_page={}", encoded_username, limit);
if let Some(p) = page {
path.push_str(&format!("&page={}", p));
}
github_request("GET", &path, None)
}
fn get_file_content(
owner: &str,
repo: &str,
path: &str,
r#ref: Option<&str>,
) -> Result<String, String> {
if !validate_path_segment(owner) || !validate_path_segment(repo) {
return Err("Invalid owner or repo name".into());
}
// Validate path segments - reject path traversal attempts and empty segments
for segment in path.split('/') {
if segment == ".." {
return Err("Invalid path: path traversal not allowed".into());
}
if segment.is_empty() {
return Err("Invalid path: empty segment not allowed".into());
}
}
// Validate ref if provided
if let Some(r#ref) = r#ref {
if r#ref.contains("..") || r#ref.contains(':') {
return Err("Invalid ref: must be a valid branch, tag, or commit SHA".into());
}
}
let encoded_owner = url_encode_path(owner);
let encoded_repo = url_encode_path(repo);
// Path can contain slashes, so we encode each segment separately
let encoded_path = path
.split('/')
.map(url_encode_path)
.collect::<Vec<_>>()
.join("/");
let url_path = if let Some(r#ref) = r#ref {
let encoded_ref = url_encode_query(r#ref);
format!(
"/repos/{}/{}/contents/{}?ref={}",
encoded_owner, encoded_repo, encoded_path, encoded_ref
)
} else {
format!(
"/repos/{}/{}/contents/{}",
encoded_owner, encoded_repo, encoded_path
)
};
github_request("GET", &url_path, None)
}
fn trigger_workflow(
owner: &str,
repo: &str,
workflow_id: &str,
r#ref: &str,
inputs: Option<serde_json::Value>,
) -> Result<String, String> {
if !validate_path_segment(owner) || !validate_path_segment(repo) {
return Err("Invalid owner or repo name".into());
}
// Validate inputs size if present
if let Some(valid_inputs) = &inputs {
let inputs_str = valid_inputs.to_string();
validate_input_length(&inputs_str, "inputs")?;
}
// Validate workflow_id - must be a safe filename
if workflow_id.contains('/') || workflow_id.contains("..") || workflow_id.contains(':') {
return Err("Invalid workflow_id: must be a filename or numeric ID".into());
}
// Validate ref - must be a valid git ref
if r#ref.contains("..") || r#ref.contains(':') {
return Err("Invalid ref: must be a valid branch, tag, or commit SHA".into());
}
let encoded_owner = url_encode_path(owner);
let encoded_repo = url_encode_path(repo);
let encoded_workflow_id = url_encode_path(workflow_id);
let path = format!(
"/repos/{}/{}/actions/workflows/{}/dispatches",
encoded_owner, encoded_repo, encoded_workflow_id
);
let mut req_body = serde_json::json!({
"ref": r#ref,
});
if let Some(inputs) = inputs {
req_body["inputs"] = inputs;
}
github_request("POST", &path, Some(req_body.to_string()))
}
fn get_workflow_runs(
owner: &str,
repo: &str,
workflow_id: Option<&str>,
page: Option<u32>,
limit: Option<u32>,
) -> Result<String, String> {
if !validate_path_segment(owner) || !validate_path_segment(repo) {
return Err("Invalid owner or repo name".into());
}
// Validate workflow_id if provided
if let Some(wid) = workflow_id {
if wid.contains('/') || wid.contains("..") || wid.contains(':') {
return Err("Invalid workflow_id: must be a filename or numeric ID".into());
}
}
let encoded_owner = url_encode_path(owner);
let encoded_repo = url_encode_path(repo);
let limit = limit.unwrap_or(30).min(100); // Cap at 100
let mut path = if let Some(workflow_id) = workflow_id {
let encoded_workflow_id = url_encode_path(workflow_id);
format!(
"/repos/{}/{}/actions/workflows/{}/runs?per_page={}",
encoded_owner, encoded_repo, encoded_workflow_id, limit
)
} else {
format!(
"/repos/{}/{}/actions/runs?per_page={}",
encoded_owner, encoded_repo, limit
)
};
if let Some(p) = page {
path.push_str(&format!("&page={}", p));
}
github_request("GET", &path, None)
}
const SCHEMA: &str = r#"{
"type": "object",
"required": ["action"],
"oneOf": [
{
"properties": {
"action": { "const": "get_repo" },
"owner": { "type": "string", "description": "Repository owner (user or org)" },
"repo": { "type": "string", "description": "Repository name" }
},
"required": ["action", "owner", "repo"]
},
{
"properties": {
"action": { "const": "list_issues" },
"owner": { "type": "string" },
"repo": { "type": "string" },
"state": { "type": "string", "enum": ["open", "closed", "all"], "default": "open" },
"limit": { "type": "integer", "default": 30 }
},
"required": ["action", "owner", "repo"]
},
{
"properties": {
"action": { "const": "create_issue" },
"owner": { "type": "string" },
"repo": { "type": "string" },
"title": { "type": "string" },
"body": { "type": "string" },
"labels": { "type": "array", "items": { "type": "string" } }
},
"required": ["action", "owner", "repo", "title"]
},
{
"properties": {
"action": { "const": "get_issue" },
"owner": { "type": "string" },
"repo": { "type": "string" },
"issue_number": { "type": "integer" }
},
"required": ["action", "owner", "repo", "issue_number"]
},
{
"properties": {
"action": { "const": "list_pull_requests" },
"owner": { "type": "string" },
"repo": { "type": "string" },
"state": { "type": "string", "enum": ["open", "closed", "all"], "default": "open" },
"limit": { "type": "integer", "default": 30 }
},
"required": ["action", "owner", "repo"]
},
{
"properties": {
"action": { "const": "get_pull_request" },
"owner": { "type": "string" },
"repo": { "type": "string" },
"pr_number": { "type": "integer" }
},
"required": ["action", "owner", "repo", "pr_number"]
},
{
"properties": {
"action": { "const": "get_pull_request_files" },
"owner": { "type": "string" },
"repo": { "type": "string" },
"pr_number": { "type": "integer" }
},
"required": ["action", "owner", "repo", "pr_number"]
},
{
"properties": {
"action": { "const": "create_pr_review" },
"owner": { "type": "string" },
"repo": { "type": "string" },
"pr_number": { "type": "integer" },
"body": { "type": "string", "description": "Review comment" },
"event": { "type": "string", "enum": ["APPROVE", "REQUEST_CHANGES", "COMMENT"] }
},
"required": ["action", "owner", "repo", "pr_number", "body", "event"]
},
{
"properties": {
"action": { "const": "list_repos" },
"username": { "type": "string" },
"limit": { "type": "integer", "default": 30 }
},
"required": ["action", "username"]
},
{
"properties": {
"action": { "const": "get_file_content" },
"owner": { "type": "string" },
"repo": { "type": "string" },
"path": { "type": "string", "description": "File path in repo" },
"ref": { "type": "string", "description": "Branch/commit (default: default branch)" }
},
"required": ["action", "owner", "repo", "path"]
},
{
"properties": {
"action": { "const": "trigger_workflow" },
"owner": { "type": "string" },
"repo": { "type": "string" },
"workflow_id": { "type": "string", "description": "Workflow filename or ID" },
"ref": { "type": "string", "description": "Branch to run on" },
"inputs": { "type": "object" }
},
"required": ["action", "owner", "repo", "workflow_id", "ref"]
},
{
"properties": {
"action": { "const": "get_workflow_runs" },
"owner": { "type": "string" },
"repo": { "type": "string" },
"workflow_id": { "type": "string" },
"limit": { "type": "integer", "default": 30 }
},
"required": ["action", "owner", "repo"]
}
]
}"#;
export!(GitHubTool);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_url_encode_path() {
assert_eq!(url_encode_path("foo-bar_123.baz"), "foo-bar_123.baz");
assert_eq!(url_encode_path("foo bar"), "foo%20bar");
assert_eq!(url_encode_path("foo/bar"), "foo%2Fbar");
}
#[test]
fn test_validate_path_segment() {
assert!(validate_path_segment("foo"));
assert!(!validate_path_segment(""));
assert!(!validate_path_segment("foo/bar"));
assert!(!validate_path_segment(".."));
// Empty segments are handled in get_file_content logic, not here
}
#[test]
fn test_validate_event_in_create_pr_review() {
let valid = ["APPROVE", "REQUEST_CHANGES", "COMMENT"];
// Ensure valid inputs are accepted
for event in valid {
assert!(valid.contains(&event));
}
}
#[test]
fn test_input_length_validation() {
assert!(validate_input_length("short", "test").is_ok());
let long = "a".repeat(MAX_TEXT_LENGTH + 1);
assert!(validate_input_length(&long, "test").is_err());
}
}