Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ ARG BUN_VERSION=1.3
ARG DEBIAN_VERSION=trixie
ARG ONNXRUNTIME_VERSION=1.24.2
ARG VECTORLITE_VERSION=16a01af79add
ARG MCP_GRAFANA_VERSION=v1.3.0

ARG GIT_USER_NAME="Claudear"
ARG GIT_USER_EMAIL="claudear@noreply.local"
Expand Down Expand Up @@ -109,6 +110,7 @@ RUN touch src/main.rs src/lib.rs \
FROM debian:${DEBIAN_VERSION}-slim AS final
ARG GIT_USER_NAME
ARG GIT_USER_EMAIL
ARG MCP_GRAFANA_VERSION

WORKDIR /app

Expand All @@ -129,6 +131,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& apt-get update && apt-get install -y --no-install-recommends gh \
&& rm -rf /var/lib/apt/lists/*

# Grafana MCP server, so agents can query Prometheus metrics and Loki logs while
# triaging. A single static Go binary: no extra language runtime, and nothing is
# downloaded on first use the way `uvx mcp-grafana` would. Attached to runs only
# when [agent.providers.claude.mcp.grafana] is configured.
RUN ARCH=$(dpkg --print-architecture) \
&& case "${ARCH}" in \
amd64) MCP_ARCH=x86_64 ;; \
arm64) MCP_ARCH=arm64 ;; \
*) echo "unsupported arch: ${ARCH}" >&2; exit 1 ;; \
esac \
&& curl -fsSL "https://github.com/grafana/mcp-grafana/releases/download/${MCP_GRAFANA_VERSION}/mcp-grafana_Linux_${MCP_ARCH}.tar.gz" \
| tar -xz -C /usr/local/bin mcp-grafana \
&& chmod 755 /usr/local/bin/mcp-grafana

COPY --from=vectorlite /build/build/release/vectorlite/vectorlite.so /usr/local/lib/vectorlite.so
COPY --from=builder /app/target/release/claudear /usr/local/bin/claudear
COPY --chmod=755 docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
Expand Down
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Point it at Linear, Sentry, Jira, GitLab, Discord, Slack, or GitHub review comme
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Configuration](#configuration)
- [MCP Servers](#mcp-servers)
- [Usage](#usage)
- [Daemon Mode](#daemon-mode)
- [Polling Mode](#polling-mode-foreground)
Expand Down Expand Up @@ -255,6 +256,7 @@ Point it at Linear, Sentry, Jira, GitLab, Discord, Slack, or GitHub review comme
| | Codex | CLI | OpenAI Codex runner |
| | Gemini | CLI | Google Gemini *(planned)* |
| | Copilot | CLI | GitHub Copilot *(planned)* |
| **Telemetry** | Grafana | MCP (stdio) | Prometheus metrics + Loki logs, pulled by the agent while triaging |
| **Storage** | SQLite | Local file | WAL mode, Vectorlite extension |
| **Embeddings** | Nomic | ONNX | Default model, local inference |
| | MiniLM | ONNX | Lightweight alternative |
Expand Down Expand Up @@ -412,6 +414,59 @@ All config values can be overridden with environment variables, useful for keepi
| `CLAUDEAR_TLS_HTTP_REDIRECT_PORT` | `tls.http_redirect_port` |
| `CLAUDEAR_DISCORD_BOT_TOKEN` | `notifiers.discord.bot_token` |
| `CLAUDEAR_SLACK_BOT_TOKEN` | `notifiers.slack.bot_token` |
| `GRAFANA_URL` | Grafana base URL (see [MCP Servers](#mcp-servers)) |
| `GRAFANA_SERVICE_ACCOUNT_TOKEN` | Grafana service account token |
| `GRAFANA_EXTRA_HEADERS` | Extra Grafana request headers, as a JSON object |

The `GRAFANA_*` variables are read by the MCP server rather than by Claudear itself,
so they are not `CLAUDEAR_`-prefixed and do not map onto a config path. Reference them
as `${VAR}` from the server's `env` block.

### MCP Servers

Agent runs can be given [MCP](https://modelcontextprotocol.io) servers, letting the
agent reach systems the issue payload does not describe. Servers are declared per
provider and gated by issue source:

```toml
[agent.providers.claude.mcp.grafana]
command = "mcp-grafana"
args = ["--disable-write"]
sources = ["sentry"]
tools = ["list_datasources", "query_prometheus", "query_loki_logs"]
instructions = "Bound every query to the issue's First Seen / Last Seen window."

[agent.providers.claude.mcp.grafana.env]
GRAFANA_URL = "https://grafana.example.com/"
GRAFANA_SERVICE_ACCOUNT_TOKEN = "${GRAFANA_SERVICE_ACCOUNT_TOKEN}"
```

| Key | Meaning |
|-----|---------|
| `command` / `args` | stdio transport: the server process to spawn |
| `url` / `headers` / `type` | HTTP or SSE transport, as an alternative to `command` |
| `env` | Environment for the server process; `${VAR}` is expanded at run time |
| `sources` | Issue sources this server attaches for; empty means all |
| `tools` | Tool names to allow; empty grants every tool the server exposes |
| `instructions` | Guidance added to the agent's prompt whenever this server attaches |

Notes:

- `sources` gates on the issue's source, not the kind of run. A server listed for
`sentry` attaches to Sentry fix, verify, and reply runs alike. Classification runs
never attach MCP servers.
- Without `instructions` the agent holds the tools but is never told they exist, so it
will usually ignore them. Put your datasource UIDs and label conventions there.
Per-repo detail belongs in that repo's `AGENT.md`, already prepended to every prompt.
- Keep secrets out of `claudear.toml` — reference them as `${VAR}` from the daemon
environment. The rendered config is written to a private temp file (mode 0600) that
is deleted when the run ends, and the repo's own `.mcp.json` is ignored.
- **Grafana** gives the agent Prometheus metrics and Loki logs through Grafana's
datasources, so it can check what production was doing when an error fired instead of
reasoning from the stack trace alone. The Docker image ships the `mcp-grafana` binary;
for other installs, take it from
[grafana/mcp-grafana releases](https://github.com/grafana/mcp-grafana/releases) and put
it on `PATH`. A Viewer-role service account is enough.

### Minimal Configuration

Expand Down
57 changes: 47 additions & 10 deletions claudear.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -146,34 +146,71 @@ sandbox = ""
# MCP servers attached to agent runs, keyed by server name.
# Gated per-run by `sources` against the issue source. Default here: HelpScout only.
# Add "discord" to enable there; set sources = [] to enable for all sources.
# Note `sources` gates on the issue's source, not the kind of run, so a server
# listed for "sentry" attaches to Sentry fix, verify, and reply runs alike.
# Classification runs never attach MCP servers.
# Keep secrets out of this file: reference them via ${VAR} from the daemon/provider env.
# [agent.providers.claude.mcp.appwrite]
# command = "uvx"
# args = ["mcp-server-appwrite", "--databases", "--users", "--functions"]
# sources = ["helpscout"]
# tools: tool names to allow (empty/omitted grants all of the server's tools).
# tools = ["databases_list_documents", "databases_get_document"]
# instructions: optional guidance added to the prompt whenever this server
# attaches, telling the agent what the tools cover and when to reach for them.
# instructions = "Look up the customer's document by the ID in the ticket."
# [agent.providers.claude.mcp.appwrite.env]
# APPWRITE_ENDPOINT = "https://fra.cloud.appwrite.io/v1"
# APPWRITE_PROJECT_ID = "monitoring-fra"
# APPWRITE_API_KEY = "${APPWRITE_API_KEY}" # read-only key, set in daemon env

# Grafana (mcp-grafana) — query dashboards, datasources, and metrics so the
# agent can pull live telemetry when triaging an issue.
# Grafana (mcp-grafana) — reach Prometheus metrics and Loki logs through Grafana
# as datasources, so the agent can check what production was actually doing when
# an error fired rather than reasoning from the stack trace alone.
# The Docker image ships the mcp-grafana binary. Installing from a deb/tarball?
# Grab it from https://github.com/grafana/mcp-grafana/releases and put it on PATH.
# [agent.providers.claude.mcp.grafana]
# command = "uvx"
# args = ["mcp-grafana"]
# command = "mcp-grafana"
# --disable-write drops every mutating tool (dashboards, incidents, alert rules).
# args = ["--disable-write"]
# sources = ["sentry"]
# tools: tool names to allow (empty/omitted grants all of the server's tools).
# tools = ["search_dashboards", "query_prometheus", "list_datasources"]
# Listing them explicitly keeps the grant read-only even if a future mcp-grafana
# release adds tools --disable-write does not cover.
# tools = [
# "list_datasources", "get_datasource",
# "query_prometheus", "list_prometheus_metric_names", "list_prometheus_label_names",
# "list_prometheus_label_values", "query_prometheus_histogram",
# "query_loki_logs", "list_loki_label_names", "list_loki_label_values",
# "query_loki_stats", "query_loki_patterns",
# "search_dashboards",
# ]
# instructions: injected into the agent's prompt whenever this server attaches.
# Without it the agent holds the tools but is never told they exist. Put your
# deployment's specifics here — datasource UIDs, label conventions, limits.
# Per-repo detail (which service label a repo maps to) belongs in that repo's
# AGENT.md, which is already prepended to every prompt.
# instructions = """
# You have read-only Grafana access covering Prometheus metrics and Loki logs.
# Before settling on a root cause, correlate the error against production telemetry:
# - Bound every query to the issue's First Seen / Last Seen window. Never query open-ended ranges.
# - Check whether the error rate tracks a deploy, a traffic spike, or resource saturation.
# - Pull the Loki lines around the failure for the affected service to see what preceded it.
# Call list_datasources once to find datasource UIDs. Keep result sets small: prefer
# aggregations and short windows over raw log dumps, which will crowd out your context.
# """
# [agent.providers.claude.mcp.grafana.env]
# GRAFANA_URL = "https://telemetry.example.com/"
# Env values are written to .mcp.json verbatim, so you can inline literals here
# or reference ${VAR} from the daemon env (Claude Code expands ${VAR}).
# GRAFANA_SERVICE_ACCOUNT_TOKEN = "glsa_xxxxxxxxxxxx" # or "${GRAFANA_SERVICE_ACCOUNT_TOKEN}"
# Reference secrets as ${VAR} from the daemon env — Claude Code expands them when
# it reads the generated .mcp.json. Inlining a literal here puts the secret in a
# file the dashboard's config editor can read, so prefer ${VAR} for anything secret.
# GRAFANA_SERVICE_ACCOUNT_TOKEN = "${GRAFANA_SERVICE_ACCOUNT_TOKEN}"
# When Grafana sits behind Cloudflare Access, pass the service-token headers as a
# JSON string (inline literals or ${VAR} both work).
# GRAFANA_EXTRA_HEADERS = "{\"CF-Access-Client-Id\": \"your-cf-access-client-id\", \"CF-Access-Client-Secret\": \"your-cf-access-client-secret\"}"
# JSON string. Keep the whole bundle in one env var rather than inlining the secret.
# GRAFANA_EXTRA_HEADERS = "${GRAFANA_EXTRA_HEADERS}"
#
# Give the Grafana service account the Viewer role: the query tools need
# datasources:query and datasources:read, and search_dashboards needs dashboards:read.

# A/B Experiments (optional)
# Test different providers or configurations against each other.
Expand Down
38 changes: 38 additions & 0 deletions crates/claudear-config/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,12 @@ pub struct McpServerConfig {
/// server's tools (`mcp__<server>`). Applies to every run that attaches this
/// server.
pub tools: Vec<String>,
/// Guidance injected into the agent's prompt whenever this server attaches.
/// Without it the agent is handed the server's tools but never told they
/// exist, what they cover, or when reaching for them is worthwhile. Put the
/// deployment-specific detail here: datasource UIDs, label conventions, and
/// any limits worth respecting.
pub instructions: Option<String>,
}

impl McpServerConfig {
Expand Down Expand Up @@ -3617,6 +3623,38 @@ mod tests {
appwrite.env.get("APPWRITE_API_KEY").map(String::as_str),
Some("${APPWRITE_API_KEY}")
);
// Omitted `instructions` must stay None rather than defaulting to a string,
// so the prompt's telemetry block stays absent for servers without guidance.
assert_eq!(appwrite.instructions, None);
}

#[test]
fn test_mcp_config_parses_instructions() {
let toml = r#"
[agent.providers.claude.mcp.grafana]
command = "mcp-grafana"
args = ["--disable-write"]
sources = ["sentry"]
tools = ["query_loki_logs", "query_prometheus"]
instructions = """
Bound every query to the issue's First Seen / Last Seen window.
Call list_datasources once to find datasource UIDs.
"""
"#;
let cfg: Config = toml::from_str(toml).expect("parse");
let grafana = cfg
.agent
.providers
.get("claude")
.expect("provider")
.mcp
.get("grafana")
.expect("mcp server");
assert_eq!(grafana.command.as_deref(), Some("mcp-grafana"));
assert_eq!(grafana.args, vec!["--disable-write".to_string()]);
let instructions = grafana.instructions.as_deref().expect("instructions");
assert!(instructions.contains("First Seen / Last Seen"));
assert!(instructions.contains("list_datasources"));
Comment on lines +3653 to +3657

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Tests mirror implementation details

These assertions copy the fixture’s exact Grafana command, argument, and instruction wording instead of testing an observable configuration outcome. That makes harmless wording or executable changes require test edits while failures in the actual prompt and run behavior can still pass. The same problem appears in the new runner tests that directly assert private helper results, server-name formatting, and template syntax. This violates the repository directive not to mirror source code or configuration in assertions and to test observable behavior instead, so the requirement must be satisfied before merging. Replace this cluster with boundary-level prompt and MCP invocation tests.

Context Used: Call out and harshly judge implementation-coupled tests. We don't mirror source code, configuration, or version pins in assertions. We test observable behavior; use linters for syntax and schema checks. (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/claudear-config/src/config.rs
Line: 3653-3657

Comment:
**Tests mirror implementation details**

These assertions copy the fixture’s exact Grafana command, argument, and instruction wording instead of testing an observable configuration outcome. That makes harmless wording or executable changes require test edits while failures in the actual prompt and run behavior can still pass. The same problem appears in the new runner tests that directly assert private helper results, server-name formatting, and template syntax. This violates the repository directive not to mirror source code or configuration in assertions and to test observable behavior instead, so the requirement must be satisfied before merging. Replace this cluster with boundary-level prompt and MCP invocation tests.

**Context Used:** Call out and harshly judge implementation-coupled tests. We don't mirror source code, configuration, or version pins in assertions. We test observable behavior; use linters for syntax and schema checks. ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex

}

#[test]
Expand Down
23 changes: 23 additions & 0 deletions crates/claudear-core/src/templates/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ You are working in the repository: {{repo_name}}
Before starting any work, verify this is the correct repository for this issue by checking that the file paths, modules, or stack traces reference code in this codebase.
If this is NOT the correct repository, set "wrong_repo" in your response to the name of the repository you believe is correct (in "org/repo" format) and do NOT attempt any fixes.
{{/if}}
{{#if mcp_instructions}}
## Live telemetry

You have MCP tools connected to the services below. Use them to gather real evidence before settling on a root cause, and say in your summary what the telemetry showed - including when it contradicts what the stack trace implies.

{{mcp_instructions}}
{{/if}}
Your task:
1. Analyze the issue/error and any stack traces
2. Find the relevant code in this codebase
Expand Down Expand Up @@ -62,6 +69,14 @@ Description:

{{context}}

{{#if mcp_instructions}}
## Live telemetry

You have MCP tools connected to the services below. Use them to gather real evidence before settling on a root cause, and say in your summary what the telemetry showed - including when it contradicts what the stack trace implies.

{{mcp_instructions}}
{{/if}}

IMPORTANT: Use a test-driven development (TDD) approach for bug fixes. Before changing any application code, write a failing test that reproduces the issue. Then implement the minimal fix to make the test pass and verify all existing tests still pass.

Create a PR that addresses this issue. Include "{{short_id}}" in the PR title.
Expand All @@ -87,6 +102,14 @@ Event count: {{event_count}}

{{context}}

{{#if mcp_instructions}}
## Live telemetry

You have MCP tools connected to the services below. Use them to gather real evidence before settling on a root cause, and say in your summary what the telemetry showed - including when it contradicts what the stack trace implies.

{{mcp_instructions}}
{{/if}}

Analyze the stack trace and error context to identify the root cause.

IMPORTANT: Use a test-driven development (TDD) approach. Before changing any application code, write a failing test that reproduces the error. Then implement the minimal fix to make the test pass and verify all existing tests still pass.
Expand Down
54 changes: 53 additions & 1 deletion crates/claudear-engine/src/api/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2568,9 +2568,18 @@ struct ConfigUpdateRequest {
}

/// Redact values of keys that look like secrets in raw TOML content.
///
/// `headers` is in the list because MCP servers carry auth in header bundles whose
/// key names say nothing about their contents - e.g. a Cloudflare Access client
/// secret inside `GRAFANA_EXTRA_HEADERS`, which none of the other patterns match.
///
/// The value alternatives are ordered longest-form first, and the basic-string arm
/// understands backslash escapes. A naive `"[^"]*"` stops at the first escaped quote
/// inside the string, replacing only the opening fragment and leaving the rest of the
/// secret in the response - exactly the shape a JSON header bundle has.
fn redact_secrets(content: &str) -> String {
let re = regex_lite::Regex::new(
r#"(?im)^(\s*(?:[a-z_]*(?:token|secret|password|api_key|auth)[a-z_]*)\s*=\s*)("[^"]*"|'[^']*'|[^\n]*)"#,
r#"(?im)^(\s*(?:[a-z_]*(?:token|secret|password|api_key|auth|headers)[a-z_]*)\s*=\s*)("""[\s\S]*?"""|'''[\s\S]*?'''|"(?:[^"\\]|\\.)*"|'[^']*'|[^\n]*)"#,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security MCP header secrets leak

The new headers alternative only redacts assignments whose key is itself named headers; it does not track entries inside an MCP headers table. A valid configuration can use [agent.providers.claude.mcp.grafana.headers] followed by a quoted key such as "CF-Access-Client-Secret" = "real-secret". That key does not match this expression, so GET /api/config returns the credential unchanged. The new test covers the separate GRAFANA_EXTRA_HEADERS JSON-string shape and misses the typed McpServerConfig.headers representation.

How this was verified: McpServerConfig.headers is a TOML map, while this expression only matches standalone secret-like assignment keys and cannot associate arbitrary entries with a surrounding headers table.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/claudear-engine/src/api/routes.rs
Line: 2582

Comment:
**MCP header secrets leak**

The new `headers` alternative only redacts assignments whose key is itself named `headers`; it does not track entries inside an MCP headers table. A valid configuration can use `[agent.providers.claude.mcp.grafana.headers]` followed by a quoted key such as `"CF-Access-Client-Secret" = "real-secret"`. That key does not match this expression, so `GET /api/config` returns the credential unchanged. The new test covers the separate `GRAFANA_EXTRA_HEADERS` JSON-string shape and misses the typed `McpServerConfig.headers` representation.

**How this was verified:** `McpServerConfig.headers` is a TOML map, while this expression only matches standalone secret-like assignment keys and cannot associate arbitrary entries with a surrounding headers table.

**Knowledge Base Used:**
- [Service API and control plane](https://app.greptile.com/appwrite/-/custom-context/knowledge-base/appwrite/claudear/-/docs/service-api.md)
- [Configuration, secrets, and deployment](https://app.greptile.com/appwrite/-/custom-context/knowledge-base/appwrite/claudear/-/docs/configuration-secrets-and-deployment.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

)
.expect("valid regex");
re.replace_all(content, r#"${1}"[REDACTED]""#).into_owned()
Expand Down Expand Up @@ -5319,6 +5328,49 @@ mod tests {
assert!(!truncated);
}

#[test]
fn test_redact_secrets_header_bundle() {
// An MCP server's header bundle carries a secret under a key name that matches
// none of the other patterns, and wraps it in a JSON blob full of escaped
// quotes. Both parts have to work or GET /api/config leaks it to the dashboard.
let content = r#"GRAFANA_EXTRA_HEADERS = "{\"CF-Access-Client-Secret\": \"real-secret\"}"
"#;
let redacted = redact_secrets(content);
assert!(!redacted.contains("real-secret"), "redacted: {}", redacted);
assert!(redacted.contains("[REDACTED]"));
}

#[test]
fn test_redact_secrets_multiline_string() {
// TOML multi-line strings must be consumed whole; matching the ordinary
// basic-string arm first would stop at the opening delimiter.
let content =
"auth_token = \"\"\"\nline-one-secret\nline-two-secret\n\"\"\"\nport = 3100\n";
let redacted = redact_secrets(content);
assert!(
!redacted.contains("line-one-secret"),
"redacted: {}",
redacted
);
assert!(
!redacted.contains("line-two-secret"),
"redacted: {}",
redacted
);
// Non-secret keys after the multi-line value survive untouched.
assert!(redacted.contains("port = 3100"));
}

#[test]
fn test_redact_secrets_leaves_instructions_intact() {
// `instructions` on an MCP server is prompt guidance, not a credential; it
// must survive redaction so the config editor round-trips it unchanged.
let content = "instructions = \"Call list_datasources once to find UIDs.\"\n";
let redacted = redact_secrets(content);
assert!(redacted.contains("Call list_datasources once to find UIDs."));
assert!(!redacted.contains("[REDACTED]"));
}

#[test]
fn test_redact_secrets_basic() {
let content = "api_token = \"sk-12345\"\nwebhook_secret = \"whsec_abc\"\nnormal_key = \"not_a_secret\"\n";
Expand Down
Loading
Loading