Compare commits

..

10 Commits

Author SHA1 Message Date
5d9c716a72 feat: add homelab configuration and transcription support
Some checks failed
CI / Detect Change Scope (push) Has been cancelled
CI / Format & Lint (push) Has been cancelled
CI / Lint Strict Delta (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (Smoke) (push) Has been cancelled
CI / Docs-Only Fast Path (push) Has been cancelled
CI / Non-Rust Fast Path (push) Has been cancelled
CI / Docs Quality (push) Has been cancelled
CI / CI Required Gate (push) Has been cancelled
Docker / PR Docker Smoke (push) Has been cancelled
Docker / Build and Push Docker Image (push) Has been cancelled
Rust Package Security Audit / Security Audit (push) Has been cancelled
Rust Package Security Audit / License & Supply Chain (push) Has been cancelled
- Add host-based delegate agents (ubuntu, grizzley, truenas, panda, pve)
- Add functional delegates (coder, reasoner, research, quick)
- Add NanoGPT provider with minimax-m2.5 model
- Add transcribe tool using faster-whisper
- Update TelegramChannel with workspace_dir
- Configure Z.AI as default provider with glm-5
2026-02-18 01:55:33 +00:00
Will Sarg
a2f29838b4 fix(build): restore ChannelMessage reply_target usage (#541) 2026-02-17 08:41:02 -05:00
Will Sarg
7ebc98d8d0 fix(ci): sync devsecops with main and repair auto-response workflow (#538)
* fix(workflows): standardize runner configuration for security jobs

* ci(actionlint): add Blacksmith runner label to config

Add blacksmith-2vcpu-ubuntu-2404 to actionlint self-hosted-runner labels config
to suppress "unknown label" warnings during workflow linting.

This label is used across all workflows after the Blacksmith migration.

* fix(actionlint): adjust indentation for self-hosted runner labels

* feat(security): enhance security workflow with CodeQL analysis steps

* fix(security): update CodeQL action to version 4 for improved analysis

* fix(security): remove duplicate permissions in security workflow

* fix(security): revert CodeQL action to v3 for stability

The v4 version was causing workflow file validation failures.
Reverting to proven v3 version that is working on main branch.

* fix(security): remove duplicate permissions causing workflow validation failure

The permissions block had duplicate security-events and actions keys,
which caused YAML validation errors and prevented workflow execution.

Fixes: workflow file validation failures on main branch

* fix(security): remove pull_request trigger to reduce costs

* fix(security): restore PR trigger but skip codeql on PRs

* fix(security): resolve YAML syntax error in security workflow

* refactor(security): split CodeQL into dedicated scheduled workflow

* fix(security): update workflow name to Rust Package Security Audit

* fix(codeql): remove push trigger, keep schedule and on-demand only

* feat(codeql): add CodeQL configuration file to ignore specific paths

* Potential fix for code scanning alert no. 39: Hard-coded cryptographic value

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* fix(ci): resolve auto-response workflow merge markers

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-02-17 08:34:09 -05:00
Chummy
a35d1e37c8 chore(labeler): normalize module labels and backfill contributor tiers (#462)
Co-authored-by: Will Sarg <12886992+willsarg@users.noreply.github.com>
2026-02-17 08:25:50 -05:00
Vernon Stinebaker
df31359ec4 feat(agent): scrub credentials from tool output (#532)
* feat(channels): add channel capabilities to system prompt

Add channel capabilities section to system prompt so the agent knows
it can send Discord messages directly without asking permission.
Also reminds agent not to repeat or echo credentials.

Co-authored-by: Vernon Stinebaker <vernon.stinebaker@gmail.com>

* feat(agent): scrub credentials from tool output

* chore: fix clippy and formatting for scrubbing
2026-02-17 08:23:11 -05:00
beee003
8ad5b6146b feat: add Astrai as a named provider (#486)
Add Astrai (https://as-trai.com) as a first-class OpenAI-compatible
provider. Astrai is an AI inference router with built-in cost
optimization, PII stripping, and compliance logging.

- Register ASTRAI_API_KEY env var in resolve_api_key
- Add "astrai" entry in provider factory → as-trai.com/v1
- Add factory_astrai unit test
- Add Astrai to compatible provider test list
- Update README provider count (22+ → 23+) and list

Co-authored-by: Maya Walcher <maya.walcher@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 08:22:38 -05:00
ehu shubham shaw
d7c1fd7bf8 security(deps): remove vulnerable xmas-elf dependency via embuild (#414)
* security(deps): remove vulnerable xmas-elf dependency via embuild

* chore(deps): update dependencies and improve ESP-IDF compatibility

- Updated `bindgen`, `embassy-sync`, `embedded-svc`, and `embuild` versions in `Cargo.lock`.
- Added patch section in `Cargo.toml` to use latest esp-rs crates for better compatibility with ESP-IDF 5.x.
- Enhanced README with updated prerequisites and build instructions for Python and Rust tools.
- Introduced `rust-toolchain.toml` to pin nightly Rust and added necessary components.
- Modified GPIO handling in `main.rs` to improve pin management and added support for 64-bit time_t in ESP-IDF.
- Updated `.cargo/config.toml` for new linker and runner configurations.

* docs: add detailed setup guide for ESP32 firmware and link in README

- Introduced a new `SETUP.md` file with comprehensive step-by-step instructions for building and flashing the ZeroClaw ESP32 firmware.
- Updated `README.md` to include a link to the new setup guide for easier access to installation and troubleshooting information.

* chore: update .gitignore and refactor main.rs for improved readability

- Added .embuild/ to .gitignore to exclude ESP32 build cache.
- Refactored code in main.rs for better readability by adjusting the formatting of the handle_request function call.

* docs: add newline for better readability in README.md

- Added a newline in the protocol section of README.md to enhance clarity and formatting.

* chore: configure workspace settings in Cargo.toml

- Added workspace configuration to `Cargo.toml` with members and resolver settings for improved project management.

---------

Co-authored-by: ehushubhamshaw <eshaw1@wpi.edu>
Co-authored-by: Will Sarg <12886992+willsarg@users.noreply.github.com>
2026-02-17 08:18:41 -05:00
fettpl
55b3c2c00c test(security): add HTTP hostname canonicalization edge-case tests (#522)
* test(security): add HTTP hostname canonicalization edge-case tests

Document that Rust's IpAddr::parse() rejects non-standard IP notations
(octal, hex, decimal integer, zero-padded) which provides defense-in-depth
against SSRF bypass attempts. Tests only — no production code changes.

Closes #515

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: apply rustfmt to providers/mod.rs

Fix pre-existing formatting issue from main.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 08:16:00 -05:00
fettpl
e3f00e82b9 fix(ci): add pull-requests write permission to contributor-tier-issues job (#501)
The contributor-tier-issues job triggers on pull_request_target events
but only had issues:write permission. GitHub API requires
pull-requests:write to set labels on pull requests, causing a 403
"Resource not accessible by integration" error.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 08:14:41 -05:00
Rin
9ec1106f53 security: fix argument injection in shell command validation (#465) 2026-02-17 08:11:20 -05:00
32 changed files with 1135 additions and 130 deletions

View File

@@ -12,7 +12,11 @@ Describe this PR in 2-5 bullets:
- Risk label (`risk: low|medium|high`):
- Size label (`size: XS|S|M|L|XL`, auto-managed/read-only):
- Scope labels (`core|agent|channel|config|cron|daemon|doctor|gateway|health|heartbeat|integration|memory|observability|onboard|provider|runtime|security|service|skillforge|skills|tool|tunnel|docs|dependencies|ci|tests|scripts|dev`, comma-separated):
<<<<<<< chore/labeler-spacing-trusted-tier
- Module labels (`<module>: <component>`, for example `channel: telegram`, `provider: kimi`, `tool: shell`):
=======
- Module labels (`<module>:<component>`, for example `channel:telegram`, `provider:kimi`, `tool:shell`):
>>>>>>> main
- Contributor tier label (`trusted contributor|experienced contributor|principal contributor|distinguished contributor`, auto-managed/read-only; author merged PRs >=5/10/20/50):
- If any auto-label is incorrect, note requested correction:

View File

@@ -18,6 +18,7 @@ jobs:
runs-on: blacksmith-2vcpu-ubuntu-2404
permissions:
issues: write
pull-requests: write
steps:
- name: Apply contributor tier label for issue author
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8

View File

@@ -325,13 +325,18 @@ jobs:
return pattern.test(text);
}
function formatModuleLabel(prefix, segment) {
return `${prefix}: ${segment}`;
}
function parseModuleLabel(label) {
const separatorIndex = label.indexOf(":");
if (separatorIndex <= 0 || separatorIndex >= label.length - 1) return null;
return {
prefix: label.slice(0, separatorIndex),
segment: label.slice(separatorIndex + 1),
};
if (typeof label !== "string") return null;
const match = label.match(/^([^:]+):\s*(.+)$/);
if (!match) return null;
const prefix = match[1].trim().toLowerCase();
const segment = (match[2] || "").trim().toLowerCase();
if (!prefix || !segment) return null;
return { prefix, segment };
}
function sortByPriority(labels, priorityIndex) {
@@ -389,7 +394,7 @@ jobs:
for (const [prefix, segments] of segmentsByPrefix) {
const hasSpecificSegment = [...segments].some((segment) => segment !== "core");
if (hasSpecificSegment) {
refined.delete(`${prefix}:core`);
refined.delete(formatModuleLabel(prefix, "core"));
}
}
@@ -418,7 +423,7 @@ jobs:
if (uniqueSegments.length === 0) continue;
if (uniqueSegments.length === 1) {
compactedModuleLabels.add(`${prefix}:${uniqueSegments[0]}`);
compactedModuleLabels.add(formatModuleLabel(prefix, uniqueSegments[0]));
} else {
forcePathPrefixes.add(prefix);
}
@@ -609,7 +614,7 @@ jobs:
segment = normalizeLabelSegment(segment);
if (!segment) continue;
detectedModuleLabels.add(`${rule.prefix}:${segment}`);
detectedModuleLabels.add(formatModuleLabel(rule.prefix, segment));
}
}
@@ -635,7 +640,7 @@ jobs:
for (const keyword of providerKeywordHints) {
if (containsKeyword(searchableText, keyword)) {
detectedModuleLabels.add(`provider:${keyword}`);
detectedModuleLabels.add(formatModuleLabel("provider", keyword));
}
}
}
@@ -661,7 +666,7 @@ jobs:
for (const keyword of channelKeywordHints) {
if (containsKeyword(searchableText, keyword)) {
detectedModuleLabels.add(`channel:${keyword}`);
detectedModuleLabels.add(formatModuleLabel("channel", keyword));
}
}
}

9
.gitignore vendored
View File

@@ -10,6 +10,15 @@ docker-compose.override.yml
# Environment files (may contain secrets)
.env
# Python virtual environments
.venv/
venv/
# ESP32 build cache (esp-idf-sys managed)
.embuild/
.env.local
.env.*.local

1
Cargo.lock generated
View File

@@ -4927,6 +4927,7 @@ dependencies = [
"prometheus",
"prost",
"rand 0.8.5",
"regex",
"reqwest",
"rppal",
"rusqlite",

View File

@@ -1,3 +1,7 @@
[workspace]
members = ["."]
resolver = "2"
[package]
name = "zeroclaw"
version = "0.1.0"
@@ -86,6 +90,7 @@ glob = "0.3"
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
futures-util = { version = "0.3", default-features = false, features = ["sink"] }
futures = "0.3"
regex = "1.10"
hostname = "0.4.2"
lettre = { version = "0.11.19", default-features = false, features = ["builder", "smtp-transport", "rustls-tls"] }
mail-parser = "0.11.2"

View File

@@ -18,7 +18,7 @@
Fast, small, and fully autonomous AI assistant infrastructure — deploy anywhere, swap anything.
```
~3.4MB binary · <10ms startup · 1,017 tests · 22+ providers · 8 traits · Pluggable everything
~3.4MB binary · <10ms startup · 1,017 tests · 23+ providers · 8 traits · Pluggable everything
```
### ✨ Features
@@ -191,7 +191,7 @@ Every subsystem is a **trait** — swap implementations with a config change, ze
| Subsystem | Trait | Ships with | Extend |
|-----------|-------|------------|--------|
| **AI Models** | `Provider` | 22+ providers (OpenRouter, Anthropic, OpenAI, Ollama, Venice, Groq, Mistral, xAI, DeepSeek, Together, Fireworks, Perplexity, Cohere, Bedrock, etc.) | `custom:https://your-api.com` — any OpenAI-compatible API |
| **AI Models** | `Provider` | 23+ providers (OpenRouter, Anthropic, OpenAI, Ollama, Venice, Groq, Mistral, xAI, DeepSeek, Together, Fireworks, Perplexity, Cohere, Bedrock, Astrai, etc.) | `custom:https://your-api.com` — any OpenAI-compatible API |
| **Channels** | `Channel` | CLI, Telegram, Discord, Slack, iMessage, Matrix, WhatsApp, Webhook | Any messaging API |
| **Memory** | `Memory` | SQLite with hybrid search (FTS5 + vector cosine similarity), Lucid bridge (CLI sync + SQLite fallback), Markdown | Any persistence backend |
| **Tools** | `Tool` | shell, file_read, file_write, memory_store, memory_recall, memory_forget, browser_open (Brave + allowlist), browser (agent-browser / rust-native), composio (optional) | Any capability |

View File

@@ -2,4 +2,10 @@
target = "riscv32imc-esp-espidf"
[target.riscv32imc-esp-espidf]
linker = "ldproxy"
runner = "espflash flash --monitor"
# ESP-IDF 5.x uses 64-bit time_t
rustflags = ["-C", "default-linker-libraries", "--cfg", "espidf_time64"]
[unstable]
build-std = ["std", "panic_abort"]

View File

@@ -58,24 +58,22 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "bindgen"
version = "0.63.0"
version = "0.71.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36d860121800b2a9a94f9b5604b332d5cffb234ce17609ea479d723dbc9d3885"
checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3"
dependencies = [
"bitflags 1.3.2",
"bitflags 2.11.0",
"cexpr",
"clang-sys",
"lazy_static",
"lazycell",
"itertools",
"log",
"peeking_take_while",
"prettyplease",
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"shlex",
"syn 1.0.109",
"which",
"syn 2.0.116",
]
[[package]]
@@ -374,14 +372,15 @@ checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01"
[[package]]
name = "embassy-sync"
version = "0.5.0"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd938f25c0798db4280fcd8026bf4c2f48789aebf8f77b6e5cf8a7693ba114ec"
checksum = "73974a3edbd0bd286759b3d483540f0ebef705919a5f56f4fc7709066f71689b"
dependencies = [
"cfg-if",
"critical-section",
"embedded-io-async",
"futures-util",
"futures-core",
"futures-sink",
"heapless",
]
@@ -446,16 +445,15 @@ dependencies = [
[[package]]
name = "embedded-svc"
version = "0.27.1"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac6f87e7654f28018340aa55f933803017aefabaa5417820a3b2f808033c7bbc"
checksum = "a7770e30ab55cfbf954c00019522490d6ce26a3334bede05a732ba61010e98e0"
dependencies = [
"defmt 0.3.100",
"embedded-io",
"embedded-io-async",
"enumset",
"heapless",
"no-std-net",
"num_enum",
"serde",
"strum 0.25.0",
@@ -463,9 +461,9 @@ dependencies = [
[[package]]
name = "embuild"
version = "0.31.4"
version = "0.33.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4caa4f198bb9152a55c0103efb83fa4edfcbb8625f4c9e94ae8ec8e23827c563"
checksum = "e188ad2bbe82afa841ea4a29880651e53ab86815db036b2cb9f8de3ac32dad75"
dependencies = [
"anyhow",
"bindgen",
@@ -475,6 +473,7 @@ dependencies = [
"globwalk",
"home",
"log",
"regex",
"remove_dir_all",
"serde",
"serde_json",
@@ -533,9 +532,8 @@ dependencies = [
[[package]]
name = "esp-idf-hal"
version = "0.43.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7adf3fb19a9ca016cbea1ab8a7b852ac69df8fcde4923c23d3b155efbc42a74"
version = "0.45.2"
source = "git+https://github.com/esp-rs/esp-idf-hal#bc48639bd626c72afc1e25e5d497b5c639161d30"
dependencies = [
"atomic-waker",
"embassy-sync",
@@ -552,14 +550,12 @@ dependencies = [
"heapless",
"log",
"nb 1.1.0",
"num_enum",
]
[[package]]
name = "esp-idf-svc"
version = "0.48.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2180642ca122a7fec1ec417a9b1a77aa66aaa067fdf1daae683dd8caba84f26b"
version = "0.51.0"
source = "git+https://github.com/esp-rs/esp-idf-svc#dee202f146c7681e54eabbf118a216fc0195d203"
dependencies = [
"embassy-futures",
"embedded-hal-async",
@@ -567,6 +563,7 @@ dependencies = [
"embuild",
"enumset",
"esp-idf-hal",
"futures-io",
"heapless",
"log",
"num_enum",
@@ -575,14 +572,13 @@ dependencies = [
[[package]]
name = "esp-idf-sys"
version = "0.34.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e148f97c04ed3e9181a08bcdc9560a515aad939b0ba7f50a0022e294665e0af"
version = "0.36.1"
source = "git+https://github.com/esp-rs/esp-idf-sys#64667a38fb8004e1fc3b032488af6857ca3cd849"
dependencies = [
"anyhow",
"bindgen",
"build-time",
"cargo_metadata",
"cmake",
"const_format",
"embuild",
"envy",
@@ -649,21 +645,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-task"
name = "futures-io"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-util"
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
]
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "getrandom"
@@ -827,6 +818,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.17"
@@ -843,18 +843,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "lazycell"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
[[package]]
name = "leb128fmt"
version = "0.1.0"
@@ -945,12 +933,6 @@ dependencies = [
"libc",
]
[[package]]
name = "no-std-net"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bcece43b12349917e096cddfa66107277f123e6c96a5aea78711dc601a47152"
[[package]]
name = "nom"
version = "7.1.3"
@@ -1007,18 +989,6 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "peeking_take_while"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099"
[[package]]
name = "pin-project-lite"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -1138,9 +1108,9 @@ dependencies = [
[[package]]
name = "rustc-hash"
version = "1.1.0"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustix"

View File

@@ -14,15 +14,21 @@ edition = "2021"
license = "MIT"
description = "ZeroClaw ESP32 peripheral firmware — GPIO over JSON serial"
[patch.crates-io]
# Use latest esp-rs crates to fix u8/i8 char pointer compatibility with ESP-IDF 5.x
esp-idf-sys = { git = "https://github.com/esp-rs/esp-idf-sys" }
esp-idf-hal = { git = "https://github.com/esp-rs/esp-idf-hal" }
esp-idf-svc = { git = "https://github.com/esp-rs/esp-idf-svc" }
[dependencies]
esp-idf-svc = "0.48"
esp-idf-svc = { git = "https://github.com/esp-rs/esp-idf-svc" }
log = "0.4"
anyhow = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
[build-dependencies]
embuild = "0.31"
embuild = { version = "0.33", features = ["espidf"] }
[profile.release]
opt-level = "s"

View File

@@ -2,8 +2,11 @@
Peripheral firmware for ESP32 — speaks the same JSON-over-serial protocol as the STM32 firmware. Flash this to your ESP32, then configure ZeroClaw on the host to connect via serial.
**New to this?** See [SETUP.md](SETUP.md) for step-by-step commands and troubleshooting.
## Protocol
- **Request** (host → ESP32): `{"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}}\n`
- **Response** (ESP32 → host): `{"id":"1","ok":true,"result":"done"}\n`
@@ -11,19 +14,44 @@ Commands: `gpio_read`, `gpio_write`.
## Prerequisites
1. **ESP toolchain** (espup):
1. **RISC-V ESP-IDF** (ESP32-C2/C3): Uses nightly Rust with `build-std`.
**Python**: ESP-IDF requires Python 3.103.13 (not 3.14). If you have Python 3.14:
```sh
brew install python@3.12
```
**virtualenv** (needed by ESP-IDF tools; PEP 668 workaround on macOS):
```sh
/opt/homebrew/opt/python@3.12/bin/python3.12 -m pip install virtualenv --break-system-packages
```
**Rust tools**:
```sh
cargo install espflash ldproxy
```
The project's `rust-toolchain.toml` pins nightly + rust-src. `esp-idf-sys` downloads ESP-IDF automatically on first build. Use Python 3.12 for the build:
```sh
export PATH="/opt/homebrew/opt/python@3.12/libexec/bin:$PATH"
```
2. **Xtensa targets** (ESP32, ESP32-S2, ESP32-S3): Use espup instead:
```sh
cargo install espup espflash
espup install
source ~/export-esp.sh # or ~/export-esp.fish for Fish
source ~/export-esp.sh
```
2. **Target**: ESP32-C3 (RISC-V) by default. Edit `.cargo/config.toml` for other targets (e.g. `xtensa-esp32-espidf` for original ESP32).
Then edit `.cargo/config.toml` to change the target (e.g. `xtensa-esp32-espidf`).
## Build & Flash
```sh
cd firmware/zeroclaw-esp32
# Use Python 3.12 (required if you have 3.14)
export PATH="/opt/homebrew/opt/python@3.12/libexec/bin:$PATH"
# Optional: pin MCU (esp32c3 or esp32c2)
export MCU=esp32c3
cargo build --release
espflash flash target/riscv32imc-esp-espidf/release/zeroclaw-esp32 --monitor
```

View File

@@ -0,0 +1,156 @@
# ESP32 Firmware Setup Guide
Step-by-step setup for building the ZeroClaw ESP32 firmware. Follow this if you run into issues.
## Quick Start (copy-paste)
```sh
# 1. Install Python 3.12 (ESP-IDF needs 3.103.13, not 3.14)
brew install python@3.12
# 2. Install virtualenv (PEP 668 workaround on macOS)
/opt/homebrew/opt/python@3.12/bin/python3.12 -m pip install virtualenv --break-system-packages
# 3. Install Rust tools
cargo install espflash ldproxy
# 4. Build
cd firmware/zeroclaw-esp32
export PATH="/opt/homebrew/opt/python@3.12/libexec/bin:$PATH"
cargo build --release
# 5. Flash (connect ESP32 via USB)
espflash flash target/riscv32imc-esp-espidf/release/zeroclaw-esp32 --monitor
```
---
## Detailed Steps
### 1. Python
ESP-IDF requires Python 3.103.13. **Python 3.14 is not supported.**
```sh
brew install python@3.12
```
### 2. virtualenv
ESP-IDF tools need `virtualenv`. On macOS with Homebrew Python, PEP 668 blocks `pip install`; use:
```sh
/opt/homebrew/opt/python@3.12/bin/python3.12 -m pip install virtualenv --break-system-packages
```
### 3. Rust Tools
```sh
cargo install espflash ldproxy
```
- **espflash**: flash and monitor
- **ldproxy**: linker for ESP-IDF builds
### 4. Use Python 3.12 for Builds
Before every build (or add to `~/.zshrc`):
```sh
export PATH="/opt/homebrew/opt/python@3.12/libexec/bin:$PATH"
```
### 5. Build
```sh
cd firmware/zeroclaw-esp32
cargo build --release
```
First build downloads and compiles ESP-IDF (~515 min).
### 6. Flash
```sh
espflash flash target/riscv32imc-esp-espidf/release/zeroclaw-esp32 --monitor
```
---
## Troubleshooting
### "No space left on device"
Free disk space. Common targets:
```sh
# Cargo cache (often 520 GB)
rm -rf ~/.cargo/registry/cache ~/.cargo/registry/src
# Unused Rust toolchains
rustup toolchain list
rustup toolchain uninstall <name>
# iOS Simulator runtimes (~35 GB)
xcrun simctl delete unavailable
# Temp files
rm -rf /var/folders/*/T/cargo-install*
```
### "can't find crate for `core`" / "riscv32imc-esp-espidf target may not be installed"
This project uses **nightly Rust with build-std**, not espup. Ensure:
- `rust-toolchain.toml` exists (pins nightly + rust-src)
- You are **not** sourcing `~/export-esp.sh` (that's for Xtensa targets)
- Run `cargo build` from `firmware/zeroclaw-esp32`
### "externally-managed-environment" / "No module named 'virtualenv'"
Install virtualenv with the PEP 668 workaround:
```sh
/opt/homebrew/opt/python@3.12/bin/python3.12 -m pip install virtualenv --break-system-packages
```
### "expected `i64`, found `i32`" (time_t mismatch)
Already fixed in `.cargo/config.toml` with `espidf_time64` for ESP-IDF 5.x. If you use ESP-IDF 4.4, switch to `espidf_time32`.
### "expected `*const u8`, found `*const i8`" (esp-idf-svc)
Already fixed via `[patch.crates-io]` in `Cargo.toml` using esp-rs crates from git. Do not remove the patch.
### 10,000+ files in `git status`
The `.embuild/` directory (ESP-IDF cache) has ~100k+ files. It is in `.gitignore`. If you see them, ensure `.gitignore` contains:
```
.embuild/
```
---
## Optional: Auto-load Python 3.12
Add to `~/.zshrc`:
```sh
# ESP32 firmware build
export PATH="/opt/homebrew/opt/python@3.12/libexec/bin:$PATH"
```
---
## Xtensa Targets (ESP32, ESP32-S2, ESP32-S3)
For nonRISC-V chips, use espup instead:
```sh
cargo install espup espflash
espup install
source ~/export-esp.sh
```
Then edit `.cargo/config.toml` to use `xtensa-esp32-espidf` (or the correct target).

View File

@@ -0,0 +1,3 @@
[toolchain]
channel = "nightly"
components = ["rust-src"]

View File

@@ -6,8 +6,9 @@
//! Protocol: same as STM32 — see docs/hardware-peripherals-design.md
use esp_idf_svc::hal::gpio::PinDriver;
use esp_idf_svc::hal::prelude::*;
use esp_idf_svc::hal::uart::*;
use esp_idf_svc::hal::peripherals::Peripherals;
use esp_idf_svc::hal::uart::{UartConfig, UartDriver};
use esp_idf_svc::hal::units::Hertz;
use log::info;
use serde::{Deserialize, Serialize};
@@ -36,9 +37,13 @@ fn main() -> anyhow::Result<()> {
let peripherals = Peripherals::take()?;
let pins = peripherals.pins;
// Create GPIO output drivers first (they take ownership of pins)
let mut gpio2 = PinDriver::output(pins.gpio2)?;
let mut gpio13 = PinDriver::output(pins.gpio13)?;
// UART0: TX=21, RX=20 (ESP32) — ESP32-C3 may use different pins; adjust for your board
let config = UartConfig::new().baudrate(Hertz(115_200));
let mut uart = UartDriver::new(
let uart = UartDriver::new(
peripherals.uart0,
pins.gpio21,
pins.gpio20,
@@ -60,7 +65,8 @@ fn main() -> anyhow::Result<()> {
if b == b'\n' {
if !line.is_empty() {
if let Ok(line_str) = std::str::from_utf8(&line) {
if let Ok(resp) = handle_request(line_str, &peripherals) {
if let Ok(resp) = handle_request(line_str, &mut gpio2, &mut gpio13)
{
let out = serde_json::to_string(&resp).unwrap_or_default();
let _ = uart.write(format!("{}\n", out).as_bytes());
}
@@ -80,10 +86,15 @@ fn main() -> anyhow::Result<()> {
}
}
fn handle_request(
fn handle_request<G2, G13>(
line: &str,
peripherals: &esp_idf_svc::hal::peripherals::Peripherals,
) -> anyhow::Result<Response> {
gpio2: &mut PinDriver<'_, G2>,
gpio13: &mut PinDriver<'_, G13>,
) -> anyhow::Result<Response>
where
G2: esp_idf_svc::hal::gpio::OutputMode,
G13: esp_idf_svc::hal::gpio::OutputMode,
{
let req: Request = serde_json::from_str(line.trim())?;
let id = req.id.clone();
@@ -98,13 +109,13 @@ fn handle_request(
}
"gpio_read" => {
let pin_num = req.args.get("pin").and_then(|v| v.as_u64()).unwrap_or(0) as i32;
let value = gpio_read(peripherals, pin_num)?;
let value = gpio_read(pin_num)?;
Ok(value.to_string())
}
"gpio_write" => {
let pin_num = req.args.get("pin").and_then(|v| v.as_u64()).unwrap_or(0) as i32;
let value = req.args.get("value").and_then(|v| v.as_u64()).unwrap_or(0);
gpio_write(peripherals, pin_num, value)?;
gpio_write(gpio2, gpio13, pin_num, value)?;
Ok("done".into())
}
_ => Err(anyhow::anyhow!("Unknown command: {}", req.cmd)),
@@ -126,28 +137,26 @@ fn handle_request(
}
}
fn gpio_read(_peripherals: &esp_idf_svc::hal::peripherals::Peripherals, _pin: i32) -> anyhow::Result<u8> {
fn gpio_read(_pin: i32) -> anyhow::Result<u8> {
// TODO: implement input pin read — requires storing InputPin drivers per pin
Ok(0)
}
fn gpio_write(
peripherals: &esp_idf_svc::hal::peripherals::Peripherals,
fn gpio_write<G2, G13>(
gpio2: &mut PinDriver<'_, G2>,
gpio13: &mut PinDriver<'_, G13>,
pin: i32,
value: u64,
) -> anyhow::Result<()> {
let pins = peripherals.pins;
let level = value != 0;
) -> anyhow::Result<()>
where
G2: esp_idf_svc::hal::gpio::OutputMode,
G13: esp_idf_svc::hal::gpio::OutputMode,
{
let level = esp_idf_svc::hal::gpio::Level::from(value != 0);
match pin {
2 => {
let mut out = PinDriver::output(pins.gpio2)?;
out.set_level(esp_idf_svc::hal::gpio::Level::from(level))?;
}
13 => {
let mut out = PinDriver::output(pins.gpio13)?;
out.set_level(esp_idf_svc::hal::gpio::Level::from(level))?;
}
2 => gpio2.set_level(level)?,
13 => gpio13.set_level(level)?,
_ => anyhow::bail!("Pin {} not configured (add to gpio_write)", pin),
}
Ok(())

View File

@@ -0,0 +1,324 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_NAME="$(basename "$0")"
usage() {
cat <<USAGE
Recompute contributor tier labels for historical PRs/issues.
Usage:
./$SCRIPT_NAME [options]
Options:
--repo <owner/repo> Target repository (default: current gh repo)
--kind <both|prs|issues>
Target objects (default: both)
--state <all|open|closed>
State filter for listing objects (default: all)
--limit <N> Limit processed objects after fetch (default: 0 = no limit)
--apply Apply label updates (default is dry-run)
--dry-run Preview only (default)
-h, --help Show this help
Examples:
./$SCRIPT_NAME --repo zeroclaw-labs/zeroclaw --limit 50
./$SCRIPT_NAME --repo zeroclaw-labs/zeroclaw --kind prs --state open --apply
USAGE
}
die() {
echo "[$SCRIPT_NAME] ERROR: $*" >&2
exit 1
}
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
die "Required command not found: $1"
fi
}
urlencode() {
jq -nr --arg value "$1" '$value|@uri'
}
select_contributor_tier() {
local merged_count="$1"
if (( merged_count >= 50 )); then
echo "distinguished contributor"
elif (( merged_count >= 20 )); then
echo "principal contributor"
elif (( merged_count >= 10 )); then
echo "experienced contributor"
elif (( merged_count >= 5 )); then
echo "trusted contributor"
else
echo ""
fi
}
DRY_RUN=1
KIND="both"
STATE="all"
LIMIT=0
REPO=""
while (($# > 0)); do
case "$1" in
--repo)
[[ $# -ge 2 ]] || die "Missing value for --repo"
REPO="$2"
shift 2
;;
--kind)
[[ $# -ge 2 ]] || die "Missing value for --kind"
KIND="$2"
shift 2
;;
--state)
[[ $# -ge 2 ]] || die "Missing value for --state"
STATE="$2"
shift 2
;;
--limit)
[[ $# -ge 2 ]] || die "Missing value for --limit"
LIMIT="$2"
shift 2
;;
--apply)
DRY_RUN=0
shift
;;
--dry-run)
DRY_RUN=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
die "Unknown option: $1"
;;
esac
done
case "$KIND" in
both|prs|issues) ;;
*) die "--kind must be one of: both, prs, issues" ;;
esac
case "$STATE" in
all|open|closed) ;;
*) die "--state must be one of: all, open, closed" ;;
esac
if ! [[ "$LIMIT" =~ ^[0-9]+$ ]]; then
die "--limit must be a non-negative integer"
fi
require_cmd gh
require_cmd jq
if ! gh auth status >/dev/null 2>&1; then
die "gh CLI is not authenticated. Run: gh auth login"
fi
if [[ -z "$REPO" ]]; then
REPO="$(gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>/dev/null || true)"
[[ -n "$REPO" ]] || die "Unable to infer repo. Pass --repo <owner/repo>."
fi
echo "[$SCRIPT_NAME] Repo: $REPO"
echo "[$SCRIPT_NAME] Mode: $([[ "$DRY_RUN" -eq 1 ]] && echo "dry-run" || echo "apply")"
echo "[$SCRIPT_NAME] Kind: $KIND | State: $STATE | Limit: $LIMIT"
TIERS_JSON='["trusted contributor","experienced contributor","principal contributor","distinguished contributor"]'
TMP_FILES=()
cleanup() {
if ((${#TMP_FILES[@]} > 0)); then
rm -f "${TMP_FILES[@]}"
fi
}
trap cleanup EXIT
new_tmp_file() {
local tmp
tmp="$(mktemp)"
TMP_FILES+=("$tmp")
echo "$tmp"
}
targets_file="$(new_tmp_file)"
if [[ "$KIND" == "both" || "$KIND" == "prs" ]]; then
gh api --paginate "repos/$REPO/pulls?state=$STATE&per_page=100" \
--jq '.[] | {
kind: "pr",
number: .number,
author: (.user.login // ""),
author_type: (.user.type // ""),
labels: [(.labels[]?.name // empty)]
}' >> "$targets_file"
fi
if [[ "$KIND" == "both" || "$KIND" == "issues" ]]; then
gh api --paginate "repos/$REPO/issues?state=$STATE&per_page=100" \
--jq '.[] | select(.pull_request | not) | {
kind: "issue",
number: .number,
author: (.user.login // ""),
author_type: (.user.type // ""),
labels: [(.labels[]?.name // empty)]
}' >> "$targets_file"
fi
if [[ "$LIMIT" -gt 0 ]]; then
limited_file="$(new_tmp_file)"
head -n "$LIMIT" "$targets_file" > "$limited_file"
mv "$limited_file" "$targets_file"
fi
target_count="$(wc -l < "$targets_file" | tr -d ' ')"
if [[ "$target_count" -eq 0 ]]; then
echo "[$SCRIPT_NAME] No targets found."
exit 0
fi
echo "[$SCRIPT_NAME] Targets fetched: $target_count"
# Ensure tier labels exist (trusted contributor might be new).
label_color=""
for probe_label in "experienced contributor" "principal contributor" "distinguished contributor" "trusted contributor"; do
encoded_label="$(urlencode "$probe_label")"
if color_candidate="$(gh api "repos/$REPO/labels/$encoded_label" --jq '.color' 2>/dev/null || true)"; then
if [[ -n "$color_candidate" ]]; then
label_color="$(echo "$color_candidate" | tr '[:lower:]' '[:upper:]')"
break
fi
fi
done
[[ -n "$label_color" ]] || label_color="C5D7A2"
while IFS= read -r tier_label; do
[[ -n "$tier_label" ]] || continue
encoded_label="$(urlencode "$tier_label")"
if gh api "repos/$REPO/labels/$encoded_label" >/dev/null 2>&1; then
continue
fi
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "[dry-run] Would create missing label: $tier_label (color=$label_color)"
else
gh api -X POST "repos/$REPO/labels" \
-f name="$tier_label" \
-f color="$label_color" >/dev/null
echo "[apply] Created missing label: $tier_label"
fi
done < <(jq -r '.[]' <<<"$TIERS_JSON")
# Build merged PR count cache by unique human authors.
authors_file="$(new_tmp_file)"
jq -r 'select(.author != "" and .author_type != "Bot") | .author' "$targets_file" | sort -u > "$authors_file"
author_count="$(wc -l < "$authors_file" | tr -d ' ')"
echo "[$SCRIPT_NAME] Unique human authors: $author_count"
author_counts_file="$(new_tmp_file)"
while IFS= read -r author; do
[[ -n "$author" ]] || continue
query="repo:$REPO is:pr is:merged author:$author"
merged_count="$(gh api search/issues -f q="$query" -F per_page=1 --jq '.total_count' 2>/dev/null || true)"
if ! [[ "$merged_count" =~ ^[0-9]+$ ]]; then
merged_count=0
fi
printf '%s\t%s\n' "$author" "$merged_count" >> "$author_counts_file"
done < "$authors_file"
updated=0
unchanged=0
skipped=0
failed=0
while IFS= read -r target_json; do
[[ -n "$target_json" ]] || continue
number="$(jq -r '.number' <<<"$target_json")"
kind="$(jq -r '.kind' <<<"$target_json")"
author="$(jq -r '.author' <<<"$target_json")"
author_type="$(jq -r '.author_type' <<<"$target_json")"
current_labels_json="$(jq -c '.labels // []' <<<"$target_json")"
if [[ -z "$author" || "$author_type" == "Bot" ]]; then
skipped=$((skipped + 1))
continue
fi
merged_count="$(awk -F '\t' -v key="$author" '$1 == key { print $2; exit }' "$author_counts_file")"
if ! [[ "$merged_count" =~ ^[0-9]+$ ]]; then
merged_count=0
fi
desired_tier="$(select_contributor_tier "$merged_count")"
if ! current_tier="$(jq -r --argjson tiers "$TIERS_JSON" '[.[] | select(. as $label | ($tiers | index($label)) != null)][0] // ""' <<<"$current_labels_json" 2>/dev/null)"; then
echo "[warn] Skipping ${kind} #${number}: cannot parse current labels JSON" >&2
failed=$((failed + 1))
continue
fi
if ! next_labels_json="$(jq -c --arg desired "$desired_tier" --argjson tiers "$TIERS_JSON" '
(. // [])
| map(select(. as $label | ($tiers | index($label)) == null))
| if $desired != "" then . + [$desired] else . end
| unique
' <<<"$current_labels_json" 2>/dev/null)"; then
echo "[warn] Skipping ${kind} #${number}: cannot compute next labels" >&2
failed=$((failed + 1))
continue
fi
if ! normalized_current="$(jq -c 'unique | sort' <<<"$current_labels_json" 2>/dev/null)"; then
echo "[warn] Skipping ${kind} #${number}: cannot normalize current labels" >&2
failed=$((failed + 1))
continue
fi
if ! normalized_next="$(jq -c 'unique | sort' <<<"$next_labels_json" 2>/dev/null)"; then
echo "[warn] Skipping ${kind} #${number}: cannot normalize next labels" >&2
failed=$((failed + 1))
continue
fi
if [[ "$normalized_current" == "$normalized_next" ]]; then
unchanged=$((unchanged + 1))
continue
fi
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "[dry-run] ${kind} #${number} @${author} merged=${merged_count} tier: '${current_tier:-none}' -> '${desired_tier:-none}'"
updated=$((updated + 1))
continue
fi
payload="$(jq -cn --argjson labels "$next_labels_json" '{labels: $labels}')"
if gh api -X PUT "repos/$REPO/issues/$number/labels" --input - <<<"$payload" >/dev/null; then
echo "[apply] Updated ${kind} #${number} @${author} tier: '${current_tier:-none}' -> '${desired_tier:-none}'"
updated=$((updated + 1))
else
echo "[apply] FAILED ${kind} #${number}" >&2
failed=$((failed + 1))
fi
done < "$targets_file"
echo ""
echo "[$SCRIPT_NAME] Summary"
echo " Targets: $target_count"
echo " Updated: $updated"
echo " Unchanged: $unchanged"
echo " Skipped: $skipped"
echo " Failed: $failed"
if [[ "$failed" -gt 0 ]]; then
exit 1
fi

View File

@@ -7,14 +7,70 @@ use crate::security::SecurityPolicy;
use crate::tools::{self, Tool};
use crate::util::truncate_with_ellipsis;
use anyhow::Result;
use regex::{Regex, RegexSet};
use std::fmt::Write;
use std::io::Write as _;
use std::sync::Arc;
use std::sync::{Arc, LazyLock};
use std::time::Instant;
use uuid::Uuid;
/// Maximum agentic tool-use iterations per user message to prevent runaway loops.
const MAX_TOOL_ITERATIONS: usize = 10;
static SENSITIVE_KEY_PATTERNS: LazyLock<RegexSet> = LazyLock::new(|| {
RegexSet::new([
r"(?i)token",
r"(?i)api[_-]?key",
r"(?i)password",
r"(?i)secret",
r"(?i)user[_-]?key",
r"(?i)bearer",
r"(?i)credential",
])
.unwrap()
});
static SENSITIVE_KV_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?i)(token|api[_-]?key|password|secret|user[_-]?key|bearer|credential)["']?\s*[:=]\s*(?:"([^"]{8,})"|'([^']{8,})'|([a-zA-Z0-9_\-\.]{8,}))"#).unwrap()
});
/// Scrub credentials from tool output to prevent accidental exfiltration.
/// Replaces known credential patterns with a redacted placeholder while preserving
/// a small prefix for context.
fn scrub_credentials(input: &str) -> String {
SENSITIVE_KV_REGEX
.replace_all(input, |caps: &regex::Captures| {
let full_match = &caps[0];
let key = &caps[1];
let val = caps
.get(2)
.or(caps.get(3))
.or(caps.get(4))
.map(|m| m.as_str())
.unwrap_or("");
// Preserve first 4 chars for context, then redact
let prefix = if val.len() > 4 { &val[..4] } else { "" };
if full_match.contains(':') {
if full_match.contains('"') {
format!("\"{}\": \"{}*[REDACTED]\"", key, prefix)
} else {
format!("{}: {}*[REDACTED]", key, prefix)
}
} else if full_match.contains('=') {
if full_match.contains('"') {
format!("{}=\"{}*[REDACTED]\"", key, prefix)
} else {
format!("{}={}*[REDACTED]", key, prefix)
}
} else {
format!("{}: {}*[REDACTED]", key, prefix)
}
})
.to_string()
}
/// Trigger auto-compaction when non-system message count exceeds this threshold.
const MAX_HISTORY_MESSAGES: usize = 50;
@@ -608,7 +664,7 @@ pub(crate) async fn run_tool_call_loop(
success: r.success,
});
if r.success {
r.output
scrub_credentials(&r.output)
} else {
format!("Error: {}", r.error.unwrap_or_else(|| r.output))
}
@@ -1222,6 +1278,25 @@ pub async fn process_message(config: Config, message: &str) -> Result<String> {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_scrub_credentials() {
let input = "API_KEY=sk-1234567890abcdef; token: 1234567890; password=\"secret123456\"";
let scrubbed = scrub_credentials(input);
assert!(scrubbed.contains("API_KEY=sk-1*[REDACTED]"));
assert!(scrubbed.contains("token: 1234*[REDACTED]"));
assert!(scrubbed.contains("password=\"secr*[REDACTED]\""));
assert!(!scrubbed.contains("abcdef"));
assert!(!scrubbed.contains("secret123456"));
}
#[test]
fn test_scrub_credentials_json() {
let input = r#"{"api_key": "sk-1234567890", "other": "public"}"#;
let scrubbed = scrub_credentials(input);
assert!(scrubbed.contains("\"api_key\": \"sk-1*[REDACTED]\""));
assert!(scrubbed.contains("public"));
}
use crate::memory::{Memory, MemoryCategory, SqliteMemory};
use tempfile::TempDir;

View File

@@ -40,7 +40,7 @@ impl Channel for CliChannel {
let msg = ChannelMessage {
id: Uuid::new_v4().to_string(),
sender: "user".to_string(),
reply_to: "user".to_string(),
reply_target: "user".to_string(),
content: line,
channel: "cli".to_string(),
timestamp: std::time::SystemTime::now()

View File

@@ -238,7 +238,7 @@ impl Channel for DingTalkChannel {
let channel_msg = ChannelMessage {
id: Uuid::new_v4().to_string(),
sender: sender_id.to_string(),
reply_to: chat_id,
reply_target: chat_id,
content: content.to_string(),
channel: "dingtalk".to_string(),
timestamp: std::time::SystemTime::now()

View File

@@ -450,7 +450,7 @@ impl LarkChannel {
let channel_msg = ChannelMessage {
id: Uuid::new_v4().to_string(),
sender: lark_msg.chat_id.clone(),
reply_to: lark_msg.chat_id.clone(),
reply_target: lark_msg.chat_id.clone(),
content: text,
channel: "lark".to_string(),
timestamp: std::time::SystemTime::now()

View File

@@ -633,6 +633,7 @@ pub async fn doctor_channels(config: Config) -> Result<()> {
Arc::new(TelegramChannel::new(
tg.bot_token.clone(),
tg.allowed_users.clone(),
Some(config.workspace_dir.clone()),
)),
));
}
@@ -923,6 +924,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
channels.push(Arc::new(TelegramChannel::new(
tg.bot_token.clone(),
tg.allowed_users.clone(),
Some(config.workspace_dir.clone()),
)));
}

View File

@@ -179,18 +179,19 @@ fn parse_attachment_markers(message: &str) -> (String, Vec<TelegramAttachment>)
}
/// Telegram channel — long-polls the Bot API for updates
pub struct TelegramChannel {
pub struct TelegramChannel { workspace_dir: std::path::PathBuf,
bot_token: String,
allowed_users: Vec<String>,
client: reqwest::Client,
}
impl TelegramChannel {
pub fn new(bot_token: String, allowed_users: Vec<String>) -> Self {
pub fn new(bot_token: String, allowed_users: Vec<String>, workspace_dir: Option<std::path::PathBuf>) -> Self {
Self {
bot_token,
allowed_users,
client: reqwest::Client::new(),
workspace_dir: workspace_dir.unwrap_or_else(|| std::path::PathBuf::from("/tmp")),
}
}

View File

@@ -231,7 +231,7 @@ async fn deliver_if_configured(config: &Config, job: &CronJob, output: &str) ->
.telegram
.as_ref()
.ok_or_else(|| anyhow::anyhow!("telegram channel not configured"))?;
let channel = TelegramChannel::new(tg.bot_token.clone(), tg.allowed_users.clone());
let channel = TelegramChannel::new(tg.bot_token.clone(), tg.allowed_users.clone(), Some(config.workspace_dir.clone()));
channel.send(output, target).await?;
}
"discord" => {

View File

@@ -709,7 +709,7 @@ async fn handle_whatsapp_message(
{
Ok(response) => {
// Send reply via WhatsApp
if let Err(e) = wa.send(&response, &msg.reply_to).await {
if let Err(e) = wa.send(&response, &msg.reply_target).await {
tracing::error!("Failed to send WhatsApp reply: {e}");
}
}
@@ -718,7 +718,7 @@ async fn handle_whatsapp_message(
let _ = wa
.send(
"Sorry, I couldn't process your message right now.",
&msg.reply_to,
&msg.reply_target,
)
.await;
}

View File

@@ -894,6 +894,7 @@ mod tests {
make_provider("Groq", "https://api.groq.com/openai", None),
make_provider("Mistral", "https://api.mistral.ai", None),
make_provider("xAI", "https://api.x.ai", None),
make_provider("Astrai", "https://as-trai.com/v1", None),
];
for p in providers {

View File

@@ -135,9 +135,11 @@ fn resolve_provider_credential(name: &str, credential_override: Option<&str>) ->
"zai" | "z.ai" => vec!["ZAI_API_KEY"],
"nvidia" | "nvidia-nim" | "build.nvidia.com" => vec!["NVIDIA_API_KEY"],
"synthetic" => vec!["SYNTHETIC_API_KEY"],
"nanogpt" | "nano-gpt" => vec!["NANO_GPT_API_KEY"],
"opencode" | "opencode-zen" => vec!["OPENCODE_API_KEY"],
"vercel" | "vercel-ai" => vec!["VERCEL_API_KEY"],
"cloudflare" | "cloudflare-ai" => vec!["CLOUDFLARE_API_KEY"],
"astrai" => vec!["ASTRAI_API_KEY"],
_ => vec![],
};
@@ -245,6 +247,12 @@ pub fn create_provider_with_url(
key,
AuthStyle::Bearer,
))),
"nanogpt" | "nano-gpt" => Ok(Box::new(OpenAiCompatibleProvider::new(
"NanoGPT",
"https://nano-gpt.com/api/v1",
key,
AuthStyle::Bearer,
))),
"bedrock" | "aws-bedrock" => Ok(Box::new(OpenAiCompatibleProvider::new(
"Amazon Bedrock",
"https://bedrock-runtime.us-east-1.amazonaws.com",
@@ -313,6 +321,11 @@ pub fn create_provider_with_url(
),
)),
// ── AI inference routers ─────────────────────────────
"astrai" => Ok(Box::new(OpenAiCompatibleProvider::new(
"Astrai", "https://as-trai.com/v1", key, AuthStyle::Bearer,
))),
// ── Bring Your Own Provider (custom URL) ───────────
// Format: "custom:https://your-api.com" or "custom:http://localhost:1234"
name if name.starts_with("custom:") => {
@@ -651,6 +664,13 @@ mod tests {
assert!(create_provider("build.nvidia.com", Some("nvapi-test")).is_ok());
}
// ── AI inference routers ─────────────────────────────────
#[test]
fn factory_astrai() {
assert!(create_provider("astrai", Some("sk-astrai-test")).is_ok());
}
// ── Custom / BYOP provider ─────────────────────────────
#[test]

View File

@@ -343,6 +343,7 @@ impl SecurityPolicy {
/// validates each sub-command against the allowlist
/// - Blocks single `&` background chaining (`&&` remains supported)
/// - Blocks output redirections (`>`, `>>`) that could write outside workspace
/// - Blocks dangerous arguments (e.g. `find -exec`, `git config`)
pub fn is_command_allowed(&self, command: &str) -> bool {
if self.autonomy == AutonomyLevel::ReadOnly {
return false;
@@ -398,13 +399,9 @@ impl SecurityPolicy {
// Strip leading env var assignments (e.g. FOO=bar cmd)
let cmd_part = skip_env_assignments(segment);
let base_cmd = cmd_part
.split_whitespace()
.next()
.unwrap_or("")
.rsplit('/')
.next()
.unwrap_or("");
let mut words = cmd_part.split_whitespace();
let base_raw = words.next().unwrap_or("");
let base_cmd = base_raw.rsplit('/').next().unwrap_or("");
if base_cmd.is_empty() {
continue;
@@ -417,6 +414,12 @@ impl SecurityPolicy {
{
return false;
}
// Validate arguments for the command
let args: Vec<String> = words.map(|w| w.to_ascii_lowercase()).collect();
if !self.is_args_safe(base_cmd, &args) {
return false;
}
}
// At least one command must be present
@@ -428,6 +431,29 @@ impl SecurityPolicy {
has_cmd
}
/// Check for dangerous arguments that allow sub-command execution.
fn is_args_safe(&self, base: &str, args: &[String]) -> bool {
let base = base.to_ascii_lowercase();
match base.as_str() {
"find" => {
// find -exec and find -ok allow arbitrary command execution
!args.iter().any(|arg| arg == "-exec" || arg == "-ok")
}
"git" => {
// git config, alias, and -c can be used to set dangerous options
// (e.g. git config core.editor "rm -rf /")
!args.iter().any(|arg| {
arg == "config"
|| arg.starts_with("config.")
|| arg == "alias"
|| arg.starts_with("alias.")
|| arg == "-c"
})
}
_ => true,
}
}
/// Check if a file path is allowed (no path traversal, within workspace)
pub fn is_path_allowed(&self, path: &str) -> bool {
// Block null bytes (can truncate paths in C-backed syscalls)
@@ -996,6 +1022,22 @@ mod tests {
assert!(!p.is_command_allowed("ls >> /tmp/exfil.txt"));
}
#[test]
fn command_argument_injection_blocked() {
let p = default_policy();
// find -exec is a common bypass
assert!(!p.is_command_allowed("find . -exec rm -rf {} +"));
assert!(!p.is_command_allowed("find / -ok cat {} \\;"));
// git config/alias can execute commands
assert!(!p.is_command_allowed("git config core.editor \"rm -rf /\""));
assert!(!p.is_command_allowed("git alias.st status"));
assert!(!p.is_command_allowed("git -c core.editor=calc.exe commit"));
// Legitimate commands should still work
assert!(p.is_command_allowed("find . -name '*.txt'"));
assert!(p.is_command_allowed("git status"));
assert!(p.is_command_allowed("git add ."));
}
#[test]
fn command_injection_dollar_brace_blocked() {
let p = default_policy();

View File

@@ -749,4 +749,54 @@ mod tests {
let _ = HttpRequestTool::redact_headers_for_display(&headers);
assert_eq!(headers[0].1, "Bearer real-token");
}
// ── SSRF: alternate IP notation bypass defense-in-depth ─────────
//
// Rust's IpAddr::parse() rejects non-standard notations (octal, hex,
// decimal integer, zero-padded). These tests document that property
// so regressions are caught if the parsing strategy ever changes.
#[test]
fn ssrf_octal_loopback_not_parsed_as_ip() {
// 0177.0.0.1 is octal for 127.0.0.1 in some languages, but
// Rust's IpAddr rejects it — it falls through as a hostname.
assert!(!is_private_or_local_host("0177.0.0.1"));
}
#[test]
fn ssrf_hex_loopback_not_parsed_as_ip() {
// 0x7f000001 is hex for 127.0.0.1 in some languages.
assert!(!is_private_or_local_host("0x7f000001"));
}
#[test]
fn ssrf_decimal_loopback_not_parsed_as_ip() {
// 2130706433 is decimal for 127.0.0.1 in some languages.
assert!(!is_private_or_local_host("2130706433"));
}
#[test]
fn ssrf_zero_padded_loopback_not_parsed_as_ip() {
// 127.000.000.001 uses zero-padded octets.
assert!(!is_private_or_local_host("127.000.000.001"));
}
#[test]
fn ssrf_alternate_notations_rejected_by_validate_url() {
// Even if is_private_or_local_host doesn't flag these, they
// fail the allowlist because they're treated as hostnames.
let tool = test_tool(vec!["example.com"]);
for notation in [
"http://0177.0.0.1",
"http://0x7f000001",
"http://2130706433",
"http://127.000.000.001",
] {
let err = tool.validate_url(notation).unwrap_err().to_string();
assert!(
err.contains("allowed_domains"),
"Expected allowlist rejection for {notation}, got: {err}"
);
}
}
}

View File

@@ -25,6 +25,7 @@ pub mod schema;
pub mod screenshot;
pub mod shell;
pub mod traits;
pub mod transcribe;
pub use browser::{BrowserTool, ComputerUseConfig};
pub use browser_open::BrowserOpenTool;
@@ -53,6 +54,7 @@ pub use schema::{CleaningStrategy, SchemaCleanr};
pub use screenshot::ScreenshotTool;
pub use shell::ShellTool;
pub use traits::Tool;
pub use transcribe::TranscribeTool;
#[allow(unused_imports)]
pub use traits::{ToolResult, ToolSpec};
@@ -191,6 +193,8 @@ pub fn all_tools_with_runtime(
tools.push(Box::new(ScreenshotTool::new(security.clone())));
tools.push(Box::new(ImageInfoTool::new(security.clone())));
tools.push(Box::new(TranscribeTool::new(security.clone(), None, None)));
if let Some(key) = composio_key {
if !key.is_empty() {
tools.push(Box::new(ComposioTool::new(key, composio_entity_id)));

283
src/tools/transcribe.rs Normal file
View File

@@ -0,0 +1,283 @@
use super::traits::{Tool, ToolResult};
use crate::security::SecurityPolicy;
use async_trait::async_trait;
use serde_json::json;
use std::path::Path;
use std::sync::Arc;
use tokio::process::Command;
const MAX_AUDIO_BYTES: u64 = 104_857_600;
const SUPPORTED_FORMATS: &[&str] = &["mp3", "wav", "m4a", "flac", "ogg", "webm", "mp4", "mpeg", "mpga"];
pub struct TranscribeTool {
security: Arc<SecurityPolicy>,
model: String,
device: String,
}
impl TranscribeTool {
pub fn new(security: Arc<SecurityPolicy>, model: Option<String>, device: Option<String>) -> Self {
Self {
security,
model: model.unwrap_or_else(|| "base".to_string()),
device: device.unwrap_or_else(|| "cpu".to_string()),
}
}
fn is_supported_format(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.map(|ext| SUPPORTED_FORMATS.contains(&ext.to_lowercase().as_str()))
.unwrap_or(false)
}
fn transcription_script() -> &'static str {
r#"
import sys
import json
def transcribe(audio_path, model_size, device):
from faster_whisper import WhisperModel
model = WhisperModel(model_size, device=device, compute_type="int8")
segments, info = model.transcribe(audio_path, beam_size=5)
transcription = []
for segment in segments:
transcription.append({
"start": round(segment.start, 2),
"end": round(segment.end, 2),
"text": segment.text.strip()
})
result = {
"language": info.language,
"language_probability": round(info.language_probability, 2),
"duration": round(info.duration, 2),
"segments": transcription,
"text": " ".join(s["text"] for s in transcription)
}
print(json.dumps(result))
if __name__ == "__main__":
if len(sys.argv) != 4:
print(json.dumps({"error": "Usage: script.py <audio_path> <model> <device>"}))
sys.exit(1)
transcribe(sys.argv[1], sys.argv[2], sys.argv[3])
"#
}
}
#[async_trait]
impl Tool for TranscribeTool {
fn name(&self) -> &str {
"transcribe"
}
fn description(&self) -> &str {
"Transcribe audio files to text using faster-whisper. \
Supports mp3, wav, m4a, flac, ogg, webm, and other common audio formats. \
Returns the transcription with timestamps and detected language."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"additionalProperties": false,
"properties": {
"path": {
"type": "string",
"description": "Path to the audio file to transcribe"
},
"model": {
"type": "string",
"enum": ["tiny", "base", "small", "medium", "large-v2", "large-v3"],
"description": "Whisper model size (default: base). Larger models are more accurate but slower."
},
"language": {
"type": "string",
"description": "Hint for the spoken language (e.g., 'en', 'es', 'zh'). Optional."
}
},
"required": ["path"]
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let path_str = args
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'path' parameter"))?;
if self.security.is_rate_limited() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("Rate limit exceeded: too many actions in the last hour".into()),
});
}
if !self.security.is_path_allowed(path_str) {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("Path not allowed by security policy: {}", path_str)),
});
}
if !self.security.record_action() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("Rate limit exceeded: action budget exhausted".into()),
});
}
let path = Path::new(path_str);
let full_path = self.security.workspace_dir.join(path);
if !full_path.exists() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("File not found: {}", path_str)),
});
}
let metadata = match std::fs::metadata(&full_path) {
Ok(m) => m,
Err(e) => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("Cannot read file metadata: {}", e)),
});
}
};
if metadata.len() > MAX_AUDIO_BYTES {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"File too large: {} bytes (max: {} bytes)",
metadata.len(),
MAX_AUDIO_BYTES
)),
});
}
if !Self::is_supported_format(&full_path) {
let ext = full_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("unknown");
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"Unsupported audio format: {}. Supported: {}",
ext,
SUPPORTED_FORMATS.join(", ")
)),
});
}
let model = args
.get("model")
.and_then(|v| v.as_str())
.unwrap_or(&self.model);
let script = Self::transcription_script();
let output = Command::new("python3")
.arg("-c")
.arg(script)
.arg(&full_path)
.arg(model)
.arg(&self.device)
.output()
.await;
match output {
Ok(result) => {
if result.status.success() {
let stdout = String::from_utf8_lossy(&result.stdout);
match serde_json::from_str::<serde_json::Value>(&stdout) {
Ok(json) => {
let text = json
.get("text")
.and_then(|t| t.as_str())
.unwrap_or(&stdout);
let language = json
.get("language")
.and_then(|l| l.as_str())
.unwrap_or("unknown");
let duration = json
.get("duration")
.and_then(|d| d.as_f64())
.unwrap_or(0.0);
let duration_f = duration;
let mut out = format!(
"**Transcription** ({:.1}, language: {})\n\n{}\n",
duration_f, language, text
);
if let Some(segments) = json.get("segments").and_then(|s| s.as_array())
{
if segments.len() > 1 {
out.push_str("\n**Segments:**\n");
for seg in segments.iter().take(20) {
if let (Some(start), Some(end), Some(seg_text)) = (
seg.get("start").and_then(|v| v.as_f64()),
seg.get("end").and_then(|v| v.as_f64()),
seg.get("text").and_then(|v| v.as_str()),
) {
let start_f = start;
let end_f = end;
out.push_str(&format!(
"[{:05.1} - {:05.1}] {}\n",
start_f, end_f, seg_text
));
}
}
if segments.len() > 20 {
out.push_str(&format!(
"... and {} more segments\n",
segments.len() - 20
));
}
}
}
Ok(ToolResult {
success: true,
output: out,
error: None,
})
}
Err(_) => Ok(ToolResult {
success: true,
output: stdout.to_string(),
error: None,
}),
}
} else {
let stderr = String::from_utf8_lossy(&result.stderr);
Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("Transcription failed: {}", stderr.trim())),
})
}
}
Err(e) => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("Failed to run transcription: {}", e)),
}),
}
}
}