This is the full developer documentation for Arrowhead
# Arrowhead
> A hardened Model Context Protocol server and an importable Python library for exposing your infrastructure to AI agents, with no unguarded path.
The safe path is the default path. OAuth 2.1 authorization, per-resource authorization, SSRF and path-traversal defenses, content sanitization and provenance, per-caller rate limiting, structured audit logging, and token-efficient schemas apply to every tool, resource, and prompt. Security lives inside each component rather than in a proxy in front of it, so the guarantees hold whether a call arrives over HTTP or the server is imported and called directly from Python.
## Run it
[Section titled “Run it”](#run-it)
* From a checkout
```bash
git clone https://github.com/jagguvarma15/arrowhead.git
cd arrowhead
uv sync
uv run python -m arrowhead.server
```
* Container image
```bash
docker run --rm -i -e ARROWHEAD_TRANSPORT=stdio \
ghcr.io/jagguvarma15/arrowhead:0.2.832
```
Auth is off in stdio mode, so every tool is immediately callable. To register it with an MCP client, the shape is always the same entry:
```json
{
"mcpServers": {
"arrowhead": {
"command": "uv",
"args": ["run", "python", "-m", "arrowhead.server"],
"cwd": "/path/to/arrowhead"
}
}
}
```
One-click installs and per-client instructions live in [Integrations](/arrowhead/integrations/).
## Find your way
[Section titled “Find your way”](#find-your-way)
Getting started
Install from a checkout, pick a profile, make the first guarded call, and see where auth begins. [Start here](/arrowhead/getting-started/)
Integrations
Tabbed, copy-paste setup for Claude Desktop, Claude Code, Cursor, VS Code, the Agent SDK, and any MCP client, with one-click install links. [Connect a client](/arrowhead/integrations/)
Tool reference
Every tool with its wire description, scope, rate ceiling, and behavior hints, generated from the catalog at build time. [Browse the catalog](/arrowhead/reference/tools/)
Configuration
Every environment variable with its real default, cross-checked against the code so the page cannot drift. [Look up a setting](/arrowhead/reference/configuration/)
Security model
Each mitigation mapped to the vulnerability class it closes, from SSRF to prompt injection via tool results. [Read the model](/arrowhead/security/)
Threat model
The attack surface tool by tool, cross-cutting concerns, and what is explicitly out of scope. [Weigh the risks](/arrowhead/threat-model/)
Architecture
The request flow from auth through rate limiting to the tool and the audit log, and the module layout. [See the shape](/arrowhead/architecture/)
Deployment
The step-by-step runbook for a live instance: platform, auth, verification, rollback, and backup. [Ship an instance](/arrowhead/deployment/)
Runnable walkthroughs live in [Examples](/arrowhead/examples/docs-rag/), development setup and conventions in [Contributing](/arrowhead/contributing/), common failure modes in the [FAQ](/arrowhead/faq/), and the protocol surface in the [capability matrix](/arrowhead/reference/capabilities/). Agents can read this site as [llms.txt](https://jagguvarma15.github.io/arrowhead/llms.txt) or fetch any page’s raw markdown by appending `.md` to its path.
# Architecture
> The request flow from auth to the audit log, and the module layout.
Arrowhead is an application on the official MCP Python SDK (the `mcp` package, version 2), served over streamable HTTP in stateless mode (stdio for local development). Stateless means the server keeps no per-session state, so any replica can serve any request and horizontal scaling needs no sticky sessions. Shared state that must survive across replicas — the rate-limit buckets — lives in Redis, keyed explicitly rather than by transport session.
One endpoint serves both protocol eras. A request carrying the reserved `_meta` envelope takes the sessionless 2026-07-28 leg; a request that sends the `initialize` lifecycle takes the handshake-era leg, negotiated down to the latest handshake version. The SDK routes between them; the application configures nothing.
## Request flow
[Section titled “Request flow”](#request-flow)
The guards do not live in message middleware. Each tool, resource, and prompt is registered wrapped in a guard chain (`arrowhead.runtime.guards`) that reproduces the prior middleware order on the component itself, so an import-and-call invocation (`Arrowhead.call`) runs the identical path as an HTTP request with no duplicated logic. Two thin SDK middlewares remain, and neither refuses: one records the request `_meta` for trace-context propagation, the other filters list results by the kill switch and scope. Every refusal lives in the wrapper.
```
flowchart TD
client([client]) --> tls["TLS termination
platform or reverse proxy, not the server"]
tls --> origin["Host and Origin check
rejects rebinding of the endpoint, when configured"]
origin --> token["Token verification
JWKS signature, issuer, expiry, audience: 401 on failure"]
token --> meta["meta-capture middleware
records request _meta for trace propagation, never refuses"]
meta --> span
subgraph guard["guard wrapper on the component"]
span["Tracing span
opens an OpenTelemetry span, joins caller trace context"]
span --> audit["Audit
times the call: caller, name, arg shapes, status"]
audit --> kill["Kill switch
refuses disabled tools"]
kill --> rate["Rate limiter
per-caller, per-tool token bucket, Redis-backed"]
rate --> scope["Scope check
caller must hold the scope, else the component is unknown"]
scope --> handler["Handler
input validation, then per-resource authorization,
the guarded action, and content sanitization plus provenance on the read side"]
handler --> boundary["Exception boundary
ToolError re-raised verbatim, anything else masked"]
boundary --> audit2["Audit
emits one structured log line: ok, refused, or error"]
audit2 --> span2["Tracing span
closes with ok or error status"]
end
span2 --> back([client])
```
The scope check is a capability gate (may this caller use this tool at all). The per-resource authorization inside the document tools is a separate, finer gate (may this caller act on this specific document): identity comes from the validated token, the default policy is deny, and a denial is audited as an `AuthorizationError` refusal without echoing the resource.
A refusal at any stage (401, kill switch, rate limit, scope, per-resource authorization, or a validation failure inside the tool) still produces an audit line and a closed span, so nothing is invisible to operators.
## Authentication flow
[Section titled “Authentication flow”](#authentication-flow)
```
sequenceDiagram
participant Client
participant Arrowhead
participant AS as Authorization server
Client->>Arrowhead: request without a token
Arrowhead-->>Client: 401 with a pointer to /.well-known/oauth-protected-resource/mcp (RFC 9728)
Client->>AS: OAuth 2.1 + PKCE flow (not against Arrowhead)
AS-->>Client: bearer token
Client->>Arrowhead: retry with the bearer token
Arrowhead->>Arrowhead: verify signature (JWKS or static key), issuer, expiry, audience, then the tool's required scope
```
Arrowhead issues no tokens and stores no client secrets. It is purely a resource server.
## Module layout
[Section titled “Module layout”](#module-layout)
```plaintext
src/arrowhead/
server.py builds the MCPServer: verifier + cache hints + components
app.py importable facade: call, read_resource, get_prompt
cli.py the arrowhead console script: serve, list-tools
config.py all settings, ARROWHEAD_-prefixed environment vars
errors.py the project ToolError every guard and tool raises
health.py unauthenticated /health and /ready probe routes
runtime/
guards.py per-component guard wrappers + listing filter
auth/
oauth.py resource server wiring + mandatory audience validation
verifier.py in-house JWKS bearer verifier (pyjwt)
scopes.py component -> required scope, split by verb
identity.py caller identity from the validated token only
principal.py in-process principal for the import path
authz/
policy.py default-deny per-resource ABAC + Authorizer seam
enforce.py enforcement point every component calls
confirmation.py Resolve-based confirmation for destructive actions
store/
document_store.py jailed corpus: read, list, stat, atomic write
repo/
store.py jailed, read-only source tree
symbols.py symbol extraction (ast / tree-sitter / heuristic)
ts_symbols.py the optional tree-sitter backend (treesitter extra)
dependencies.py bounded Python import graph
content/
provenance.py untrusted-data wrapping with randomized delimiters
render.py shared format-aware document renderer
chunking.py bounded character windows
code_chunking.py structure-aware chunking for source files
json_safe.py, markdown_safe.py, text_safe.py format sanitizers
tools/
catalog.py the component contract: specs, families, profiles
registry.py registers in-profile components behind the guards
integrity.py the pinned tool-surface digest
search_core.py the shared search runner behind doc_search/code_search
.py one module per tool
connectors/
sql.py vetted read-only SQL over a pooled async engine
pgvector.py pgvector search with server-side tenant isolation
hybrid.py vector + full-text fusion retrieval
pgvector_index.py diff-aware chunk-and-embed ingestion
tasks.py handle-based async tasks and timers, owner-scoped
memory/
base.py the memory backend seam: memories + kv scratchpad
factory.py file backend by default, Postgres when memory_dsn set
file_backend.py jailed JSON records, hashed-owner paths, keyword recall
postgres_backend.py fixed-table SQL, optional pgvector semantic recall
embeddings/
base.py, factory.py the embedding provider seam
deterministic.py offline stdlib embedder for tests and demos
http.py SSRF-guarded OpenAI-compatible embedding client
llm/
base.py, transport.py, anthropic_http.py, openai_http.py, factory.py
the completion provider seam, one hardened HTTP path
exec/
base.py the runner seam: request and outcome
factory.py picks the configured runner
subprocess_runner.py rlimit-bounded, env-scrubbed subprocess
container_runner.py network-none, read-only container runner
workingsets.py owner-scoped working set registry
resources/, prompts/, completions/ the non-tool primitives
security/
ssrf_guard.py resolve, block private ranges, pin; trusted-internal gate
input_validation.py shared allowlist validators
sandbox.py AST arithmetic interpreter (no eval)
search_match.py ReDoS-safe literal / timed-regex matcher
secret_scan.py fixed-pattern secret/PII detection and redaction
rate_limit.py token-bucket limiter, memory or Redis store
kill_switch.py per-component disable
observability/
audit_log.py structured, source-redacted audit line
tracing.py OpenTelemetry span + W3C trace context
telemetry.py OTLP exporter wiring, no-op until configured
metrics.py tool-call count and duration instruments
```
## Deployment shape
[Section titled “Deployment shape”](#deployment-shape)
```
flowchart LR
https([HTTPS]) --> proxy["platform or proxy
TLS termination"]
proxy -->|HTTP| arrowhead["arrowhead
HTTP + disk"]
arrowhead <--> redis[("Redis
rate-limit buckets")]
arrowhead --> disk[("persistent disk
document corpus")]
arrowhead --> oauth["external OAuth 2.1
authorization server, JWKS"]
```
The request path itself is stateless — any instance can serve any request, and the rate-limit buckets live in shared Redis. The durable local state is the write-capable document corpus and, in the default file-backed configuration, the agent memory root, both on a persistent disk. Because a disk attaches to a single instance, the reference deployment runs **one** instance so the corpus stays consistent; scaling the write tier horizontally means moving the corpus behind object storage (a roadmap item) and pointing memory at Postgres (`ARROWHEAD_MEMORY_DSN`, supported today) so the request tier can again run many replicas. See `deploy/` for the container image and the Render and Fly.io blueprints.
Three families carry their own architectural gates. Memory sits behind a backend seam like the embedding and completion providers: the jailed file backend needs no configuration and reports keyword recall, the Postgres backend takes over when `ARROWHEAD_MEMORY_DSN` is set, and semantic recall only engages when the real embedding provider is configured, so a search result’s `recall` label always tells the truth. Timer tasks share the in-process task registry and its documented single-instance limitation: a pending timer does not survive a restart. The notify family is registered only when `ARROWHEAD_NOTIFY_ALLOWLIST` is non-empty (the exec\_enabled pattern) so the default catalog never lists a tool that cannot succeed.
# Contributing
> Development setup, the test layers, and the conventions the repo holds itself to.
## Development setup
[Section titled “Development setup”](#development-setup)
```bash
git clone https://github.com/jagguvarma15/arrowhead.git
cd arrowhead
uv sync --all-extras --dev
uv run pytest tests/ -v
uv run ruff check src/ tests/
```
Python 3.12 or newer. The suite treats every warning as an error, so a new deprecation fails loudly instead of landing silently. Lint runs the `E`, `F`, `I`, `UP`, `B`, and `S` rule sets - the `S` set is flake8-bandit, so security lint is part of ordinary lint.
## The test layers
[Section titled “The test layers”](#the-test-layers)
| Directory | What it covers |
| -------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `tests/unit/` | Each tool and each security module in isolation |
| `tests/security/` | An adversarial corpus of SSRF, injection, and traversal payloads run against every input that could reach a guard |
| `tests/conformance/` | The protocol surface over the HTTP transport, and the golden catalog |
| `tests/integration/` | Postgres and pgvector paths, run when `ARROWHEAD_POSTGRES_TEST_URL` names a live database |
For the integration leg locally:
```bash
docker run -d --name arrowhead-pg -p 5432:5432 -e POSTGRES_PASSWORD=postgres pgvector/pgvector:pg16
ARROWHEAD_POSTGRES_TEST_URL=postgresql+asyncpg://postgres:postgres@localhost/postgres uv run pytest tests/integration/
```
CI provides the same service container, so the integration tests always run on a pull request.
## The golden catalog and frozen docstrings
[Section titled “The golden catalog and frozen docstrings”](#the-golden-catalog-and-frozen-docstrings)
`tests/fixtures/tool_list_golden.json` pins the client-visible tool surface: name, description, input schema, annotations, output-schema presence, and scope for every tool under the default environment. A tool’s docstring is sent to clients verbatim as its wire description, so changing one is a surface change: the conformance test fails, the `arrowhead://integrity` digest shifts, and the fixture must be deliberately regenerated from `arrowhead list-tools --json` projected to the fixture’s shape. Keep docstrings terse, include an `Example:` line, and mind the token budget - a conformance test fails the build if the tool list’s average cost per tool grows past its ceiling.
## Adding a tool
[Section titled “Adding a tool”](#adding-a-tool)
Declare a `ToolSpec` in `src/arrowhead/tools/catalog.py`. The spec will not construct without a scope, a rate-limit setting, and a family, and registration wraps every tool in the same guard chain (span, audit, kill switch, rate limit, scope check, masked errors) - there is no unguarded registration path. A new tool ships with unit tests, adversarial tests for any new input surface, and the regenerated golden fixture.
## Commit convention
[Section titled “Commit convention”](#commit-convention)
Every file changes in its own commit, and the suite stays green at every commit; the rare deliberate exception (a catalog change whose fixture lands in the adjacent commit) is disclosed in the pull-request body. Commit subjects are imperative and plain.
## What CI runs
[Section titled “What CI runs”](#what-ci-runs)
Every pull request runs four workflows: `test` (lock check, lint, the full suite against a pgvector service container, a wheel build smoke), `image` (the production Docker build with a blocking Trivy scan), `security` (pip-audit, Trivy filesystem and config scans, gitleaks, and an SBOM), and the docs build (the site builds with a link validator that fails on any broken internal link, and the reference generator fails on drift between `.env.example` and the Settings model). A release tag additionally publishes the container image to GitHub Container Registry.
## The documentation site
[Section titled “The documentation site”](#the-documentation-site)
The site you are reading builds from three sources: the repository documents under `docs/`, the site pages under `website/src/content/docs/`, and two generated reference pages that never exist on disk. To work on it locally:
```bash
uv run python scripts/gen_reference.py
cd website
npm ci
npm run dev
```
# Deploying a reference instance
> The step-by-step runbook for a live reference deployment.
This runbook stands up a public reference deployment of Arrowhead on Render with WorkOS AuthKit for authentication. It is written for a showcase instance, not a multi-tenant SaaS: one instance, a persistent disk for the corpus, and managed Redis.
## Prerequisites
[Section titled “Prerequisites”](#prerequisites)
* A Render account and this repository connected to it.
* A WorkOS account with an AuthKit project (or any OAuth 2.1 IdP for the `jwt` provider path — Keycloak works well self-hosted).
* The public URL Render will assign (e.g. `https://arrowhead.onrender.com`), or a custom domain. This URL is the server’s **canonical resource URI** and must match the token audience.
## 1. Configure WorkOS AuthKit
[Section titled “1. Configure WorkOS AuthKit”](#1-configure-workos-authkit)
1. In the WorkOS dashboard, create or open an AuthKit project.
2. Note the AuthKit domain (e.g. `https://your-project.authkit.app`).
3. AuthKit serves the OAuth metadata MCP clients discover, so no manual client registration is needed on this server’s side.
The `workos` provider is configuration sugar over the standard JWKS path: it derives the issuer (`https://`) and the JWKS URI (`https:///oauth2/jwks`) from the AuthKit domain, and the server verifies tokens with its in-house verifier exactly as it would for any OAuth 2.1 issuer. AuthKit-specific conveniences beyond token verification and RFC 9728 discovery are not part of this path; pointing `jwt` at the same issuer and JWKS URI is equivalent.
## 2. Deploy on Render
[Section titled “2. Deploy on Render”](#2-deploy-on-render)
The blueprint in `deploy/render.yaml` declares the web service, a managed Redis instance, and a persistent disk for the corpus. Create the service from the blueprint, then set these environment variables (the blueprint marks them `sync: false` so they are entered in the dashboard, not committed):
| Variable | Value |
| -------------------------------- | ------------------------------------------------- |
| `ARROWHEAD_OAUTH_PROVIDER` | `workos` |
| `ARROWHEAD_OAUTH_AUTHKIT_DOMAIN` | your AuthKit domain |
| `ARROWHEAD_SERVER_PUBLIC_URL` | the service’s public URL (canonical resource URI) |
| `ARROWHEAD_ALLOWED_HOSTS` | the public hostname |
| `ARROWHEAD_ALLOWED_ORIGINS` | the public origin |
`ARROWHEAD_REDIS_URL` and `ARROWHEAD_DOCS_ROOT` are wired by the blueprint (the Redis connection string and the disk mount path). Auth is enabled and the transport is HTTP by default in the blueprint.
For the bring-your-own-IdP path instead, set `ARROWHEAD_OAUTH_PROVIDER=jwt` and `ARROWHEAD_OAUTH_ISSUER`, `ARROWHEAD_OAUTH_AUDIENCE` (= the public URL’s resource), and `ARROWHEAD_OAUTH_JWKS_URI`.
Render terminates TLS and routes HTTPS to the container. The health check points at `/health`, which needs no token.
## 3. Verify the live instance
[Section titled “3. Verify the live instance”](#3-verify-the-live-instance)
```bash
# Liveness and readiness (no token needed)
curl https:///health
curl https:///ready
# Discovery metadata (RFC 9728)
curl https:///.well-known/oauth-protected-resource/mcp
```
Then connect an MCP client (MCP Inspector, or Claude) to `https:///mcp`. The client performs the OAuth flow against WorkOS, obtains a token, and can call the tools its scopes allow. Confirm `doc_write` then `doc_read` round-trip.
## 4. Observability (optional)
[Section titled “4. Observability (optional)”](#4-observability-optional)
Point traces and metrics at a collector by setting `ARROWHEAD_OTEL_EXPORTER_OTLP_ENDPOINT` (OTLP/HTTP base URL) and, if needed, `ARROWHEAD_OTEL_EXPORTER_OTLP_HEADERS`. Without an endpoint, telemetry is a no-op. Audit logs are JSON on stdout and can be shipped via Render’s log stream.
## 5. Rollback
[Section titled “5. Rollback”](#5-rollback)
Render keeps previous deploys. To roll back, open the service’s Deploys tab and redeploy the last known-good deploy. Because the corpus lives on the persistent disk (not the image), a rollback does not lose written documents.
## 6. Corpus backup and restore
[Section titled “6. Corpus backup and restore”](#6-corpus-backup-and-restore)
The document corpus is on the Render disk mounted at `/var/lib/arrowhead/documents`.
* **Backup**: use Render’s disk snapshots, or copy the directory out with a one-off shell into the instance (`tar czf - /var/lib/arrowhead/documents`).
* **Restore**: extract a backup into the same path and restart the service.
## 7. Load smoke
[Section titled “7. Load smoke”](#7-load-smoke)
Against a local stack (auth off), sanity-check concurrency and rate limiting:
```bash
docker compose -f deploy/docker-compose.yml up --build -d
uv run python scripts/loadtest.py http://localhost:8000 200
docker compose -f deploy/docker-compose.yml down -v
```
It reports health, readiness, latency percentiles, and how many calls were rate-limited under the burst.
## Choosing a profile
[Section titled “Choosing a profile”](#choosing-a-profile)
Set `ARROWHEAD_PROFILE` to expose only the tool families the deployment serves: `core`, `docs`, `coding`, or `full` (the default). A smaller profile keeps the tool list that rides in every connected model’s context lean. A `coding` deployment must also set `ARROWHEAD_REPO_ROOT` to the source tree the repo tools serve (read-only, mounted into the container); without it the repo jail points at a `repo` directory that does not exist and every repo tool fails.
## Enabling sandboxed execution
[Section titled “Enabling sandboxed execution”](#enabling-sandboxed-execution)
The `exec` family is doubly gated. Set `ARROWHEAD_EXEC_ENABLED=true` and grant the `execute` action in the authorization policy; both are required. Prefer the container runner in production: set `ARROWHEAD_EXEC_RUNNER=container` and `ARROWHEAD_EXEC_CONTAINER_IMAGE` to an image with the tools a snippet needs. The container runner adds `--network none` and a read-only root filesystem that the default subprocess runner cannot; the subprocess runner bounds resources but leaves network and filesystem reads to OS permissions. `run_tests` also needs `ARROWHEAD_EXEC_TEST_COMMAND` set to the argv that runs the suite inside the copied subtree.
## Fronting a local model
[Section titled “Fronting a local model”](#fronting-a-local-model)
To back the assist tools with a local model server (Ollama, vLLM, LM Studio), set `ARROWHEAD_LLM_PROVIDER=openai`, point `ARROWHEAD_LLM_ENDPOINT` at its OpenAI-compatible chat-completions URL, and name its exact `host:port` in `ARROWHEAD_LLM_INTERNAL_HOSTS` so the SSRF guard admits it. For a hosted model, use `ARROWHEAD_LLM_PROVIDER=anthropic` (or `openai` against a cloud endpoint) and supply `ARROWHEAD_LLM_API_KEY` out of band.
## Scaling note
[Section titled “Scaling note”](#scaling-note)
A persistent disk attaches to a single instance, so the reference deployment runs one instance and the write corpus stays consistent. Scaling the request tier to many replicas means moving the corpus behind object storage. The task and working-set registries are likewise in-process today; a shared backend (the rate limiter already uses Redis) is the seam for a multi-instance deployment.
# Coding agent walkthrough
> Exercise the coding surface through the importable facade, no server needed.
A single script that exercises the coding surface against the tiny sample repository in its directory, with no server and no database. Every call goes through the importable `Arrowhead` facade, which runs the identical guarded path an HTTP client reaches, so this doubles as a smoke test of the coding families. The runnable source lives in [`examples/coding_agent/`](https://github.com/jagguvarma15/arrowhead/tree/main/examples/coding_agent); run it from a repository checkout:
```bash
uv run python examples/coding_agent/run.py
```
It demonstrates, in order:
* `symbol_map` over the repository, listing each definition and its line span.
* `code_read` of a line range, returned inside the untrusted framing.
* `workingset_update` pinning a file into a named working set.
* `pack_context` assembling a token-budgeted, secret-scanned, provenance-stamped bundle from the pinned file plus retrieval.
* `run_snippet` executing a self-contained snippet in the sandbox and returning its bounded, framed output.
The script constructs `Settings(profile="coding", repo_root=..., exec_enabled=True)`, so it shows how a deployment selects the coding profile and turns execution on. A real HTTP deployment would additionally grant the `execute` action in its authorization policy; here auth is off, so the allow-all authorizer stands in.
# Docs RAG walkthrough
> Chunk documents, embed them, and answer a query with cited chunks.
Two ways to see the retrieval loop - chunk documents, embed them, and answer a query with cited chunks. The runnable source lives in [`examples/docs_rag/`](https://github.com/jagguvarma15/arrowhead/tree/main/examples/docs_rag); run the commands from a repository checkout.
## Offline, no server and no database
[Section titled “Offline, no server and no database”](#offline-no-server-and-no-database)
```bash
uv run python examples/docs_rag/run_offline.py
```
This chunks the `corpus/` documents, embeds them with the deterministic provider, and prints the chunks most similar to a query, each with its source and chunk index. The deterministic embedder is not semantic, so treat the ranking as illustrative of the shape, not the quality.
## Real, with Postgres and a real embedder
[Section titled “Real, with Postgres and a real embedder”](#real-with-postgres-and-a-real-embedder)
1. Apply the schema once (the vector width must match `ARROWHEAD_EMBEDDING_DIMENSIONS`):
```bash
psql "$WRITE_DSN" -f deploy/pgvector_schema.sql
```
2. Configure a real embedding provider and the read and write credentials. Enabling auth requires the OAuth settings too (issuer, audience, the server’s public URL, and a JWKS URI or public key; see the [deployment runbook](/arrowhead/deployment/)), or the server refuses to start:
```bash
export ARROWHEAD_AUTH_ENABLED=true
export ARROWHEAD_OAUTH_ISSUER=https://auth.example
export ARROWHEAD_OAUTH_AUDIENCE=https://mcp.example
export ARROWHEAD_OAUTH_JWKS_URI=https://auth.example/.well-known/jwks.json
export ARROWHEAD_SERVER_PUBLIC_URL=https://mcp.example
export ARROWHEAD_SQL_DSN=postgresql+asyncpg://reader@host/db
export ARROWHEAD_VECTOR_WRITE_DSN=postgresql+asyncpg://writer@host/db
export ARROWHEAD_PGVECTOR_COLLECTIONS=doc_chunks
export ARROWHEAD_EMBEDDING_PROVIDER=http
export ARROWHEAD_EMBEDDING_ENDPOINT=https://api.example/v1/embeddings
export ARROWHEAD_EMBEDDING_API_KEY=...
export ARROWHEAD_EMBEDDING_DIMENSIONS=1536
export ARROWHEAD_EGRESS_ALLOWED_HOSTS=api.example
```
3. Grant the caller the `ingest` action, which the default policy denies, via `ARROWHEAD_AUTHZ_POLICY`:
```json
{"grants": [{"subject": "*", "actions": ["ingest", "query"],
"kinds": ["table"], "prefix": "doc_chunks"}]}
```
4. Call `doc_index(collection="doc_chunks")` to index the corpus, then `vector_query(collection="doc_chunks", query="how long do refunds take?")`. Each result carries the source document and chunk index it came from.
The write role should be limited to `INSERT`, `UPDATE`, `DELETE`, and `SELECT` on the chunks table (a re-index upserts unchanged rows, so `UPDATE` is required); the read tools use the read-only `ARROWHEAD_SQL_DSN`.
# FAQ
> Common failure modes and the design decisions behind them.
## A tool I expect is not in the tool list
[Section titled “A tool I expect is not in the tool list”](#a-tool-i-expect-is-not-in-the-tool-list)
Four gates decide what registers, in order: the profile (`ARROWHEAD_PROFILE` - a tool outside the active profile is never registered), the family gates (`exec` needs `ARROWHEAD_EXEC_ENABLED=true`, `notify` needs a non-empty `ARROWHEAD_NOTIFY_ALLOWLIST`), and the kill switch (`ARROWHEAD_DISABLED_TOOLS` takes named tools out of service without a redeploy). `arrowhead list-tools` prints exactly what your configuration serves.
## I granted the scope but calls are still denied
[Section titled “I granted the scope but calls are still denied”](#i-granted-the-scope-but-calls-are-still-denied)
A scope is necessary but not sufficient. Every call also passes a server-side per-resource check that is default-deny: the small JSON grant list in `ARROWHEAD_AUTHZ_POLICY` (or the safe default policy when it is empty) must allow the action on that resource. Two actions are deliberately absent from the default grants - `execute` and `notify` - so those families need an explicit grant on top of their family gates. The default memory grant is restricted to memory resources, so it can never widen document access.
## Calls fail with a rate-limit error
[Section titled “Calls fail with a rate-limit error”](#calls-fail-with-a-rate-limit-error)
Every tool has a per-caller per-minute ceiling, configurable per tool (the `*_PER_MINUTE` variables). Without Redis the buckets are per process; set `ARROWHEAD_REDIS_URL` to share them across replicas so a caller cannot multiply its budget by the replica count.
## memory\_search labels its recall “keyword” when I expected semantic
[Section titled “memory\_search labels its recall “keyword” when I expected semantic”](#memory_search-labels-its-recall-keyword-when-i-expected-semantic)
The label is honest by design. Semantic recall needs both `ARROWHEAD_MEMORY_DSN` pointing at Postgres (with `deploy/memory_schema.sql` applied) and `ARROWHEAD_EMBEDDING_PROVIDER=http` with a real endpoint. The file backend, and the Postgres backend under the deterministic embedder, search by keyword and say so rather than fake a similarity ranking.
## My scheduled timers vanished after a restart
[Section titled “My scheduled timers vanished after a restart”](#my-scheduled-timers-vanished-after-a-restart)
Timers share the in-process task registry and do not survive a restart - the `task_schedule` description states this. Durable schedules need external state and are out of scope for the in-process registry; treat timers as best-effort reminders within one server lifetime.
## The server refuses to start over HTTP
[Section titled “The server refuses to start over HTTP”](#the-server-refuses-to-start-over-http)
That is the safe default, not a bug. HTTP with `ARROWHEAD_AUTH_ENABLED=false` exposes every tool with no check, so startup refuses unless `ARROWHEAD_ALLOW_INSECURE_HTTP=true` opts in for a trusted-network test. With auth enabled, the OAuth settings must be complete (issuer, audience, the server’s public URL, and a JWKS URI or public key). A malformed `ARROWHEAD_NOTIFY_ALLOWLIST` entry also fails startup by design, so a typo cannot silently widen egress.
## sql\_query or vector\_search says the connector is not configured
[Section titled “sql\_query or vector\_search says the connector is not configured”](#sql_query-or-vector_search-says-the-connector-is-not-configured)
Empty DSNs leave a connector unconfigured and its tool refuses to run. Reads use `ARROWHEAD_SQL_DSN` (a read-only role is the recommended credential); vector ingestion needs the separate write-capable `ARROWHEAD_VECTOR_WRITE_DSN`; vector search also needs `ARROWHEAD_PGVECTOR_COLLECTIONS` to name the searchable tables. The connectors need the packaging extras: `uv sync --extra sql --extra postgres`.
## Why is the package not on PyPI
[Section titled “Why is the package not on PyPI”](#why-is-the-package-not-on-pypi)
The `arrowhead` name on PyPI belongs to an unrelated project. Install from a checkout or a git URL, or run the container image a release tag publishes to `ghcr.io/jagguvarma15/arrowhead`.
## What is arrowhead://integrity for
[Section titled “What is arrowhead://integrity for”](#what-is-arrowheadintegrity-for)
It is a resource carrying a digest of the tool surface a client consented to. A client pins it and can detect any later change - a renamed tool, an edited description, a schema change - before trusting the surface again.
## Can an agent read these docs
[Section titled “Can an agent read these docs”](#can-an-agent-read-these-docs)
Yes. The whole site ships as [llms.txt](https://jagguvarma15.github.io/arrowhead/llms.txt) and [llms-full.txt](https://jagguvarma15.github.io/arrowhead/llms-full.txt), and every plain-markdown page has a raw mirror at its path with `.md` appended.
# Getting started
> Install arrowhead, pick a profile, and make the first guarded call.
Arrowhead needs Python 3.12 or newer and [uv](https://docs.astral.sh/uv/), or just Docker. The package is not on PyPI (that name belongs to an unrelated project), so install it from a checkout or a git URL; deployments run the container image.
## Install and run
[Section titled “Install and run”](#install-and-run)
* uv
```bash
git clone https://github.com/jagguvarma15/arrowhead.git
cd arrowhead
uv sync
uv run python -m arrowhead.server
```
* Docker
```bash
docker run --rm -i -e ARROWHEAD_TRANSPORT=stdio \
ghcr.io/jagguvarma15/arrowhead:0.2.832
```
That serves over stdio, ready for a pipe-connected client. Auth is off in stdio mode, so every tool in the active profile is immediately callable. The MCP Inspector gives you an interactive console:
```bash
npx @modelcontextprotocol/inspector uv run python -m arrowhead.server
```
Only the `data` family needs packaging extras (the parser and database drivers); the rest runs on the base install:
```bash
uv sync --extra sql --extra postgres # SQL and pgvector connectors
uv sync --extra treesitter # exact non-Python symbol extraction
```
## First calls
[Section titled “First calls”](#first-calls)
Try `calculate` with `2 * (3 + 4)`, or `read_file` with `welcome.txt` (a sample lives in `sandbox/`). The command line shows the surface you are serving:
```bash
uv run arrowhead list-tools # each tool and the scope it requires
uv run arrowhead list-tools --json # the full client-visible catalog with schemas
```
## Pick a profile
[Section titled “Pick a profile”](#pick-a-profile)
A deployment sets `ARROWHEAD_PROFILE` so a connection carries only the tool families it needs; a tool outside the active profile is never registered, so it costs no context and a call to it is unknown.
| Profile | Serves |
| ---------------- | --------------------------------------------------------------------- |
| `core` | The general-purpose utilities only |
| `docs` | The document corpus, data connectors, tasks, memory, and notify |
| `coding` | The repo, assist, exec, and context families over the data connectors |
| `full` (default) | Every family |
Two families gate a second time regardless of profile: `exec` registers only when `ARROWHEAD_EXEC_ENABLED` is true, and `notify` only when `ARROWHEAD_NOTIFY_ALLOWLIST` is non-empty. The [tool reference](/arrowhead/reference/tools/) lists every family and tool; the [configuration reference](/arrowhead/reference/configuration/) lists every variable.
## Serve over HTTP
[Section titled “Serve over HTTP”](#serve-over-http)
```bash
docker compose -f deploy/docker-compose.yml up
```
That serves a streamable-HTTP endpoint on `http://localhost:8000/mcp` with a shared rate-limit store.
Caution
Serving HTTP with auth disabled exposes every tool with no check, so the server refuses to start that way unless `ARROWHEAD_ALLOW_INSECURE_HTTP` is set for a trusted-network test. Anywhere public, enable auth and set the OAuth variables first.
The [deployment runbook](/arrowhead/deployment/) walks through a live instance end to end, and each release tag publishes the image to `ghcr.io/jagguvarma15/arrowhead`.
## Connect your client
[Section titled “Connect your client”](#connect-your-client)
[Integrations](/arrowhead/integrations/) has tabbed, copy-paste instructions for Claude Desktop, Claude Code, Cursor, VS Code, the Claude Agent SDK, agent-scaffold projects, and any generic MCP client, plus one-click install links for Cursor and VS Code.
## Use it as a library
[Section titled “Use it as a library”](#use-it-as-a-library)
`Arrowhead.call` and the HTTP transport route through the same dispatch, so an imported call runs the identical authorization, sanitization, and middleware path as a call over the wire:
```python
from arrowhead import Arrowhead
app = Arrowhead()
with app.as_principal("service:etl", {"docs:read"}):
result = await app.call("doc_read", {"path": "notes.md"})
document = await app.read_resource("doc://notes.md")
```
A call with no principal is anonymous, and every scoped component is denied, so the guarded path is the default whichever door a call comes through.
## Where next
[Section titled “Where next”](#where-next)
* [Architecture](/arrowhead/architecture/) - how a request flows from auth to the audit log
* [Security model](/arrowhead/security/) - what each guard closes and how
* [Examples](/arrowhead/examples/docs-rag/) - runnable RAG and coding-agent walkthroughs
# Connecting a client
> Per-client setup for Claude, Cursor, VS Code, the Agent SDK, and any MCP client.
Arrowhead speaks the Model Context Protocol over two transports: stdio for local use and streamable HTTP for a deployed server. Local stdio runs with auth off, so every tool is immediately callable; a deployed HTTP server verifies an OAuth 2.1 bearer token and enforces per-caller scopes. Pick the transport your client supports and give it the command or URL below.
The examples run the server from a source checkout with `uv`. Replace `/path/to/arrowhead` with your checkout, or swap the command for the published container image: `docker run --rm -i -e ARROWHEAD_TRANSPORT=stdio ghcr.io/jagguvarma15/arrowhead:0.2.832`.
Set `ARROWHEAD_PROFILE` to expose only the tool families a client needs: a coding host wants `coding`, a documentation service `docs`, and the default `full` exposes everything. A smaller profile means fewer tools in the client’s context. The `exec` family stays hidden until `ARROWHEAD_EXEC_ENABLED` is set, even under the `coding` or `full` profile.
## One-click install
[Section titled “One-click install”](#one-click-install)
Both links register the published container image over stdio; Docker pulls it on first run.
[](cursor://anysphere.cursor-deeplink/mcp/install?name=arrowhead\&config=eyJjb21tYW5kIjogImRvY2tlciIsICJhcmdzIjogWyJydW4iLCAiLS1ybSIsICItaSIsICItZSIsICJBUlJPV0hFQURfVFJBTlNQT1JUPXN0ZGlvIiwgImdoY3IuaW8vamFnZ3V2YXJtYTE1L2Fycm93aGVhZDowLjIuODMyIl19) [](vscode:mcp/install?%7B%22name%22%3A%20%22arrowhead%22%2C%20%22command%22%3A%20%22docker%22%2C%20%22args%22%3A%20%5B%22run%22%2C%20%22--rm%22%2C%20%22-i%22%2C%20%22-e%22%2C%20%22ARROWHEAD_TRANSPORT%3Dstdio%22%2C%20%22ghcr.io%2Fjagguvarma15%2Farrowhead%3A0.2.832%22%5D%7D)
## Per-client setup
[Section titled “Per-client setup”](#per-client-setup)
* Claude Code
Register the server with the CLI:
```bash
claude mcp add arrowhead -- uv run python -m arrowhead.server
```
For a deployed HTTP server, add it by URL and supply the bearer token:
```bash
claude mcp add --transport http arrowhead https://arrowhead.example/mcp \
--header "Authorization: Bearer $TOKEN"
```
* Claude Desktop
Add an entry to the Claude Desktop configuration file (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):
```json
{
"mcpServers": {
"arrowhead": {
"command": "uv",
"args": ["run", "python", "-m", "arrowhead.server"],
"cwd": "/path/to/arrowhead"
}
}
}
```
Restart Claude Desktop; the Arrowhead tools appear in the tools menu.
* Cursor
Create `.cursor/mcp.json` in the project (or the global `~/.cursor/mcp.json`):
```json
{
"mcpServers": {
"arrowhead": {
"command": "uv",
"args": ["run", "python", "-m", "arrowhead.server"],
"cwd": "/path/to/arrowhead"
}
}
}
```
* VS Code
Create `.vscode/mcp.json` in the workspace:
```json
{
"servers": {
"arrowhead": {
"type": "stdio",
"command": "uv",
"args": ["run", "python", "-m", "arrowhead.server"],
"cwd": "/path/to/arrowhead"
}
}
}
```
* Agent SDK
The Claude Agent SDK (Python) connects to an MCP server defined in its options. The exact field names track the SDK version, so check its docs; the shape is:
```python
from claude_agent_sdk import ClaudeAgentOptions, query
options = ClaudeAgentOptions(
mcp_servers={
"arrowhead": {
"type": "stdio",
"command": "uv",
"args": ["run", "python", "-m", "arrowhead.server"],
}
},
allowed_tools=[
"mcp__arrowhead__vector_query",
"mcp__arrowhead__doc_read",
],
)
async for message in query(prompt="Summarize the refund policy", options=options):
print(message)
```
To reach a deployed server instead, point the entry at the HTTP URL and pass the bearer token in a header rather than a stdio command.
* Generic HTTP
Any client that speaks streamable HTTP connects to the `/mcp` endpoint of a deployed server and authenticates with a bearer token:
```plaintext
POST https://arrowhead.example/mcp
Authorization: Bearer
```
A client with no token receives 401 and a pointer to `/.well-known/oauth-protected-resource/mcp` (RFC 9728), which names the authorization server to complete an OAuth 2.1 flow against. Arrowhead issues no tokens itself; it only verifies them. The caller sees only the tools whose scope its token holds.
A client that speaks the sessionless 2026-07-28 protocol sends its requests with the reserved `_meta` envelope and no `initialize` handshake; a handshake-era client sends `initialize` first. The same endpoint serves both.
## agent-scaffold projects
[Section titled “agent-scaffold projects”](#agent-scaffold-projects)
A project generated by the agent-scaffold CLI consumes arrowhead as a streamable-HTTP MCP server in its local docker stack: the `docs` profile, `ARROWHEAD_TRANSPORT=http`, and, because every published port is bound to loopback, auth off with `ARROWHEAD_ALLOW_INSECURE_HTTP=true`. The scaffold writes the servers a recipe declares into an `mcp.json` registry at the generated project’s root; the generated agent reads that file at boot and registers each server with its framework’s MCP support. No entry carries a secret value, only env var names. A deployed stack replaces the loopback arrangement with the OAuth 2.1 setup, unchanged.
`arrowhead list-tools --json` prints the exact catalog such an agent will see under the active profile, including input schemas, for recipe authors who want to pin or document the surface.
## Pinning the tool surface
[Section titled “Pinning the tool surface”](#pinning-the-tool-surface)
A client that pins the tool surface it consented to can read the `arrowhead://integrity` resource, which returns a sha256 digest of the enabled tools, and compare it each session to detect a later change.
## A note on scopes
[Section titled “A note on scopes”](#a-note-on-scopes)
Over HTTP the token’s scopes decide which tools are visible. The retrieval tools need `vector:search` (for `vector_query` and `hybrid_query`) and `vector:write` (for `doc_index`), and `doc_index` additionally requires the `ingest` action to be granted in the per-resource policy, which the default policy denies. The coding families use `repo:read` and `repo:search` for the repository tools, `assist:run` for the model-backed helpers, `exec:run` plus the `execute` action for sandboxed execution, and `context:read` and `context:write` for the packer and working sets. The [tool reference](/arrowhead/reference/tools/) carries the full tool-to-scope mapping, and the [security model](/arrowhead/security/) describes the authorization model.
# Capability matrix
> The MCP protocol surface arrowhead serves, with live catalog counts.
What a connected client can rely on, generated from the same catalog that registers the server. Counts include the gated families (exec and notify), which register only when their gates open.
| Capability | What arrowhead serves |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| Tools | 38 across 10 families, every one with structured output, behavior annotations, and a required OAuth scope |
| Resources | 2 static resources and 1 resource template, including the `arrowhead://integrity` surface digest |
| Prompts | 2, each scope-guarded like a tool |
| Argument completions | Served for prompt and template arguments, rate-limited per caller |
| Elicitation | Destructive document writes confirm through the client when it supports elicitation; an explicit flag stands in when it cannot |
| Cache hints | List results and resource reads carry private-scope TTLs in `_meta` |
| Protocol eras | One endpoint serves the sessionless 2026-07-28 protocol and handshake-era clients that send `initialize` |
| Transports | stdio for local pipes and streamable HTTP for deployments |
| Authorization | OAuth 2.1 resource server (RFC 9728 discovery), scopes split by verb, plus a default-deny per-resource policy engine |
| Tasks | Handle-based asynchronous scans and timer schedules, owner-scoped |
Profiles select families; the [tool reference](/arrowhead/reference/tools/) carries the per-family breakdown and the [configuration reference](/arrowhead/reference/configuration/) every gate and ceiling. Not implemented, by design or not yet: sampling, resource subscriptions, list pagination, and progress notifications are optional protocol features a catalog of this size does not need.
# Configuration reference
> Every environment variable with its real default, section by section.
Every setting is an environment variable with the `ARROWHEAD_` prefix; a local `.env` file is honored for development. The prose here mirrors the annotated [`.env.example`](https://github.com/jagguvarma15/arrowhead/blob/main/.env.example) in the repository, and the defaults shown are the values the server uses when a variable is unset.
## Profile
[Section titled “Profile”](#profile)
Which tool families this deployment exposes: core, docs, coding, or full. A tool outside the active profile is never registered, so it costs no context and a call to it is unknown. The exec family is gated a second time behind ARROWHEAD\_EXEC\_ENABLED even under coding or full.
| Variable | Default |
| ------------------- | ------- |
| `ARROWHEAD_PROFILE` | `full` |
## Transport
[Section titled “Transport”](#transport)
stdio for Inspector over a pipe; http to serve the streamable endpoint.
| Variable | Default |
| -------------------------- | ----------- |
| `ARROWHEAD_TRANSPORT` | `stdio` |
| `ARROWHEAD_HOST` | `127.0.0.1` |
| `ARROWHEAD_PORT` | `8000` |
| `ARROWHEAD_STATELESS_HTTP` | `true` |
Comma-separated Host and Origin allowlists for the HTTP endpoint. Set these to your public hostname when deploying behind a proxy.
| Variable | Default |
| --------------------------- | --------- |
| `ARROWHEAD_ALLOWED_HOSTS` | *(empty)* |
| `ARROWHEAD_ALLOWED_ORIGINS` | *(empty)* |
## Auth (OAuth 2.1 resource server)
[Section titled “Auth (OAuth 2.1 resource server)”](#auth-oauth-21-resource-server)
Leave disabled only for local stdio development. The server’s public URL is always required when enabled.
| Variable | Default |
| ------------------------ | ------- |
| `ARROWHEAD_AUTH_ENABLED` | `false` |
Serving HTTP with auth disabled exposes every tool with no check; the server refuses to start that way unless this is set for a trusted-network test.
| Variable | Default |
| ------------------------------- | --------- |
| `ARROWHEAD_ALLOW_INSECURE_HTTP` | `false` |
| `ARROWHEAD_SERVER_PUBLIC_URL` | *(unset)* |
“jwt” verifies against any OAuth 2.1 issuer (bring-your-own-IdP, e.g. Keycloak); “workos” wires WorkOS AuthKit, purpose-built for MCP.
| Variable | Default |
| -------------------------- | ------- |
| `ARROWHEAD_OAUTH_PROVIDER` | `jwt` |
jwt provider: issuer, audience (= the canonical resource URI, must equal SERVER\_PUBLIC\_URL’s resource), and one key source (JWKS URI preferred).
| Variable | Default |
| ---------------------------- | --------- |
| `ARROWHEAD_OAUTH_ISSUER` | *(unset)* |
| `ARROWHEAD_OAUTH_AUDIENCE` | *(unset)* |
| `ARROWHEAD_OAUTH_JWKS_URI` | *(unset)* |
| `ARROWHEAD_OAUTH_PUBLIC_KEY` | *(unset)* |
workos provider: the AuthKit domain from the WorkOS dashboard. AuthKit serves the OAuth metadata MCP clients need for automatic registration.
| Variable | Default |
| -------------------------------- | --------- |
| `ARROWHEAD_OAUTH_AUTHKIT_DOMAIN` | *(unset)* |
## Per-resource authorization (document tools)
[Section titled “Per-resource authorization (document tools)”](#per-resource-authorization-document-tools)
JSON policy applied on top of scopes. Empty uses a safe default: any authenticated caller may search, read, and scan the corpus, but write only within its own “\/” namespace. Ignored when auth is off. Example:
```plaintext
ARROWHEAD_AUTHZ_POLICY={"grants":[{"subject":"*","actions":["read","search","scan"],"prefix":""},{"subject":"*","actions":["write"],"prefix":"${subject}/"}]}
```
| Variable | Default |
| ------------------------ | --------- |
| `ARROWHEAD_AUTHZ_POLICY` | *(empty)* |
## read\_file jail
[Section titled “read\_file jail”](#read_file-jail)
The only directory read\_file may read from.
| Variable | Default |
| ------------------------------- | --------- |
| `ARROWHEAD_JAIL_ROOT` | `sandbox` |
| `ARROWHEAD_READ_FILE_MAX_BYTES` | `1000000` |
## Document corpus (doc\_\* tools)
[Section titled “Document corpus (doc\_\* tools)”](#document-corpus-doc_-tools)
The jailed root the document tools operate on, separate from the read-only read\_file sandbox. Only these extensions are documents.
| Variable | Default |
| ---------------------------------- | ---------------- |
| `ARROWHEAD_DOCS_ROOT` | `documents` |
| `ARROWHEAD_DOC_MAX_BYTES` | `1000000` |
| `ARROWHEAD_DOC_ALLOWED_EXTENSIONS` | `.json,.md,.txt` |
Content hardening caps applied to returned document content.
| Variable | Default |
| ----------------------------- | --------- |
| `ARROWHEAD_CONTENT_MAX_BYTES` | `1000000` |
| `ARROWHEAD_JSON_MAX_DEPTH` | `64` |
| `ARROWHEAD_JSON_MAX_ELEMENTS` | `100000` |
## doc\_search
[Section titled “doc\_search”](#doc_search)
Regex is off by default (denial-of-service surface); when enabled it runs through a ReDoS-resistant engine with a hard timeout.
| Variable | Default |
| ------------------------------------ | -------- |
| `ARROWHEAD_SEARCH_QUERY_MAX_LENGTH` | `200` |
| `ARROWHEAD_SEARCH_REGEX_ENABLED` | `false` |
| `ARROWHEAD_SEARCH_REGEX_TIMEOUT_MS` | `250` |
| `ARROWHEAD_SEARCH_MAX_FILES` | `2000` |
| `ARROWHEAD_SEARCH_MAX_RESULTS` | `50` |
| `ARROWHEAD_SEARCH_MAX_TOTAL_BYTES` | `200000` |
| `ARROWHEAD_SEARCH_SNIPPET_MAX_CHARS` | `200` |
## doc\_scan
[Section titled “doc\_scan”](#doc_scan)
| Variable | Default |
| ----------------------------------- | --------- |
| `ARROWHEAD_SCAN_MAX_FILES` | `2000` |
| `ARROWHEAD_SCAN_PER_FILE_MAX_BYTES` | `1000000` |
| `ARROWHEAD_SCAN_TIMEOUT_SECONDS` | `10.0` |
| `ARROWHEAD_SCAN_MAX_FINDINGS` | `200` |
## doc\_write
[Section titled “doc\_write”](#doc_write)
| Variable | Default |
| --------------------------------- | ---------- |
| `ARROWHEAD_DOC_WRITE_MAX_BYTES` | `1000000` |
| `ARROWHEAD_DOC_WRITE_QUOTA_BYTES` | `50000000` |
Overwriting asks for confirmation via elicitation; when the client cannot elicit, the explicit overwrite flag stands in as the opt-in.
| Variable | Default |
| -------------------------------------- | ------- |
| `ARROWHEAD_REQUIRE_WRITE_CONFIRMATION` | `true` |
## safe\_fetch
[Section titled “safe\_fetch”](#safe_fetch)
| Variable | Default |
| ------------------------------------ | --------- |
| `ARROWHEAD_FETCH_TIMEOUT_SECONDS` | `10.0` |
| `ARROWHEAD_FETCH_MAX_RESPONSE_BYTES` | `1000000` |
| `ARROWHEAD_FETCH_MAX_REDIRECTS` | `3` |
## Egress allowlist (safe\_fetch, doc\_retrieve)
[Section titled “Egress allowlist (safe\_fetch, doc\_retrieve)”](#egress-allowlist-safe_fetch-doc_retrieve)
Comma-separated hostnames the fetch and retrieve tools may reach, enforced on every redirect hop. Empty allows any public host; the SSRF guard blocks private ranges regardless. Set this to restrict outbound requests to approved destinations.
| Variable | Default |
| -------------------------------- | --------- |
| `ARROWHEAD_EGRESS_ALLOWED_HOSTS` | *(empty)* |
Extra ports permitted beyond 80 and 443 (comma-separated). Empty allows only the two web ports, so a public host cannot be used to reach an internal service on a non-web port.
| Variable | Default |
| -------------------------------- | --------- |
| `ARROWHEAD_EGRESS_ALLOWED_PORTS` | *(empty)* |
## calculate
[Section titled “calculate”](#calculate)
| Variable | Default |
| --------------------------------- | ------- |
| `ARROWHEAD_EXPRESSION_MAX_LENGTH` | `200` |
## SQL connector (sql extra)
[Section titled “SQL connector (sql extra)”](#sql-connector-sql-extra)
The read tool runs a single vetted read-only statement against this database. The DSN is a SQLAlchemy async URL. Empty leaves the connector unconfigured and the tool refuses to run. A read-only database role is the recommended credential; the connector also runs each query in a read-only transaction under a server-side statement timeout where supported.
```plaintext
ARROWHEAD_SQL_DSN=postgresql+asyncpg://readonly@db.example.com/app
```
| Variable | Default |
| ------------------- | --------- |
| `ARROWHEAD_SQL_DSN` | *(empty)* |
The dialect the guard parses against; empty infers it from the DSN driver.
| Variable | Default |
| -------------------------------- | --------- |
| `ARROWHEAD_SQL_DIALECT` | *(empty)* |
| `ARROWHEAD_SQL_QUERY_MAX_LENGTH` | `2000` |
| `ARROWHEAD_SQL_MAX_ROWS` | `1000` |
| `ARROWHEAD_SQL_MAX_BYTES` | `1000000` |
| `ARROWHEAD_SQL_MAX_COLUMNS` | `100` |
| `ARROWHEAD_SQL_TIMEOUT_SECONDS` | `10.0` |
## pgvector search (postgres extra), over the SQL DSN database
[Section titled “pgvector search (postgres extra), over the SQL DSN database”](#pgvector-search-postgres-extra-over-the-sql-dsn-database)
Only these collections (comma-separated table names) may be searched, and the tenant filter is the authenticated caller, never an argument. Empty leaves the search tool unconfigured. The column names default to a common convention and are overridable per deployment.
| Variable | Default |
| ------------------------------------- | ----------- |
| `ARROWHEAD_PGVECTOR_COLLECTIONS` | *(empty)* |
| `ARROWHEAD_PGVECTOR_TENANT_COLUMN` | `tenant` |
| `ARROWHEAD_PGVECTOR_ID_COLUMN` | `id` |
| `ARROWHEAD_PGVECTOR_CONTENT_COLUMN` | `content` |
| `ARROWHEAD_PGVECTOR_EMBEDDING_COLUMN` | `embedding` |
Provenance columns written by doc\_index and returned by vector\_query so a retrieved chunk carries its source document and position as a citation.
| Variable | Default |
| --------------------------------------- | ------------- |
| `ARROWHEAD_PGVECTOR_SOURCE_COLUMN` | `source` |
| `ARROWHEAD_PGVECTOR_CHUNK_INDEX_COLUMN` | `chunk_index` |
content\_hash powers diff-aware re-indexing; content\_tsv powers the full-text branch of hybrid\_query. Both are added by deploy/pgvector\_schema.sql.
| Variable | Default |
| ---------------------------------------- | -------------- |
| `ARROWHEAD_PGVECTOR_CONTENT_HASH_COLUMN` | `content_hash` |
| `ARROWHEAD_PGVECTOR_TSVECTOR_COLUMN` | `content_tsv` |
| `ARROWHEAD_PGVECTOR_MAX_K` | `50` |
| `ARROWHEAD_PGVECTOR_MAX_DIMENSIONS` | `2000` |
## Embeddings (used by doc\_index and vector\_query)
[Section titled “Embeddings (used by doc\_index and vector\_query)”](#embeddings-used-by-doc_index-and-vector_query)
“deterministic” is a stdlib, offline, non-semantic provider for tests and local development. “http” posts to an OpenAI-compatible endpoint through the SSRF guard; the endpoint host must be on the egress allowlist and the key is supplied here, never forwarded to any other host.
| Variable | Default |
| ---------------------------------------- | --------------- |
| `ARROWHEAD_EMBEDDING_PROVIDER` | `deterministic` |
| `ARROWHEAD_EMBEDDING_DIMENSIONS` | `1536` |
| `ARROWHEAD_EMBEDDING_ENDPOINT` | *(empty)* |
| `ARROWHEAD_EMBEDDING_MODEL` | *(empty)* |
| `ARROWHEAD_EMBEDDING_API_KEY` | *(empty)* |
| `ARROWHEAD_EMBEDDING_BATCH_SIZE` | `64` |
| `ARROWHEAD_EMBEDDING_TIMEOUT_SECONDS` | `30.0` |
| `ARROWHEAD_EMBEDDING_MAX_TEXTS` | `10000` |
| `ARROWHEAD_EMBEDDING_MAX_RESPONSE_BYTES` | `8000000` |
## Vector ingestion (doc\_index)
[Section titled “Vector ingestion (doc\_index)”](#vector-ingestion-doc_index)
Writing chunks needs a write-capable credential, kept separate from the read-only SQL DSN so the read tools stay least privilege; ingestion refuses to run until it is set. The caps bound how much one call may index.
| Variable | Default |
| ---------------------------------------- | --------- |
| `ARROWHEAD_VECTOR_WRITE_DSN` | *(empty)* |
| `ARROWHEAD_VECTOR_INDEX_MAX_FILES` | `500` |
| `ARROWHEAD_VECTOR_INDEX_MAX_CHUNKS` | `5000` |
| `ARROWHEAD_VECTOR_INDEX_CHUNK_MAX_CHARS` | `1500` |
| `ARROWHEAD_VECTOR_INDEX_CHUNK_OVERLAP` | `200` |
| `ARROWHEAD_VECTOR_INDEX_TIMEOUT_SECONDS` | `30.0` |
Source files chunk along their structure with this wider window. A re-index re-embeds only chunks whose content hash changed; set reuse false to restore the wholesale delete-and-insert on a schema without the content\_hash column.
| Variable | Default |
| --------------------------------- | ------- |
| `ARROWHEAD_CODE_CHUNK_MAX_CHARS` | `2000` |
| `ARROWHEAD_INDEX_REUSE_UNCHANGED` | `true` |
## Hybrid retrieval (hybrid\_query)
[Section titled “Hybrid retrieval (hybrid\_query)”](#hybrid-retrieval-hybrid_query)
Fuses vector similarity with Postgres full-text rank by reciprocal rank fusion. The candidate multiplier sizes each branch’s pool as a multiple of k. The text search configuration must name an installed Postgres regconfig.
| Variable | Default |
| --------------------------------------- | --------- |
| `ARROWHEAD_HYBRID_RRF_K` | `60` |
| `ARROWHEAD_HYBRID_CANDIDATE_MULTIPLIER` | `4` |
| `ARROWHEAD_FTS_LANGUAGE` | `english` |
## Repo intelligence (repo family)
[Section titled “Repo intelligence (repo family)”](#repo-intelligence-repo-family)
The code tools read a jailed source tree, separate from the document corpus: read-only, with version-control and dependency directories pruned, binary files refused, and per-file and per-walk caps.
| Variable | Default |
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `ARROWHEAD_REPO_ROOT` | `repo` |
| `ARROWHEAD_REPO_MAX_FILE_BYTES` | `500000` |
| `ARROWHEAD_REPO_ALLOWED_EXTENSIONS` | `.c,.cpp,.cs,.go,.h,.hpp,.java,.js,.json,.jsx,.kt,.md,.php,.py,.pyi,.rb,.rs,.scala,.sh,.sql,.swift,.toml,.ts,.tsx,.txt,.yaml,.yml` |
| `ARROWHEAD_REPO_EXCLUDED_DIRS` | `.git,.hg,.svn,.venv,node_modules,__pycache__,dist,build,target` |
| `ARROWHEAD_REPO_SEARCH_MAX_FILES` | `5000` |
| `ARROWHEAD_SYMBOL_MAP_MAX_FILES` | `500` |
| `ARROWHEAD_SYMBOL_MAP_MAX_SYMBOLS` | `5000` |
| `ARROWHEAD_DEPENDENCY_GRAPH_MAX_FILES` | `500` |
## Completion backend (assist family)
[Section titled “Completion backend (assist family)”](#completion-backend-assist-family)
“none” keeps the assist tools refusing. “anthropic” posts to the Anthropic Messages API; “openai” posts to any OpenAI-compatible chat-completions URL, which covers Ollama, vLLM, and LM Studio. The key is supplied here and never appears in an error. A local endpoint must be named in ARROWHEAD\_LLM\_INTERNAL\_HOSTS (exact host:port pairs) to be reachable.
| Variable | Default |
| -------------------------------- | --------- |
| `ARROWHEAD_LLM_PROVIDER` | `none` |
| `ARROWHEAD_LLM_ENDPOINT` | *(empty)* |
| `ARROWHEAD_LLM_MODEL` | *(empty)* |
| `ARROWHEAD_LLM_API_KEY` | *(empty)* |
| `ARROWHEAD_LLM_MAX_TOKENS` | `1024` |
| `ARROWHEAD_LLM_TIMEOUT_SECONDS` | `60.0` |
| `ARROWHEAD_LLM_MAX_PROMPT_CHARS` | `32000` |
| `ARROWHEAD_LLM_INTERNAL_HOSTS` | *(empty)* |
| `ARROWHEAD_DIFF_MAX_BYTES` | `200000` |
## Sandboxed execution (exec family)
[Section titled “Sandboxed execution (exec family)”](#sandboxed-execution-exec-family)
Off by default and, even when enabled, denied by the default policy: a deployment opts in twice, by this flag and by granting the execute action. The subprocess runner bounds resources and scrubs the environment but does not block network or filesystem reads; use the container runner for that.
| Variable | Default |
| ---------------------------------- | -------------- |
| `ARROWHEAD_EXEC_ENABLED` | `false` |
| `ARROWHEAD_EXEC_RUNNER` | `subprocess` |
| `ARROWHEAD_EXEC_CONTAINER_IMAGE` | *(empty)* |
| `ARROWHEAD_EXEC_WORKDIR` | `exec-scratch` |
| `ARROWHEAD_EXEC_CPU_SECONDS` | `10` |
| `ARROWHEAD_EXEC_WALL_SECONDS` | `30.0` |
| `ARROWHEAD_EXEC_TEST_WALL_SECONDS` | `120.0` |
| `ARROWHEAD_EXEC_MEMORY_BYTES` | `512000000` |
| `ARROWHEAD_EXEC_MAX_OUTPUT_BYTES` | `200000` |
| `ARROWHEAD_EXEC_MAX_CODE_BYTES` | `100000` |
| `ARROWHEAD_EXEC_MAX_COPY_BYTES` | `50000000` |
| `ARROWHEAD_EXEC_COPY_MAX_FILES` | `500` |
The argv run\_tests executes inside the scratch copy, shell-split; empty refuses run\_tests.
| Variable | Default |
| ----------------------------- | --------- |
| `ARROWHEAD_EXEC_TEST_COMMAND` | *(empty)* |
## Working sets and context packer (context family)
[Section titled “Working sets and context packer (context family)”](#working-sets-and-context-packer-context-family)
A working set is a named, owner-scoped collection of pinned references; the packer assembles a token-budgeted, secret-scanned bundle from it plus retrieval. Bounds keep one caller from growing state without limit.
| Variable | Default |
| -------------------------------- | ------- |
| `ARROWHEAD_WORKINGSET_MAX_SETS` | `100` |
| `ARROWHEAD_WORKINGSET_MAX_ITEMS` | `200` |
| `ARROWHEAD_PACK_MAX_TOKENS` | `8000` |
## Agent memory and scratchpad (memory family)
[Section titled “Agent memory and scratchpad (memory family)”](#agent-memory-and-scratchpad-memory-family)
Owner-scoped memories in namespaces plus an exact-key kv store. The backend is a jailed file tree under the memory root by default; setting a Postgres DSN (write-capable, separate from the read-only SQL DSN) moves storage there, with semantic recall once the http embedding provider is configured. Apply deploy/memory\_schema.sql first.
| Variable | Default |
| -------------------------------------------- | ---------- |
| `ARROWHEAD_MEMORY_ROOT` | `memory` |
| `ARROWHEAD_MEMORY_DSN` | *(empty)* |
| `ARROWHEAD_MEMORY_MAX_CONTENT_BYTES` | `100000` |
| `ARROWHEAD_MEMORY_MAX_METADATA_BYTES` | `4096` |
| `ARROWHEAD_MEMORY_MAX_ENTRIES_PER_NAMESPACE` | `500` |
| `ARROWHEAD_MEMORY_MAX_NAMESPACES` | `50` |
| `ARROWHEAD_MEMORY_QUOTA_BYTES` | `50000000` |
| `ARROWHEAD_MEMORY_MAX_K` | `20` |
| `ARROWHEAD_KV_MAX_KEYS` | `1000` |
| `ARROWHEAD_KV_MAX_VALUE_BYTES` | `100000` |
| `ARROWHEAD_KV_MAX_TTL_SECONDS` | `604800` |
## Timer tasks (task\_schedule)
[Section titled “Timer tasks (task\_schedule)”](#timer-tasks-task_schedule)
Timers share the in-process task registry and do not survive a restart.
| Variable | Default |
| -------------------------------------------- | --------- |
| `ARROWHEAD_TASK_SCHEDULE_MAX_DELAY_SECONDS` | `86400.0` |
| `ARROWHEAD_TASK_SCHEDULE_MIN_REPEAT_SECONDS` | `60.0` |
| `ARROWHEAD_TASK_SCHEDULE_MAX_PER_OWNER` | `20` |
| `ARROWHEAD_TASK_PAYLOAD_MAX_BYTES` | `16384` |
## Outbound webhook notifications (notify family)
[Section titled “Outbound webhook notifications (notify family)”](#outbound-webhook-notifications-notify-family)
Comma-separated absolute http(s) URL prefixes. Empty leaves the notify family unregistered; even an allowlisted target is still SSRF-guarded, and the notify action must additionally be granted in the authz policy.
| Variable | Default |
| ------------------------------------- | --------- |
| `ARROWHEAD_NOTIFY_ALLOWLIST` | *(empty)* |
| `ARROWHEAD_NOTIFY_TIMEOUT_SECONDS` | `10.0` |
| `ARROWHEAD_NOTIFY_MAX_PAYLOAD_BYTES` | `100000` |
| `ARROWHEAD_NOTIFY_MAX_RESPONSE_BYTES` | `10000` |
## Abuse controls
[Section titled “Abuse controls”](#abuse-controls)
Ceilings are calls per caller per minute. Redis, if set, shares the buckets across replicas; without it limits apply per process.
| Variable | Default |
| ---------------------------------------- | ------- |
| `ARROWHEAD_RATE_LIMIT_ENABLED` | `true` |
| `ARROWHEAD_SAFE_FETCH_PER_MINUTE` | `30` |
| `ARROWHEAD_CALCULATE_PER_MINUTE` | `120` |
| `ARROWHEAD_READ_FILE_PER_MINUTE` | `60` |
| `ARROWHEAD_DOC_SEARCH_PER_MINUTE` | `60` |
| `ARROWHEAD_DOC_READ_PER_MINUTE` | `60` |
| `ARROWHEAD_DOC_RETRIEVE_PER_MINUTE` | `30` |
| `ARROWHEAD_DOC_SCAN_PER_MINUTE` | `20` |
| `ARROWHEAD_DOC_WRITE_PER_MINUTE` | `30` |
| `ARROWHEAD_SQL_QUERY_PER_MINUTE` | `60` |
| `ARROWHEAD_VECTOR_SEARCH_PER_MINUTE` | `30` |
| `ARROWHEAD_VECTOR_QUERY_PER_MINUTE` | `30` |
| `ARROWHEAD_HYBRID_QUERY_PER_MINUTE` | `30` |
| `ARROWHEAD_VECTOR_INDEX_PER_MINUTE` | `6` |
| `ARROWHEAD_CODE_SEARCH_PER_MINUTE` | `60` |
| `ARROWHEAD_CODE_READ_PER_MINUTE` | `60` |
| `ARROWHEAD_SYMBOL_MAP_PER_MINUTE` | `20` |
| `ARROWHEAD_DEPENDENCY_GRAPH_PER_MINUTE` | `10` |
| `ARROWHEAD_CODE_EXPLAIN_PER_MINUTE` | `10` |
| `ARROWHEAD_SUMMARIZE_DIFF_PER_MINUTE` | `10` |
| `ARROWHEAD_RERANK_PER_MINUTE` | `20` |
| `ARROWHEAD_RUN_SNIPPET_PER_MINUTE` | `10` |
| `ARROWHEAD_RUN_TESTS_PER_MINUTE` | `4` |
| `ARROWHEAD_WORKINGSET_GET_PER_MINUTE` | `60` |
| `ARROWHEAD_WORKINGSET_UPDATE_PER_MINUTE` | `30` |
| `ARROWHEAD_PACK_CONTEXT_PER_MINUTE` | `10` |
Starting an async task is expensive (a background scan); polling is cheap.
| Variable | Default |
| ------------------------------------- | ------- |
| `ARROWHEAD_TASK_START_PER_MINUTE` | `10` |
| `ARROWHEAD_TASK_GET_PER_MINUTE` | `120` |
| `ARROWHEAD_TASK_UPDATE_PER_MINUTE` | `30` |
| `ARROWHEAD_TASK_LIST_PER_MINUTE` | `60` |
| `ARROWHEAD_TASK_SCHEDULE_PER_MINUTE` | `10` |
| `ARROWHEAD_MEMORY_STORE_PER_MINUTE` | `30` |
| `ARROWHEAD_MEMORY_SEARCH_PER_MINUTE` | `60` |
| `ARROWHEAD_MEMORY_LIST_PER_MINUTE` | `60` |
| `ARROWHEAD_MEMORY_DELETE_PER_MINUTE` | `30` |
| `ARROWHEAD_KV_SET_PER_MINUTE` | `60` |
| `ARROWHEAD_KV_GET_PER_MINUTE` | `120` |
| `ARROWHEAD_KV_DELETE_PER_MINUTE` | `60` |
| `ARROWHEAD_NOTIFY_WEBHOOK_PER_MINUTE` | `10` |
Reading a resource, getting a prompt, and completing an argument are rate-limited per caller too.
| Variable | Default |
| ------------------------------------ | ------- |
| `ARROWHEAD_RESOURCE_READ_PER_MINUTE` | `60` |
| `ARROWHEAD_PROMPT_GET_PER_MINUTE` | `60` |
| `ARROWHEAD_COMPLETION_PER_MINUTE` | `60` |
Any tool without an explicit ceiling above falls back to this default.
| Variable | Default |
| ----------------------------------- | --------- |
| `ARROWHEAD_DEFAULT_TOOL_PER_MINUTE` | `60` |
| `ARROWHEAD_REDIS_URL` | *(unset)* |
## Kill switch
[Section titled “Kill switch”](#kill-switch)
Comma-separated tool names to take out of service without a redeploy.
| Variable | Default |
| -------------------------- | --------- |
| `ARROWHEAD_DISABLED_TOOLS` | *(empty)* |
## Cache hints
[Section titled “Cache hints”](#cache-hints)
How long clients may cache a list result and a single resource read; both ride in the result \_meta with a private cache scope.
| Variable | Default |
| -------------------------------- | --------- |
| `ARROWHEAD_TOOL_LIST_TTL_MS` | `3600000` |
| `ARROWHEAD_RESOURCE_READ_TTL_MS` | `60000` |
## Observability (OpenTelemetry)
[Section titled “Observability (OpenTelemetry)”](#observability-opentelemetry)
Set the OTLP/HTTP collector base endpoint to export traces and metrics; leave empty and both stay no-op at no cost. Headers are comma-separated key=value pairs (e.g. a collector API key). Audit logs are always emitted as JSON on stdout for the platform’s log drain regardless of this.
| Variable | Default |
| --------------------------------------- | ----------- |
| `ARROWHEAD_OTEL_EXPORTER_OTLP_ENDPOINT` | *(unset)* |
| `ARROWHEAD_OTEL_EXPORTER_OTLP_HEADERS` | *(unset)* |
| `ARROWHEAD_OTEL_SERVICE_NAME` | `arrowhead` |
# Tool reference
> Every tool with its wire description, scope, rate ceiling, and hints.
All 38 tools in the catalog, grouped by family. This page is generated from the catalog when the site builds; each description below is the exact text the server sends to a connected client, and the per-minute ceilings are the defaults a deployment can raise or lower per tool.
A deployment exposes families by setting `ARROWHEAD_PROFILE`; a tool outside the active profile is never registered.
| Profile | Families |
| -------- | --------------------------------------------------- |
| `core` | `core` |
| `docs` | `core`, `data`, `docs`, `memory`, `notify`, `tasks` |
| `coding` | `assist`, `context`, `core`, `data`, `exec`, `repo` |
| `full` | every family |
## core
[Section titled “core”](#core)
| Tool | Scope | Per minute | Hints |
| --------------------------- | ------------ | ---------- | --------------------- |
| [`safe_fetch`](#safe_fetch) | `tools:read` | 30 | read-only, open-world |
| [`calculate`](#calculate) | `tools:read` | 120 | read-only |
| [`read_file`](#read_file) | `tools:read` | 60 | read-only |
### safe\_fetch
[Section titled “safe\_fetch”](#safe_fetch)
Fetch a public http or https URL and return its status, content type, and body text. Private, loopback, link-local, and cloud metadata addresses are refused. Example: safe\_fetch(url=“”).
### calculate
[Section titled “calculate”](#calculate)
Evaluate an arithmetic expression with + - \* / and parentheses. Example: calculate(expression=“2 \* (3 + 4)”) returns 14.
### read\_file
[Section titled “read\_file”](#read_file)
Read a text file from the server’s sandbox directory by relative path. Example: read\_file(path=“notes/readme.txt”).
## docs
[Section titled “docs”](#docs)
| Tool | Scope | Per minute | Hints |
| ------------------------------- | ------------- | ---------- | --------------------- |
| [`doc_search`](#doc_search) | `docs:search` | 60 | read-only |
| [`doc_read`](#doc_read) | `docs:read` | 60 | read-only |
| [`doc_retrieve`](#doc_retrieve) | `docs:read` | 30 | read-only, open-world |
| [`doc_scan`](#doc_scan) | `docs:scan` | 20 | read-only |
| [`doc_write`](#doc_write) | `docs:write` | 30 | destructive |
### doc\_search
[Section titled “doc\_search”](#doc_search)
Search corpus documents for a query and return bounded, sanitized snippets. Literal by default; set use\_regex when enabled. Example: doc\_search(query=“deadline”, path\_prefix=“notes/”).
### doc\_read
[Section titled “doc\_read”](#doc_read)
Read a JSON, Markdown, or text document from the corpus by relative path. Returns sanitized content wrapped with provenance. Example: doc\_read(path=“notes/todo.md”).
### doc\_retrieve
[Section titled “doc\_retrieve”](#doc_retrieve)
Retrieve a JSON, Markdown, or text document from a public URL, SSRF-guarded and sanitized. Returns wrapped, untrusted content. Example: doc\_retrieve(url=“”).
### doc\_scan
[Section titled “doc\_scan”](#doc_scan)
Scan corpus documents for secrets and PII and return redacted findings (type, location, and a hashed placeholder, never the raw value). Example: doc\_scan(path\_prefix=“exports/”).
### doc\_write
[Section titled “doc\_write”](#doc_write)
Write a JSON, Markdown, or text document to the corpus. Creates a new document; set overwrite to replace an existing one, which asks for confirmation. Example: doc\_write(path=“notes/todo.md”, content=“# Todo”).
## data
[Section titled “data”](#data)
| Tool | Scope | Per minute | Hints |
| --------------------------------- | --------------- | ---------- | ---------- |
| [`sql_query`](#sql_query) | `sql:read` | 60 | read-only |
| [`vector_search`](#vector_search) | `vector:search` | 30 | read-only |
| [`vector_query`](#vector_query) | `vector:search` | 30 | read-only |
| [`hybrid_query`](#hybrid_query) | `vector:search` | 30 | read-only |
| [`doc_index`](#doc_index) | `vector:write` | 6 | idempotent |
### sql\_query
[Section titled “sql\_query”](#sql_query)
Run a read-only SQL query and return its rows as untrusted data. Only a single SELECT may run; every referenced table is authorized, and results are capped. Bind values with named parameters. Example: sql\_query(query= “select id, email from users where org = :org”, params={“org”: “acme”}).
### vector\_search
[Section titled “vector\_search”](#vector_search)
Find the nearest rows in a pgvector collection to a query embedding, scoped to the caller’s tenant. Only an allow-listed collection may be searched, the tenant is the caller, and results are capped. Example: vector\_search(collection=“documents”, embedding=\[0.1, 0.2, …], k=5).
### vector\_query
[Section titled “vector\_query”](#vector_query)
Find the corpus chunks most similar to a natural-language query, scoped to the caller’s tenant. The query is embedded server-side, only an allow-listed collection is searched, and each result carries its source document and chunk index as a citation. Example: vector\_query(collection=“doc\_chunks”, query=“how do refunds work?”, k=5).
### hybrid\_query
[Section titled “hybrid\_query”](#hybrid_query)
Retrieve corpus chunks by fused vector and keyword relevance, scoped to the caller’s tenant. The query is embedded server-side and also run as a full-text search; the two rankings merge by reciprocal rank fusion, so exact identifiers and paraphrases both surface. Example: hybrid\_query(collection=“doc\_chunks”, query=“refund window”, k=5).
### doc\_index
[Section titled “doc\_index”](#doc_index)
Index corpus documents into a pgvector collection so vector\_query can retrieve them. Each document under the path prefix is chunked, embedded, and written under the caller’s tenant; a re-index replaces a document’s existing chunks and skips unchanged ones without re-embedding them. Example: doc\_index(collection=“doc\_chunks”, path\_prefix=“handbook/”).
## repo
[Section titled “repo”](#repo)
| Tool | Scope | Per minute | Hints |
| --------------------------------------- | ------------- | ---------- | --------- |
| [`code_search`](#code_search) | `repo:search` | 60 | read-only |
| [`code_read`](#code_read) | `repo:read` | 60 | read-only |
| [`symbol_map`](#symbol_map) | `repo:read` | 20 | read-only |
| [`dependency_graph`](#dependency_graph) | `repo:read` | 10 | read-only |
### code\_search
[Section titled “code\_search”](#code_search)
Search repository files for a query and return bounded, sanitized snippets with line numbers. Literal by default; set use\_regex when enabled. Example: code\_search(query=“TODO”, path\_prefix=“src/”).
### code\_read
[Section titled “code\_read”](#code_read)
Read a repository file by relative path, optionally sliced to a line range; end\_line 0 means to the end. Returns sanitized text wrapped with provenance. Example: code\_read(path=“src/app.py”, start\_line=10, end\_line=40).
### symbol\_map
[Section titled “symbol\_map”](#symbol_map)
List the functions, classes, and types defined under a repository prefix, with their line spans. Example: symbol\_map(path\_prefix=“src/”).
### dependency\_graph
[Section titled “dependency\_graph”](#dependency_graph)
Map the imports between the Python modules under a repository prefix, marking dependencies outside it as external. Example: dependency\_graph(path\_prefix=“src/”).
## assist
[Section titled “assist”](#assist)
| Tool | Scope | Per minute | Hints |
| ----------------------------------- | ------------ | ---------- | --------------------- |
| [`code_explain`](#code_explain) | `assist:run` | 10 | read-only, open-world |
| [`summarize_diff`](#summarize_diff) | `assist:run` | 10 | read-only, open-world |
| [`rerank`](#rerank) | `assist:run` | 20 | read-only, open-world |
### code\_explain
[Section titled “code\_explain”](#code_explain)
Explain a repository file or line range through the configured model backend; end\_line 0 means to the end. The explanation returns as untrusted data. Example: code\_explain(path=“src/app.py”).
### summarize\_diff
[Section titled “summarize\_diff”](#summarize_diff)
Summarize a unified diff through the configured model backend; the summary returns as untrusted data. Example: summarize\_diff(diff=“— a/x.py\n+++ b/x.py\n@@ …”).
### rerank
[Section titled “rerank”](#rerank)
Rank candidate passages by relevance to a query through the configured model backend and return their indices in order. Example: rerank(query=“refund policy”, candidates=\[“…”, “…”]).
## exec
[Section titled “exec”](#exec)
Gated family
Registered only when `ARROWHEAD_EXEC_ENABLED` is true; the execute action must additionally be granted in the authorization policy.
| Tool | Scope | Per minute | Hints |
| ----------------------------- | ---------- | ---------- | ---------- |
| [`run_snippet`](#run_snippet) | `exec:run` | 10 | open-world |
| [`run_tests`](#run_tests) | `exec:run` | 4 | open-world |
### run\_snippet
[Section titled “run\_snippet”](#run_snippet)
Run a short Python snippet in the sandbox and return its bounded, secret-redacted output. Requires the execution feature to be enabled and granted. Example: run\_snippet(code=“print(2 + 2)”).
### run\_tests
[Section titled “run\_tests”](#run_tests)
Copy the authorized repository subtree into the sandbox and run the configured test command there, returning its bounded, secret-redacted output. Requires the execution feature to be enabled and granted. Example: run\_tests(path\_prefix=“src/”).
## context
[Section titled “context”](#context)
| Tool | Scope | Per minute | Hints |
| ----------------------------------------- | --------------- | ---------- | --------- |
| [`pack_context`](#pack_context) | `context:read` | 10 | read-only |
| [`workingset_get`](#workingset_get) | `context:read` | 60 | read-only |
| [`workingset_update`](#workingset_update) | `context:write` | 30 | |
### pack\_context
[Section titled “pack\_context”](#pack_context)
Assemble a token-budgeted context bundle for a query: a working set’s pinned items first, then retrieved snippets, each secret-scanned, provenance-stamped, and framed as untrusted data. Example: pack\_context(query=“how are refunds issued?”, working\_set=“refunds”).
### workingset\_get
[Section titled “workingset\_get”](#workingset_get)
Read the contents of a named working set you own. A set you do not own is reported as not found. Example: workingset\_get(name=“bug”).
### workingset\_update
[Section titled “workingset\_update”](#workingset_update)
Pin, unpin, or clear references in a named working set. Each item is {“kind”: “doc”|“repo\_file”, “identifier”: …, “note”: …}; pinned items are authorized as you pin them. Example: workingset\_update(name=“bug”, action=“pin”, items=\[{“kind”: “repo\_file”, “identifier”: “src/app.py”}]).
## tasks
[Section titled “tasks”](#tasks)
| Tool | Scope | Per minute | Hints |
| ----------------------------------------- | ------------- | ---------- | ---------- |
| [`scan_corpus_async`](#scan_corpus_async) | `docs:scan` | 10 | |
| [`task_get`](#task_get) | `tasks:read` | 120 | read-only |
| [`task_update`](#task_update) | `tasks:write` | 30 | idempotent |
| [`task_list`](#task_list) | `tasks:read` | 60 | read-only |
| [`task_schedule`](#task_schedule) | `tasks:write` | 10 | |
### scan\_corpus\_async
[Section titled “scan\_corpus\_async”](#scan_corpus_async)
Start a background secrets-and-PII scan of the corpus (or a path prefix) and return a task handle immediately. Poll it with task\_get(task\_id=…) and cancel it with task\_update(task\_id=…, action=“cancel”). Example: scan\_corpus\_async(path\_prefix=“exports/”).
### task\_get
[Section titled “task\_get”](#task_get)
Return the status of a task you started, and its result once it has finished. A task id you do not own is reported as not found. Example: task\_get(task\_id=“3f2a…”).
### task\_update
[Section titled “task\_update”](#task_update)
Update a task you started. The only action is “cancel”, which stops a running task. A task id you do not own is reported as not found. Example: task\_update(task\_id=“3f2a…”, action=“cancel”).
### task\_list
[Section titled “task\_list”](#task_list)
List the task handles you own with their status and kind. Poll one with task\_get(task\_id=…). Example: task\_list().
### task\_schedule
[Section titled “task\_schedule”](#task_schedule)
Schedule a timer that completes after delay\_seconds carrying your payload; repeat\_seconds respawns the next occurrence. Poll with task\_get(task\_id=…); timers do not survive a restart. Example: task\_schedule(delay\_seconds=60, payload={“note”: “check build”}).
## memory
[Section titled “memory”](#memory)
| Tool | Scope | Per minute | Hints |
| --------------------------------- | -------------- | ---------- | ----------------------- |
| [`memory_store`](#memory_store) | `memory:write` | 30 | idempotent |
| [`memory_search`](#memory_search) | `memory:read` | 60 | read-only |
| [`memory_list`](#memory_list) | `memory:read` | 60 | read-only |
| [`memory_delete`](#memory_delete) | `memory:write` | 30 | destructive, idempotent |
| [`kv_set`](#kv_set) | `memory:write` | 60 | destructive, idempotent |
| [`kv_get`](#kv_get) | `memory:read` | 120 | read-only |
| [`kv_delete`](#kv_delete) | `memory:write` | 60 | destructive, idempotent |
### memory\_store
[Section titled “memory\_store”](#memory_store)
Store a memory in a namespace you own and return its id. Identical content returns the existing id unchanged; pass id to update an entry in place. Example: memory\_store(namespace=“prefs”, content=“prefers metric units”).
### memory\_search
[Section titled “memory\_search”](#memory_search)
Recall the memories in a namespace you own most relevant to a query. Recall is keyword on the file backend and semantic when an embedding provider is configured. Example: memory\_search(namespace=“prefs”, query=“units”, k=5).
### memory\_list
[Section titled “memory\_list”](#memory_list)
List the ids and metadata of the memories in a namespace you own. Example: memory\_list(namespace=“prefs”).
### memory\_delete
[Section titled “memory\_delete”](#memory_delete)
Delete one memory you own by namespace and id. An id you do not own is reported as not found. Example: memory\_delete(namespace=“prefs”, id=“3f2a…”).
### kv\_set
[Section titled “kv\_set”](#kv_set)
Set a scratchpad value for an exact key, replacing any prior value. ttl\_seconds expires it; keys use letters, digits, and . \_ : - only. Example: kv\_set(key=“run:42:state”, value=“phase2”, ttl\_seconds=600).
### kv\_get
[Section titled “kv\_get”](#kv_get)
Get the scratchpad value stored for an exact key you own. An expired or missing key is reported as not found. Example: kv\_get(key=“run:42:state”).
### kv\_delete
[Section titled “kv\_delete”](#kv_delete)
Delete the scratchpad value stored for an exact key you own. Example: kv\_delete(key=“run:42:state”).
## notify
[Section titled “notify”](#notify)
Gated family
Registered only when `ARROWHEAD_NOTIFY_ALLOWLIST` is non-empty; the notify action must additionally be granted in the authorization policy.
| Tool | Scope | Per minute | Hints |
| ----------------------------------- | ------------- | ---------- | ---------- |
| [`notify_webhook`](#notify_webhook) | `notify:send` | 10 | open-world |
### notify\_webhook
[Section titled “notify\_webhook”](#notify_webhook)
POST a JSON payload to a webhook URL on the operator allowlist and return the bounded response status. Example: notify\_webhook(url=“”, payload={“text”: “done”}).
# Security
> Each mitigation mapped to the vulnerability class it closes.
This document maps each mitigation in Arrowhead to the vulnerability class it closes. The three core tools were chosen because they correspond to the three most common flaws found in real MCP servers; the surrounding layers (auth, rate limiting, audit logging) close the fourth, missing authentication. Every family that has been added since — documents, data connectors, repo intelligence, assist, exec, context, tasks — registers behind the same guard chain, so the same posture covers all of them.
## Server-side request forgery — `safe_fetch`
[Section titled “Server-side request forgery — safe\_fetch”](#server-side-request-forgery--safe_fetch)
**Risk.** A fetch tool that accepts an arbitrary URL can be pointed at the cloud metadata endpoint (`169.254.169.254`), at internal services on private ranges, or at loopback, exfiltrating credentials or reaching systems the caller should never touch.
**Mitigation** (`security/ssrf_guard.py`, `tools/safe_fetch.py`):
* The scheme must be `http` or `https`; everything else (`file:`, `gopher:`, `dict:`, …) is refused before any network activity.
* The hostname is resolved and every resolved address is checked. If any address is not globally routable unicast (private, loopback, link-local, carrier-grade NAT, multicast, or the metadata address) the request is refused. An IPv4 address embedded in an IPv6 wrapper (IPv4-mapped, 6to4, Teredo, NAT64, or IPv4-compatible) is unwrapped and re-checked, so `64:ff9b::a9fe:a9fe` cannot reach the metadata endpoint through a NAT64 gateway. Mixed public-and-private DNS answers are refused as a whole.
* The port must be `80`, `443`, or one a deployment explicitly allow-listed (validated at startup); a malformed port is a clean refusal, not an unhandled error.
* The approved address is **pinned**: the connection is made to that exact IP while the original hostname travels in the `Host` header and as the TLS server name. Because the address is never re-resolved between the check and the connection, DNS rebinding cannot swap a public record for a private one.
* Redirects are followed manually, and the guard runs again on every hop, so a public URL that 302-redirects toward the metadata endpoint is caught.
* Response bodies are capped; the outbound request carries none of the caller’s MCP credentials.
## Command and code injection — `calculate`
[Section titled “Command and code injection — calculate”](#command-and-code-injection--calculate)
**Risk.** Passing user input to `eval`, `exec`, or a shell lets an attacker run arbitrary code. This is the single most common MCP server flaw.
**Mitigation** (`security/input_validation.py`, `security/sandbox.py`, `tools/calculate.py`) — two independent layers:
1. A strict character allowlist accepts only digits, `+ - * / ( ) .`, and whitespace. `1+1; import os`, `__import__('os')`, and backtick or `$()` shell syntax are all rejected here, before reaching any evaluator.
2. The expression is then parsed to an AST and walked by an interpreter that recognizes only numeric literals, the four binary operators, and unary plus/minus. Names, calls, attributes, subscripts, and exponentiation are refused even though some of them pass the character allowlist — for example `2 ** 8` is caught by this second layer. Node count is bounded to prevent pathological evaluation cost.
There is no `eval`, no `exec`, and no `subprocess` anywhere in the path.
## Path traversal — `read_file`
[Section titled “Path traversal — read\_file”](#path-traversal--read_file)
**Risk.** A file reader that trusts the requested path can be walked out of its intended directory with `../../` sequences, absolute paths, or symlinks, exposing `/etc/passwd`, SSH keys, or application secrets.
**Mitigation** (`security/input_validation.py`, `tools/read_file.py`):
* The path must be relative, contain no `..` components, and carry no null bytes; absolute paths are refused up front.
* The path is joined to the configured jail root and fully resolved with symlinks followed. The result must still be inside the jail root, so a symlink placed inside the jail that points outside it is refused.
* Error messages never echo the requested path, so a probing caller cannot use them to map the filesystem.
* Files above a configured size are refused.
## Authentication and authorization
[Section titled “Authentication and authorization”](#authentication-and-authorization)
**Risk.** A large share of MCP servers ship with no authentication, and among those that have it, token mismanagement — accepting tokens minted for other audiences, or forwarding the caller’s token to downstream services — is the top risk.
**Mitigation** (`auth/oauth.py`, `auth/scopes.py`):
* Arrowhead is an OAuth 2.1 **resource server**. It never issues tokens; an external authorization server does. Every HTTP request’s bearer token is verified for signature, issuer, and expiry.
* **Audience validation is mandatory.** A token whose `aud` does not name this server is rejected with 401 even when its signature, issuer, and expiry are all valid. Enabling auth without an audience configured is a startup error.
* **No token passthrough.** Outbound `safe_fetch` requests are built from scratch and carry no inbound credentials; a regression test asserts the absence of any `Authorization` header or cookie.
* Each tool requires a scope; a caller lacking it cannot see the tool in `tools/list` and cannot call it. Protected-resource metadata is published at `/.well-known/oauth-protected-resource/mcp` per RFC 9728.
### Identity provider
[Section titled “Identity provider”](#identity-provider)
Arrowhead is a resource server, so it needs an external authorization server to issue tokens. Two provider paths are configurable (`ARROWHEAD_OAUTH_PROVIDER`):
* **`workos`** (recommended for a hosted deployment): WorkOS AuthKit is purpose-built for MCP. It supports the Dynamic Client Registration and Client-ID-Metadata-Document registration MCP clients use to self-register, hosts the authorization server, and serves the discovery metadata. Set `ARROWHEAD_OAUTH_AUTHKIT_DOMAIN` to the AuthKit domain and `ARROWHEAD_SERVER_PUBLIC_URL` to this server’s canonical URL.
* **`jwt`** (bring-your-own-IdP): verify against any OAuth 2.1 issuer via its JWKS URI (preferred) or a static public key. Point `ARROWHEAD_OAUTH_ISSUER`, `ARROWHEAD_OAUTH_AUDIENCE` (the canonical resource URI), and `ARROWHEAD_OAUTH_JWKS_URI` at the IdP. **Keycloak** is a good open-source, self-hostable choice here, though its RFC 8707 resource-indicator support is still a manual mapper exercise as of early 2026.
The JWKS verification path (key discovery, key rotation, and audience validation with a JWKS-sourced signature) is covered by an integration test that serves a key set in-process, so the production path is exercised, not only a static-key stub.
## Document tools: content, authorization, and mutation
[Section titled “Document tools: content, authorization, and mutation”](#document-tools-content-authorization-and-mutation)
The document suite (`doc_search`, `doc_read`, `doc_retrieve`, `doc_scan`, `doc_write`) operates over a jailed corpus of JSON, Markdown, and text files and adds three mitigations beyond the ones above.
**Untrusted content boundary** (`content/`). A tool result flows back into a model’s context, where prose could read as instructions. Every returned document is sanitized for its format and wrapped in provenance:
* JSON (`content/json_safe.py`) is parsed with size, pre-parse nesting-depth, element-count, and duplicate-key bounds, rejects non-standard NaN/Infinity, and is re-serialized canonically. Python’s parser never instantiates arbitrary types, so CWE-502 gadget chains do not apply.
* Markdown (`content/markdown_safe.py`) has raw HTML stripped, image URLs dropped (killing zero-click `` exfiltration), links restricted to http/https, and `javascript:`/`data:`/`file:` scheme URIs neutralized.
* Text (`content/text_safe.py`) has ANSI escapes, control characters, and zero-width/bidi characters stripped, a UTF-7 byte-order mark refused, and is always decoded as UTF-8 and NFC-normalized.
* Every return is wrapped (`content/provenance.py`) in randomized per-response delimiters plus structured metadata and an untrusted-data notice.
**Scope is necessary but not sufficient** (`authz/`). The MCP guidance names treating the token scope as sufficient an anti-pattern, so each document call passes a per-resource authorization check after its scope check. Scopes are split by verb (`docs:search/read/scan/write`); the default policy grants corpus-wide search/read/scan but confines writes to the caller’s own `/` namespace, so cross-subject writes are denied. The `Authorizer` protocol is the seam for an external policy engine (OPA, Cedar). A denial is audited distinctly (error type `AuthorizationError`) and never echoes the resource.
**Write-path safety** (`store/document_store.py`, `doc_write`). Writes are jailed after full path canonicalization (symlink escapes refused), atomic (temp file, fsync, move into place, so no partial document is ever read), and no-clobber by default via a race-free hard link. Per-document size and total corpus quota caps apply. Overwriting is destructive: it requires an explicit flag and, when the client supports it, human confirmation via elicitation bound to the token subject; an explicit decline blocks the write.
**Secret and PII redaction** (`security/secret_scan.py`). `doc_scan` reports a type, a location, and a redacted placeholder `[REDACTED:TYPE:tag]`. The raw value is never returned or logged; an adversarial test asserts this for each secret type. The tag is a hash of the value keyed by a random per-process salt, so the same value redacts to the same tag within one run (letting a caller correlate occurrences) but cannot be brute-forced back to a low-entropy value such as an SSN or email, since the salt is secret and never persisted. The correlation therefore holds within a process, not across restarts. Overlapping matches for one secret are reported once.
**Search denial-of-service** (`security/search_match.py`). Search is literal by default. Regex is opt-in and disabled by default, and when enabled runs through a ReDoS-resistant engine with a hard per-match timeout, applied per line. Results, files scanned, and aggregate bytes are all bounded.
### A note on scope enforcement
[Section titled “A note on scope enforcement”](#a-note-on-scope-enforcement)
Per the MCP spec, an under-scoped call SHOULD receive a `403` with a `WWW-Authenticate: insufficient_scope` step-up challenge. Arrowhead instead hides a tool the caller lacks the scope for (it is filtered from `tools/list` and reported as unknown on call). This is a deliberate choice: revealing that a tool exists and which scope it needs leaks the tool surface and scope taxonomy to an under-privileged caller. The MUST-level discovery path (401 with `WWW-Authenticate` and `resource_metadata` on a missing or invalid token) is still served.
## Data connectors: `sql_query`, `vector_search`, `vector_query`, `hybrid_query`, `doc_index`
[Section titled “Data connectors: sql\_query, vector\_search, vector\_query, hybrid\_query, doc\_index”](#data-connectors-sql_query-vector_search-vector_query-hybrid_query-doc_index)
The SQL connector never runs the caller’s raw text. A statement is parsed in the database’s own dialect (derived from the DSN, or set explicitly and validated at startup), its parenthesis nesting is bounded so a pathological query cannot exhaust the parser stack, and only a single read-only `SELECT` survives: stacked statements, every write form, DDL, `SET`, and row-locking clauses (`FOR UPDATE`) are refused. A denylist of side-effecting, file, network, and administrative functions (`pg_read_file`, `dblink`, `query_to_xml`, `pg_terminate_backend`, `LOAD_FILE`, `BENCHMARK`, and the like) is refused as well, since a read query could otherwise smuggle a table read or a side effect inside a function. Every referenced table is authorized; a query that reads no table is authorized against a dedicated tableless resource the default policy does not grant, so a functions-only query is denied unless a deployment opts in.
The single-statement check, not comment stripping, is what stops a second statement: the parser preserves comment bodies, so the security property comes from the statement count. The parser is defense in depth, not a complete sandbox: the primary controls are a least-privilege read-only database role and the network egress controls. On Postgres the connector additionally runs each query in a read-only transaction under a server-side statement timeout; other engines rely on the parser guard, the function denylist, the in-process deadline, and the read-only role. Results are bounded by byte count (measured in bytes, per cell and in total), row count, and column count, and column names, not just values, are sanitized before they leave the process.
`vector_search` adds tenant isolation as a first-class control: the collection must be one a deployment allow-listed (the table name is never taken raw from the caller), and the tenant filter is the authenticated caller, injected server-side, never an argument, so one tenant cannot read another’s rows even by asking. The query embedding is bound as a parameter, and `k`, row, byte, and dimension counts are all bounded.
`vector_query` and `hybrid_query` are the text-facing retrieval tools: the caller’s query is embedded server-side through the configured provider, and authorization runs before embedding, so a denied caller can never trigger an outbound embedding request. The embedding client posts through the same SSRF-guarded, redirect-refusing, egress-allowlisted path as `safe_fetch`, with the endpoint resolved once and pinned. `hybrid_query` fuses the vector ranking with Postgres full-text rank inside one statement whose only interpolated names are allow-listed or configured identifiers; the query, language, tenant, and limits are all bound parameters.
`doc_index` is the ingestion tool and is governed separately on every axis: it requires the `ingest` action, which the default policy denies; it writes through `ARROWHEAD_VECTOR_WRITE_DSN`, a credential separate from the read-only `sql_dsn`, so the read tools cannot write even if a guard failed; and the tenant it writes under is the authenticated caller returned by authorization, never an argument. Chunk ids are deterministic hashes of tenant, source, and index, so a re-index replaces a document’s chunks in place and cannot collide across tenants.
## Repo intelligence: `code_search`, `code_read`, `symbol_map`, `dependency_graph`
[Section titled “Repo intelligence: code\_search, code\_read, symbol\_map, dependency\_graph”](#repo-intelligence-code_search-code_read-symbol_map-dependency_graph)
The coding tools read a second jail, separate from the document corpus: a read-only source tree under `ARROWHEAD_REPO_ROOT`. The same traversal rules as `read_file` apply (relative paths, no parent components, resolved paths must stay inside the root), and the store adds controls tuned to source trees: version-control and dependency directories (`.git`, `node_modules`, and the like) are pruned from every walk so their contents are unreachable, binary files are refused by content sniff, an extension allowlist bounds what may be read (enforced identically by `code_read` and `code_explain`), and per-file and per-walk caps bound every operation. `code_search` filters every candidate file through the caller’s per-file read authorization exactly as `doc_search` does for documents, and both run the same shared, ReDoS-guarded matcher. `symbol_map` and `dependency_graph` parse with the stdlib compiler (or the optional tree-sitter backend), never by executing repository code.
## Resources, prompts, and completions
[Section titled “Resources, prompts, and completions”](#resources-prompts-and-completions)
The non-tool primitives are not a bypass. A resource read runs the same per-resource authorization and content sanitization as the equivalent tool call, so `doc://{+path}` is exactly as safe as `doc_read`. Prompts reference documents by resource URI or by a tool call rather than inlining corpus text, and sanitize their arguments, so the instruction channel cannot carry untrusted content. Completion candidates pass the same per-document read authorization as search, so completion never reveals a path the caller could not read. Because the low-level completion path bypasses the middleware chain, its handler is wrapped so a completion passes the same per-caller rate limit, kill switch, and audit line as a tool call; a rate-limited or disabled completion returns no values without running the corpus walk. Audit logging, rate limiting, and the kill switch cover resource reads, prompt gets, and completions, not only tool calls.
## Asynchronous tasks
[Section titled “Asynchronous tasks”](#asynchronous-tasks)
Long-running work (a background corpus scan) is exposed as ordinary guarded tools that pass a server-minted task handle as an argument, following the 2026-07-28 stateless pattern rather than a protocol session. The start tool authorizes the work and records the caller as the task’s owner; `task_get` and `task_update` resolve the handle only for that owner, so a caller can see or cancel only its own tasks and a handle it does not own is reported as simply not found. The task registry is in process, so a task is visible only on the instance that created it; a multi-instance deployment would back it with the shared rate-limit store. The wire-level tasks extension is not adopted, because the stable SDK the server runs on does not speak it (see “MCP specification and the request path” below).
Timer tasks (`task_schedule`) ride the same registry and ownership rules. They are bounded per caller: a maximum number of pending timers per owner, a maximum delay, a minimum recurrence interval, and a payload byte cap, so a caller cannot park unbounded state or a tight self-refiring loop in the server. A pending timer does not survive a restart; that limitation is stated in the tool description itself so an agent never plans around durability the server does not have.
## Agent memory and scratchpad
[Section titled “Agent memory and scratchpad”](#agent-memory-and-scratchpad)
The memory tools (`memory_*`, `kv_*`, scopes `memory:read` / `memory:write`) store caller-curated state. Ownership is structural, not policy: every record is keyed server-side by the verified caller identity (the same idiom as task ownership), so no grant shape, namespace, id, or key can reach another owner’s entries — a foreign id is reported as simply not found. On the file backend the owner segment of every path is a SHA-256 of the identity, so a free-form subject (an email, a traversal-shaped string) never shapes a filesystem path; namespaces, ids, and keys pass strict allowlist validators and the jailed store enforces containment regardless. Bounds cap entries per namespace, namespaces per owner, keys per owner, content, metadata, and value sizes, and a total quota. Memory content is agent and user data: it is not secret-scanned or blocked on write, and it is sanitized and wrapped as untrusted data on every read, exactly like a document read. Search results carry a `recall` label naming the search actually performed (“keyword” or “semantic”), so a deployment without a real embedding provider never implies semantics it does not have.
## Outbound notifications
[Section titled “Outbound notifications”](#outbound-notifications)
`notify_webhook` (scope `notify:send`) is a deliberate egress channel and is therefore gated twice, like execution: the `notify` family registers only when `ARROWHEAD_NOTIFY_ALLOWLIST` is non-empty, and the `notify` action is absent from the default authorization grants, so an operator both names the destinations and grants the verb. Allowlist matching parses both sides and compares scheme, host, port, and path on a component boundary, so userinfo (`host@evil`) and suffix (`host.evil`) tricks fail on host equality rather than slipping past a string prefix. Even an allowlisted target is resolved and pinned by the SSRF guard, so a private-range entry or a DNS rebind is refused; redirects are refused outright because a redirect is a different destination than the operator approved. Payloads and captured responses are byte-capped, the response is sanitized and framed as untrusted, and the caller’s MCP credentials are never attached.
* **Rate limiting** (`security/rate_limit.py`): per-caller, per-tool token buckets with cost-appropriate ceilings. Backed by Redis when configured so limits hold across replicas. Exceeding a limit is a clean error, not a crash.
* **Kill switch** (`security/kill_switch.py`): any tool, prompt, or resource can be taken out of service through configuration without a code change. Disabling a resource template (`doc://{+path}`) both hides it from listings and refuses every concrete read it expands to.
* **Audit log** (`observability/audit_log.py`): one structured JSON line per call on stdout with caller identity, tool, argument *shapes* (never values), status, and latency, for the platform’s log drain. Redaction happens at the source, so secrets in arguments never reach log storage. A resource read is logged by its scheme and path shape, never the caller-supplied path itself.
* **Tracing and metrics** (`observability/tracing.py`, `telemetry.py`, `metrics.py`): an OpenTelemetry span per call that joins the caller’s W3C trace context, plus tool-call and duration metrics. Both export over OTLP/HTTP when `ARROWHEAD_OTEL_EXPORTER_OTLP_ENDPOINT` is set and are no-ops otherwise, so telemetry costs nothing until a collector is configured.
## Transport security
[Section titled “Transport security”](#transport-security)
The server does not terminate TLS. In any non-local deployment the hosting platform or a reverse proxy must provide HTTPS. The HTTP endpoint also enforces `Host` and `Origin` allowlists (configurable) to defend against DNS rebinding of the endpoint itself; with neither list set the check stays off, matching the documented platform-proxy posture where the perimeter rewrites `Host` freely.
Serving HTTP with authentication disabled would expose every tool over the network with no scope or per-resource check, so the server refuses to start in that configuration unless a deployment sets `ARROWHEAD_ALLOW_INSECURE_HTTP` for a deliberate trusted-network test.
## MCP specification and the request path
[Section titled “MCP specification and the request path”](#mcp-specification-and-the-request-path)
Arrowhead runs on the official MCP Python SDK, version 2. One streamable-HTTP endpoint serves both protocol eras: a client sending the reserved `_meta` envelope reaches the sessionless 2026-07-28 leg, and a client sending the `initialize` lifecycle reaches the handshake-era leg, negotiated down to the latest handshake version. Cache hints, argument completion, and elicitation are all first-class SDK features, so the server carries no low-level API patches.
Because the SDK returns a tool’s exception message to the client, the masking boundary is Arrowhead’s own: every tool, resource, and prompt is registered wrapped in a guard chain (`arrowhead.runtime.guards`) that reproduces the prior middleware order (tracing span, audit line, kill switch, rate limit, scope check) and, at its edge, re-raises a deliberate `ToolError` verbatim while replacing every other exception with a generic refusal. A driver string, a stack detail, or a database error therefore never reaches a client. Because the guard lives on the component, an import-and-call invocation runs the identical path as an HTTP request.
### Authorization and token verification
[Section titled “Authorization and token verification”](#authorization-and-token-verification)
Bearer tokens are verified in-house (`arrowhead.auth.verifier`) with pyjwt: signature against the issuer’s JWKS or a static key, issuer, expiry, and, critically, that the token’s audience names this server, so a token minted for another service is refused. The `workos` provider is configuration sugar that derives the AuthKit issuer and JWKS URI. RFC 9728 protected-resource metadata is served at `/.well-known/oauth-protected-resource/mcp` and deliberately advertises no scope list: a caller discovers the scopes it is entitled to through the filtered tool listing, never through an unauthenticated probe, and a scoped call it cannot make reports the tool as unknown.
### Trusted internal endpoints for a local model
[Section titled “Trusted internal endpoints for a local model”](#trusted-internal-endpoints-for-a-local-model)
The completion providers (`code_explain`, `summarize_diff`, `rerank`) post through the same SSRF-guarded, redirect-refusing path as the embedding client. A local model server (Ollama, vLLM, LM Studio) sits at a loopback address the guard refuses by default, so reaching one requires naming its exact `host:port` pair in `ARROWHEAD_LLM_INTERNAL_HOSTS`. That exemption skips only the reachability refusals (egress allowlist, port allowlist, non-public address) for the named pair; the scheme check, the single DNS resolution, and the address pinning still apply, and only configuration-addressed clients pass it. The public fetch tools never accept the parameter, so the public SSRF posture is unchanged.
### Sandboxed execution guarantees, and non-guarantees
[Section titled “Sandboxed execution guarantees, and non-guarantees”](#sandboxed-execution-guarantees-and-non-guarantees)
The `exec` family (`run_snippet`, `run_tests`) is off by default and, even when `ARROWHEAD_EXEC_ENABLED` is set, denied by the default authorization policy: a deployment opts in twice, by the flag and by granting the `execute` action. The default subprocess runner runs each command in a fresh process group with a scrubbed environment (no `ARROWHEAD_` configuration, no ambient secrets), bounds CPU, memory, wall time, output, and process count, executes an explicit argv rather than a shell, and secret-scans and redacts output before it leaves. Output beyond the byte cap kills the run immediately, so a flooding child can neither fill server memory nor hold its wall clock open. `run_tests` executes only inside a scratch copy of the authorized subtree (bounded by its own file and byte caps), so a test command can never write into the real repository. What the subprocess runner does **not** do: it does not block network egress or filesystem reads beyond ordinary OS permissions. A deployment that needs those isolated uses the container runner (`ARROWHEAD_EXEC_RUNNER=container`), which adds `--network none`, a read-only root filesystem, a pids limit, a memory cap, and the same CPU-time budget enforced as a ulimit inside the container, or an external sandbox. `RLIMIT_AS` is unreliable on macOS, so memory bounding is best effort there and dependable on Linux.
### Context packing and working sets
[Section titled “Context packing and working sets”](#context-packing-and-working-sets)
`pack_context` never returns content that bypasses the guards: the pack itself is authorized at the boundary as a search over the corpus namespace, every pinned working-set item is re-authorized at pack time (a grant can change after pinning), every snippet is secret-scanned and redacted before it is packed, and each is wrapped in per-snippet untrusted framing with a random marker the content cannot forge. The bundle is capped at the token budget, and an item that would exceed it is dropped with a truncation flag rather than blowing the budget.
Working sets are the packer’s write surface. `workingset_update` authorizes every item at pin time under the same per-resource check that would govern reading it, validates kinds and identifiers, and stores the set keyed by the caller’s verified identity, so another owner’s set reads as simply not found. The registry is bounded twice, per owner and globally, so neither one caller nor many distinct callers can grow server memory without limit.
### Tool integrity
[Section titled “Tool integrity”](#tool-integrity)
The `arrowhead://integrity` resource returns a sha256 digest over the semantic surface of every enabled tool (name, description, input and output schema, annotations). A client records the digest at consent time and compares it each session, so a later change to what a tool advertises (a rug pull) is detectable. The digest legitimately differs between profiles and kill-switch states; a client pins the digest of the deployment it consented to.
# Threat model
> The attack surface per tool and what is out of scope.
This document breaks down the attack surface per tool and states what is explicitly out of scope for this version.
## Assets and trust boundaries
[Section titled “Assets and trust boundaries”](#assets-and-trust-boundaries)
* **The host environment.** The process runs inside a container with access to outbound network and a small jailed directory. The metadata endpoint, internal services, and the broader filesystem are assets an attacker would want to reach through the tools.
* **The authorization boundary.** Over HTTP, callers are authenticated by an external OAuth 2.1 authorization server. The bearer token is the only trusted assertion of identity; headers, arguments, and anything else the caller controls are untrusted.
* **Log storage.** Audit logs are shipped somewhere. Anything written into them is assumed to be readable by operators, so argument values must never land there.
The caller (the MCP client, and by extension whatever drives it) is untrusted. Every tool argument is attacker-controlled input.
## Per-tool attack surface
[Section titled “Per-tool attack surface”](#per-tool-attack-surface)
### `safe_fetch`
[Section titled “safe\_fetch”](#safe_fetch)
* **Inputs:** one URL, plus any `Location` headers on redirects.
* **Attacks considered:** requests to the cloud metadata endpoint (IPv4 and IPv6), private/loopback/link-local ranges, non-HTTP schemes, DNS rebinding (public record at check time, private at connection time), redirect chains that end at an internal address, oversized responses used to exhaust memory, and reuse of the caller’s token as outbound credentials.
* **Controls:** scheme allowlist, resolve-and-check-every-address, address pinning, per-hop re-validation, response size cap, and construction of outbound requests without inbound credentials.
* **Residual risk:** by default any service at a genuinely public address is fetchable, since this is a general-purpose fetch tool. A deployment that needs to restrict destinations sets an egress allowlist (`ARROWHEAD_EGRESS_ALLOWED_HOSTS`), which confines the fetch and retrieve tools to approved hosts and is enforced on every redirect hop. With it unset the guard still blocks private ranges but not public destinations.
### `calculate`
[Section titled “calculate”](#calculate)
* **Inputs:** one expression string.
* **Attacks considered:** code injection via `eval`-style payloads, shell metacharacters, Python dunder traversal (`().__class__…`), and resource-exhaustion via very long or deeply structured expressions.
* **Controls:** character allowlist, AST interpreter restricted to numbers and basic operators, length and node-count bounds.
* **Residual risk:** none known within arithmetic; the tool intentionally does nothing but arithmetic.
### `read_file`
[Section titled “read\_file”](#read_file)
* **Inputs:** one relative path string.
* **Attacks considered:** `../` traversal, absolute paths, symlinks pointing outside the jail, null-byte tricks, path disclosure through error messages, and oversized files.
* **Controls:** relative-path validation, canonicalization with symlink resolution, jail-containment check, path-free error messages, size cap.
* **Residual risk:** any file the operator places inside the jail is readable by any authorized caller. The jail’s contents are the operator’s responsibility.
### Document suite (`doc_search`, `doc_read`, `doc_retrieve`, `doc_scan`, `doc_write`)
[Section titled “Document suite (doc\_search, doc\_read, doc\_retrieve, doc\_scan, doc\_write)”](#document-suite-doc_search-doc_read-doc_retrieve-doc_scan-doc_write)
* **Inputs:** a corpus-relative path or prefix, a search query, content bytes, and (for retrieve) a URL. All are attacker-controlled.
* **Attacks considered:** indirect prompt injection via returned content; Markdown image/link exfiltration and embedded HTML/script; JSON bombs (deep nesting, duplicate keys, huge structures); ANSI/control/UTF-7/homoglyph text injection; ReDoS from a user-supplied search pattern; path traversal and symlink escape on read, scan, and write; clobbering or partially writing a document; writing outside the caller’s namespace; leaking a secret’s raw value through a scan result or log; and, on retrieve, the full SSRF set plus decompression bombs.
* **Controls:** the per-format content sanitizers and provenance wrapping; the bounded, symlink-safe document store with atomic no-clobber writes and quotas; scope-by-verb plus a default-deny per-resource authorization check; redaction-only scan findings; a ReDoS-resistant matcher with a timeout; the reused SSRF guard and decompressed-size cap on retrieve; and elicitation confirmation for destructive overwrites.
* **Residual risk:** the sanitizers are conservative transforms, not full renderers; a client that renders returned Markdown must still apply its own output-side controls (an image proxy, a content security policy). Search and scan read document contents, so a caller with those scopes over a subtree can infer the presence of matching or sensitive data within what the policy lets it access.
### Data connectors (`sql_query`, `vector_search`, `vector_query`, `hybrid_query`, `doc_index`)
[Section titled “Data connectors (sql\_query, vector\_search, vector\_query, hybrid\_query, doc\_index)”](#data-connectors-sql_query-vector_search-vector_query-hybrid_query-doc_index)
* **Surface:** arbitrary SQL and vector queries against a configured database, plus an ingestion path that writes rows and triggers outbound embedding requests. The primary risks are write/DDL smuggling, injection through table or column names, denial of service through an expensive query, cross-tenant reads or writes, and the embedding egress being steered somewhere hostile.
* **Mitigations:** a real SQL parser (with bounded nesting so it cannot be made to exhaust its stack) admits only a single read-only `SELECT`, rejecting stacked statements, writes, DDL, `SET`, row-locking clauses, and a denylist of side-effecting, file, network, and administrative functions. Every referenced table is authorized; a tableless query is authorized against a dedicated resource the default policy does not grant. On Postgres the query runs in a read-only transaction under a server-side statement timeout; a read-only role is the primary control on every engine. Results are bounded by byte, row, and column count. For `vector_search`, the connector requires Postgres, the collection is allow-listed, all interpolated identifiers pass a strict guard, the embedding is a bound parameter of finite numbers, and the tenant filter is the authenticated caller injected server-side. `vector_query` and `hybrid_query` authorize before embedding, so a denied caller cannot trigger an outbound request, and the embedding endpoint is configuration-addressed, SSRF-guarded, egress-allowlisted, and redirect-refusing. `doc_index` requires the `ingest` action the default policy denies, writes through a credential separate from the read-only DSN, and binds the tenant from authorization, so one tenant cannot index into another’s rows.
* **Residual risk:** the parser and function denylist are defense in depth, not a sandbox; a misconfigured deployment that grants a writable role or a broad authorization policy widens the surface, and the denylist is not exhaustive. On non-Postgres engines there is no server-side read-only transaction, so the read-only role and the parser carry the weight; on SQLite an expensive query cannot be interrupted mid-flight by the in-process deadline, so SQLite is a demo backend, not a hardened multi-tenant target. Tenant isolation depends on the collection carrying a correct tenant column.
### Resources, prompts, completions, and tasks
[Section titled “Resources, prompts, completions, and tasks”](#resources-prompts-completions-and-tasks)
* **Surface:** a second path to corpus content (resources), a server-provided instruction channel (prompts), a discovery oracle (completions), and server-minted handles for background work (the `tasks` family: `scan_corpus_async`, `task_get`, `task_update`, `task_list`, `task_schedule`).
* **Mitigations:** resource reads run the same per-resource authorization and sanitization as `doc_read`; prompts reference resources or tools rather than inlining untrusted content and sanitize their arguments; completions are filtered by the caller’s read authorization, bounded, and wrapped so they pass the same rate limit, kill switch, and audit line as a tool call even though the low-level path bypasses the middleware chain; tasks are authorized at start, owner-scoped so a caller can only poll or cancel its own, and bounded in count.
* **Residual risk:** completions confirm which authorized paths exist, the same inference a caller with search access already has. The task registry is in process, so a task is visible only on the instance that created it; a pending timer does not survive a restart, and a completed occurrence is subject to the finished-task eviction like any other task. Timers are additionally capped per owner in count, delay, recurrence interval, and payload size, so a caller cannot park unbounded state or a tight self-refiring loop in the registry.
### Repo intelligence (`code_search`, `code_read`, `symbol_map`, `dependency_graph`)
[Section titled “Repo intelligence (code\_search, code\_read, symbol\_map, dependency\_graph)”](#repo-intelligence-code_search-code_read-symbol_map-dependency_graph)
* **Surface:** read-only access to a source tree, which typically holds credentials in config files, private URLs in version-control metadata, and the shape of an organization’s code.
* **Mitigations:** the repo store applies the document store’s containment rule (resolve, then require the path to stay inside the root) to a separate jail, is read-only by construction, prunes `.git` and dependency directories from every walk so version-control internals are unreachable, refuses binary files by content sniff, and caps per-file and per-walk size. Repository resources have their own authorization kinds, so a grant over corpus documents never reaches code and vice versa; the default policy grants only read and search on the repo. Symbol and dependency results are filtered per file, so they never name something in a file the caller could not read directly.
* **Residual risk:** a caller granted broad repo read can infer the presence of matching content the same way corpus search allows. Symbol extraction for non-Python languages without the tree-sitter extra is a line heuristic and may miss or misclassify a definition.
### Assist tools (`code_explain`, `summarize_diff`, `rerank`)
[Section titled “Assist tools (code\_explain, summarize\_diff, rerank)”](#assist-tools-code_explain-summarize_diff-rerank)
* **Surface:** an outbound request to a model backend carrying code or a diff, and model output flowing back into the caller’s context.
* **Mitigations:** the completion providers post through the SSRF-guarded, redirect-refusing path the embedding client uses; the API key is configuration-supplied and never appears in an error. `code_explain` reads through the repo jail under the same authorization as `code_read`, so the assist path can reach no code the read path could not. A local model endpoint is reachable only through the exact-pair internal allowlist. Every input is bounded and sanitized, and all model output returns inside the untrusted framing, because a model reading attacker-influenced source can be steered by it; `rerank` asks only for an ordering and parses it defensively, so a hostile answer cannot inject content.
* **Residual risk:** the backend is a third party (unless self-hosted); what it does with a prompt is outside this server’s control. The tools are only as private as the endpoint an operator points them at.
### Sandboxed execution (`run_snippet`, `run_tests`)
[Section titled “Sandboxed execution (run\_snippet, run\_tests)”](#sandboxed-execution-run_snippet-run_tests)
* **Surface:** arbitrary code execution, the highest-privilege operation the server offers.
* **Mitigations:** off by default and denied by the default policy, so a deployment opts in twice (the `ARROWHEAD_EXEC_ENABLED` flag and the `execute` grant). The subprocess runner scrubs the environment, bounds CPU, memory, wall time, output, and process count, runs an explicit argv with no shell, uses a fresh per-call scratch directory, and secret-scans and redacts output. `run_tests` copies only the authorized subtree into scratch, so a test that writes never touches the real repository.
* **Residual risk (stated plainly):** the subprocess runner does **not** block network egress or filesystem reads beyond OS permissions. A snippet can reach the network and read world-readable files. A deployment that needs those isolated must use the container runner (`--network none`, read-only root) or an external sandbox. On macOS, `RLIMIT_AS` memory bounding is best effort.
### Context packer and working sets (`pack_context`, `workingset_*`)
[Section titled “Context packer and working sets (pack\_context, workingset\_\*)”](#context-packer-and-working-sets-pack_context-workingset_)
* **Surface:** a single call that assembles many sources into one bundle, and a per-caller store of pinned references.
* **Mitigations:** the packer reaches every source through the same internal functions the standalone tools use, re-authorizes each pinned item at pack time, secret-scans and redacts every snippet before it leaves, wraps each in per-snippet untrusted framing with an unforgeable random marker, and caps the bundle at the token budget. Working sets are owner-keyed and bounded exactly like the task registry; a set another owner holds reads as not found, and pinning authorizes each item.
* **Residual risk:** the working set registry is in process, so a set is visible only on the instance that created it, the same single-instance limitation the task registry documents.
### Memory and scratchpad (`memory_*`, `kv_*`)
[Section titled “Memory and scratchpad (memory\_\*, kv\_\*)”](#memory-and-scratchpad-memory_-kv_)
* **Surface:** durable caller-curated state: agent memories in namespaces and an exact-key scratchpad, both landing on a jailed file tree by default or in two fixed Postgres tables when configured. Names (namespace, id, key) are caller-supplied strings that become path segments or bound parameters.
* **Mitigations:** every record is keyed server-side by the verified caller identity, so ownership is structural and a foreign entry reads as not found regardless of policy shape; the file backend hashes the owner into the path so a hostile subject string never shapes one. Names pass strict allowlist validators (no separators, no dotfiles, no traversal by construction) and the store enforces containment anyway. Per-owner bounds cap namespaces, entries, keys, sizes, and a total quota. Reads are sanitized and framed as untrusted, like document reads; recall results honestly label the search performed. On Postgres every identifier in the SQL is a constant and every runtime value is a bound parameter.
* **Residual risk:** memories are deliberately not secret-scanned or blocked on write — remembering user data is the tool’s purpose — so what agents store is governed by the deployment, and an operator who needs DLP applies it downstream of reads. Content-hash dedup means a caller can confirm it already stored some exact content, an inference limited to its own owner scope.
### Outbound notify (`notify_webhook`)
[Section titled “Outbound notify (notify\_webhook)”](#outbound-notify-notify_webhook)
* **Surface:** a deliberate egress channel that posts caller-shaped JSON to an operator-named destination — the one tool whose purpose is to emit data outward, and therefore an exfiltration primitive if its destination control fails.
* **Mitigations:** double opt-in (a non-empty allowlist registers the family; the `notify` action must additionally be granted, and is absent from the default grants). Allowlist matching parses both sides and compares scheme, lowercased host, resolved port, and path on a component boundary, defeating userinfo and host-suffix tricks. The SSRF guard resolves and pins even allowlisted targets, so private ranges, metadata addresses, and DNS rebinds are refused; redirects are refused outright. Payload and captured response are byte-capped, the response is sanitized and framed as untrusted, and the caller’s credentials are never attached.
* **Residual risk:** an allowlisted public destination receives whatever the granted caller chooses to send; the allowlist bounds where data can go, not what it says. Naming a destination is therefore an explicit operator declaration that outbound data flow to it is acceptable.
## Cross-cutting
[Section titled “Cross-cutting”](#cross-cutting)
* **Authentication:** enforced over HTTP; audience validation is mandatory and tokens are never forwarded. Over stdio (local development against a process the operator already owns) auth is skipped.
* **Tool integrity:** the `arrowhead://integrity` digest lets a client pin the tool surface it consented to and detect a later change to what a tool advertises.
* **Authorization:** scopes are split by verb, and the document tools add a per-resource, default-deny check on top; a scope alone never grants access to a specific document.
* **Rate limiting and kill switch:** bound abuse volume and allow rapid disabling of a tool without redeploying. Every tool has a ceiling; a tool without an explicit one falls back to a configurable default.
* **Audit and tracing:** provide after-the-fact accountability without leaking argument values; authorization denials are recorded distinctly.
## Explicitly out of scope for this version
[Section titled “Explicitly out of scope for this version”](#explicitly-out-of-scope-for-this-version)
* **Full multi-tenant isolation.** The per-resource policy demonstrates per-subject write namespaces and prefix grants, but there is no tenant boundary beyond the subject claim, no per-document ACL store, and no external policy engine wired in (the `Authorizer` seam exists for one).
* **Output-side rendering controls.** Returned content is sanitized at the server, but a client that renders it must still apply its own image proxy and content security policy; the server cannot enforce those.
* **Content classification beyond secrets/PII patterns.** `doc_scan` uses a fixed pattern set; it is not a comprehensive DLP or malware scanner.
* **TLS termination.** Delegated to the hosting platform or a reverse proxy.
* **Authorization-server security.** Token issuance, client registration, and key rotation belong to the external IdP, not to this server.
* **Network egress and filesystem isolation for sandboxed execution.** The default subprocess runner bounds resources but does not confine network or filesystem access beyond OS permissions; isolation requires the container runner or an external sandbox. This is a documented property of the runner choice, not a gap to be closed in the subprocess path.
* **Denial of service beyond per-caller rate limits.** Network-level flood protection is the platform’s job. The document quota check walks the corpus on each write, which is O(corpus size); the in-memory rate-limit store is per-replica, and while the Redis store reads time from the server, an operator running the in-memory store across replicas with skewed clocks gets per-replica limits.
* **Filesystem races on the read path.** The document store and `read_file` resolve a path and then open it; an attacker with write access inside the jail could swap a path for a symlink between the two steps (a TOCTOU window). Atomic writes are hard-link based, which assumes the filesystem supports hard links.
* **Multi-line and non-ASCII-normalized secrets.** `doc_scan` is line-oriented, so a secret split across lines (a wrapped private key) is detected by its header only, and its tag is of the NFC-normalized text, not the on-disk bytes.