Skip to the content.

CONTRACT.md — the source of truth

Everything that has to fit together across the sidecar, the agent runtime, the control plane, the CLI and the Kubernetes manifests is defined here. When code and this file disagree, this file wins and the code is wrong. Keep it updated in the same change.

0. Names (placeholders, one find/replace away)

Thing Name Where
Product attache repo, docs
Sidecar daemon binary attached services/attached
CLI attache services/attache-cli
Control plane / CA embassy services/embassy
Agent + policy spec file charter.yaml examples/charters/*.charter.yaml
Kubernetes API group attache.io/v1alpha1 apiVersion in charters, labels
Agent runtime (Python) attache-runtime runtime/
Discipline AgentSecOps README

1. What attache is

The agent is a sidecar of the service, not a service of its own.

                       pod: orders                                      pod: inventory
 ┌────────────────────────────────────────────────┐        ┌────────────────────────────────────────┐
 │  app container (Go, no SDK)                    │        │  app container (FastAPI, no SDK)       │
 │   POST localhost:3500/v1/agents/ops-assistant  │        │   GET /stock      POST /admin/reset    │
 │             │                                  │        │        ▲                 ▲ (denied)    │
 │  ───────────┼──────────────────────────────    │        │  ──────┼─────────────────┼──────────── │
 │  attached (sidecar)  ◀── charter.yaml (CM)     │  mTLS  │  attached (sidecar) ◀── charter.yaml   │
 │   HTTP 3500 · gRPC 50001 · mesh 50002 ─────────┼────────┼─▶ mesh 50002  authz(SAN + agent)       │
 │   guardrails · budgets · authz · audit JSONL   │        │   guardrails · budgets · authz · audit │
 │        │ unix socket                           │        │        │ unix socket                   │
 │   attache-runtime (python) ── Claude / mock    │        │   attache-runtime (python)             │
 │     ops-assistant (orchestrator)               │        │     stock-analyst (agent)              │
 └────────────────────────────────────────────────┘        └────────────────────────────────────────┘
                 ▲ certs: Secret attache-cert-orders           ▲ certs: Secret attache-cert-inventory
                 └──────────────── embassy (CA, Job) ──────────┘  spiffe://attache/ns/<ns>/svc/<svc>

Principles every component honours:

  1. Zero SDK. The app talks plain HTTP (or gRPC) to localhost. Any language.
  2. Everything an agent does goes through the sidecar and is audited: agent invocations, model calls, tool calls, service calls, HTTP calls, authz decisions, guardrail decisions, budget decisions. One JSONL line each.
  3. Policy is data. Agents, tools, budgets, guardrails, authz and audit live in charter.yaml. The sidecar validates it on load and refuses unknown fields. A UI will generate it later; the schema is the API.
  4. Identity is the certificate. Every pod has an mTLS identity spiffe://attache/ns/<namespace>/svc/<service>. The callee sidecar decides using the caller’s certificate SAN plus the calling agent name, never a header alone.
  5. Default deny for authz once a charter exists. Explicit allow rules are the only way an identity reaches an agent or an agent reaches a path.

2. Components and ports

Component Listens on Who calls it Protocol
attached app HTTP 127.0.0.1:3500 the app container HTTP/1.1 JSON
attached app gRPC 127.0.0.1:50001 the app container gRPC plaintext
attached mesh 0.0.0.0:50002 (pod IP, via Service) other pods’ sidecars gRPC mTLS
attached tools unix:/run/attache/sidecar.sock the local agent runtime gRPC
attache-runtime unix:/run/attache/runtime.sock the local sidecar gRPC
embassy none (Job) — see §7 — Kubernetes API

Peer discovery: the mesh address of service S in namespace N is S.N.svc.cluster.local:50002 unless mesh.peers.S in the charter overrides it. Every demo Service therefore exposes port 50002 (mesh) next to the app port.

Flags / environment of attached (flag wins over env):

Flag Env Default
--charter ATTACHE_CHARTER /etc/attache/charter/charter.yaml
--tls-dir ATTACHE_TLS_DIR /etc/attache/tls (ca.crt, tls.crt, tls.key)
--http-addr ATTACHE_HTTP_ADDR 127.0.0.1:3500
--grpc-addr ATTACHE_GRPC_ADDR 127.0.0.1:50001
--mesh-addr ATTACHE_MESH_ADDR 0.0.0.0:50002
--socket-dir ATTACHE_SOCKET_DIR /run/attache
--runtime-cmd ATTACHE_RUNTIME_CMD attache-runtime (shell words; --listen and --sidecar are appended)
--no-runtime ATTACHE_NO_RUNTIME off. 1/true/yes/on = do not spawn the runtime; something else serves runtime.sock, and readiness still follows its Health
— ANTHROPIC_API_KEY unset → the runtime uses the mock model for every agent

3. charter.yaml

Top-level keys are exactly these; unknown keys anywhere are a validation error (attache charter validate exits 1 and names the path). JSON Schema: schemas/charter.schema.json, generated from the Rust types by attache charter schema; a test keeps the file in sync.

apiVersion: attache.io/v1alpha1
kind: Charter
service:
  name: orders                 # DNS-1123 label
  namespace: attache-demo      # DNS-1123 label
  app_port: 8080               # where the app container listens on localhost
mesh:                          # optional
  port: 50002
  peers: {}                    # service name → host:port override
agents:
  - name: ops-assistant        # unique, DNS-1123 label
    type: orchestrator         # agent | orchestrator (orchestrator = may use `agent` tools)
    model: claude-opus-5-5     # any Claude model id, or `mock`
    system: "..."              # system prompt
    tools: []                  # see below
    budgets:
      max_tokens_per_call: 2048      # max_tokens for each model call, 1..=128000 (thinking counts against it)
      max_calls_per_minute: 30       # inbound invocations of this agent, sliding window
      max_cost_usd_per_day: 5.0      # sum of model_call cost_usd per UTC day
      max_steps: 6                   # model-call iterations per invocation
guardrails:
  max_input_bytes: 16384
  pii_redact: true             # redact emails and phone numbers in agent output (response + audit)
  input_deny:                  # regex (Rust `regex` syntax) applied to the raw input; deny on match
    - { name: no-secrets, regex: "(?i)(api[_-]?key|password)\\s*[:=]" }
  output_deny: []              # same, applied to the final output
  allowed_tools:               # agent → tool names it may use (default: every tool it declares)
    ops-assistant: [lookup_order, stock_analyst]
  allowed_services:            # agent → services it may call at all (default: every service its tools name)
    ops-assistant: [orders, inventory]
authz:
  default: deny                # deny | allow — applies when no rule matches
  callers:                     # who may invoke which of MY agents
    - identity: local                                        # the app container (localhost)
      agents: ["*"]
    - identity: spiffe://attache/ns/attache-demo/svc/orders  # a peer pod
      agents: [stock-analyst]
      caller_agents: [ops-assistant]   # optional: only when the caller's sidecar says this agent is calling
  services:                    # path-level rules for service invocation
    - name: inbound-orders-to-stock
      identity: spiffe://attache/ns/attache-demo/svc/orders  # caller pod; omit = any (incl. local)
      agent: ops-assistant                                   # calling agent; omit = any
      service: inventory                                     # target service (self when evaluated inbound)
      allow: ["GET /stock", "GET /stock/*"]
      deny:  ["* /admin", "* /admin/*"]
audit:
  sink: stdout                 # stdout | file
  file: /var/log/attache/audit.jsonl   # only with sink: file; the image makes /var/log/attache writable
  redact: []                   # dotted paths inside `input`/`output` to replace with "[redacted]"

3.1 Tools

- name: lookup_order           # tool name shown to the model, unique per agent
  description: "..."           # required
  type: service                # service | agent | http
  parameters:                  # JSON Schema object for the tool input (default: {type: object, properties: {}})
    type: object
    properties: { order_id: { type: string } }
    required: [order_id]
  # type: service — call another (or this) service's HTTP API through the sidecar
  service: orders              # target service name; this service's own name = local app
  method: GET
  path: /orders/{order_id}     # `{param}` is substituted from the tool input; leftovers become the JSON body (POST/PUT/PATCH) or query (GET/DELETE)
  # type: agent — invoke another agent (orchestrator pattern)
  agent: stock-analyst
  service: inventory           # omit for a local agent
  # type: http — a URL performed by the sidecar; `{param}` only in the path/query,
  # never in the scheme, host or port (the origin is fixed by the charter)
  method: GET
  url: https://example.com/items/{id}

agent tools take { "input": string }; their parameters default to that. Only type: orchestrator agents may declare agent tools. An agent tool whose service is this charter’s own service is a local agent tool.

{param} values are percent-encoded by the runtime (/ becomes %2F), so a value can never add path segments.

3.2 Validation rules (all enforced by attache-charter)

4. Wire protocol

Protobuf package attache.v1alpha1 under proto/attache/v1alpha1/ (common.proto, app.proto, mesh.proto, runtime.proto). Generated Rust in crates/attache-proto; generated Python in runtime/src/attache_runtime/pb/ (committed; regenerate with make proto).

4.1 App → sidecar (HTTP, 127.0.0.1:3500)

POST /v1/agents/{name}/invoke

{ "input": "How many WIDGET-1 are in stock?", "context": { "any": "json" } }

Request headers honoured: traceparent (W3C; generated when absent), content-type: application/json.

Response 200:

{
  "status": "ok",
  "request_id": "7f0c…",            "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "agent": "ops-assistant",
  "output": "WIDGET-1: 42 on hand …",
  "usage": { "input_tokens": 812, "output_tokens": 96, "cost_usd": 0.0052 },
  "steps": [
    { "kind": "model_call",   "name": "claude-opus-5-5",            "decision": "allow", "latency_ms": 1180, "summary": "stop=tool_use" },
    { "kind": "agent_call",   "name": "inventory/stock-analyst",    "decision": "allow", "latency_ms": 930 },
    { "kind": "service_call", "name": "inventory POST /admin/reset","decision": "deny",  "rule": "authz.services.deny-admin", "latency_ms": 4 }
  ]
}

Other statuses, same envelope with status and rule/reason:

HTTP status when
400 error bad JSON / missing input
403 denied authz or guardrail denied the invocation itself
404 error unknown agent
429 denied budget exceeded (budget.max_calls_per_minute, budget.max_cost_usd_per_day)
502 error runtime unavailable / model error

A tool call that is denied inside the run does not fail the invocation; it is returned to the model as an error tool result, appears in steps[] with decision: deny, and is audited.

Response headers always include traceparent and x-attache-request-id.

Also: GET /v1/agents → {"agents":[{"name","type","model","tools":[...]}]}, GET /v1/charter → the loaded charter as JSON, GET /healthz → 200 ok (process up), GET /readyz → 200 once the charter is loaded, TLS is loaded and the runtime answered Health; 503 otherwise. Body: {"ready", "runtime", "model_provider", "mesh"}.

An unknown agent is reported only after authz.callers passed: the app gets 404 agent.unknown, a peer not allowed for that name gets 403 authz.callers.* (no probing of agent names over the mesh).

gRPC equivalent on 127.0.0.1:50001: AppApi.InvokeAgent(InvokeAgentRequest) and AppApi.ListAgents. Denials map to PERMISSION_DENIED / RESOURCE_EXHAUSTED, unknown agent to NOT_FOUND, runtime errors to UNAVAILABLE; the JSON envelope above is the gRPC message InvokeAgentResponse.

4.2 Sidecar ↔ sidecar (mesh, gRPC over mTLS, port 50002)

Metadata on every call: traceparent, x-attache-request-id, x-attache-agent (calling agent). Caller identity is only taken from the client certificate’s URI SAN. x-attache-agent is a claim made by the caller’s sidecar (it is set by attached, never by the app or the model); the callee cannot see the caller’s charter, so it trusts the pair (SAN, agent) exactly as far as its own authz rules say.

4.3 Sidecar ↔ runtime (gRPC over Unix sockets)

Runtime (served by the runtime):

SidecarTools (served by the sidecar on sidecar.sock, called by the runtime for every tool call so the sidecar can authorize and audit it):

The sidecar does not trust the runtime (it is driven by a model). Every call must name a tool the agent declared and may use (guardrail.allowed_tools) and must match that tool’s declaration — same service and method, same agent target, same http origin — else guardrail.tool_spec.

The sidecar adds traceparent (a child span) and x-attache-request-id to every request it sends — local app, mesh, http tool — and, toward an app, x-attache-caller and x-attache-agent. Values for traceparent, tracestate, host, content-length and any x-attache-* header supplied by the runtime or a peer are dropped, never forwarded.

Outcomes, for all three tool RPCs (message = reason, metadata x-attache-rule):

outcome gRPC status runtime sees audit decision
allowed and done OK the result allow
denied by policy (here or at the callee) PERMISSION_DENIED (RESOURCE_EXHAUSTED for budget.*) is_error tool result denied by <rule>: <reason> deny + rule
allowed but failed (peer down, app down, model error, unknown agent) UNAVAILABLE (NOT_FOUND for agent.unknown) is_error tool result tool failed: … allow + error

A RESOURCE_EXHAUSTED without a budget.* rule (gRPC’s own message-size limit) is a failure, not a denial.

Clients of the unix sockets must send a plain :authority (the runtime sets grpc.default_authority=localhost): grpc-python’s default for unix: targets is the percent-encoded socket path, which the sidecar’s HTTP/2 stack rejects with PROTOCOL_ERROR.

5. Identity, mTLS, authz

5.1 Authz evaluation order (callee side, inbound)

  1. authz.callers: find rules whose identity equals the caller (local or the SAN); the agent is allowed if any such rule lists it or "*" and the rule’s caller_agents is empty or contains the calling agent (the caller sidecar’s x-attache-agent claim; for local agent calls, the local calling agent). Otherwise deny with rule authz.callers.default (no rule for that identity) or authz.callers.<service> (a rule exists but excludes the agent or the calling agent). This runs before the agent name is looked up.
  2. Budgets: budget.max_calls_per_minute, budget.max_cost_usd_per_day.
  3. Guardrails on input — the input and the context JSON, everything the model sees first: guardrail.max_input_bytes (their sum), guardrail.input_deny.<name> (either matches).
  4. Run. Tool calls: egress rules on the caller side (guardrail.allowed_tools, guardrail.tool_spec, guardrail.allowed_services, guardrail.path, authz.services for the target with identity local), inbound rules on the callee side (guardrail.path, authz.services for self with the caller’s SAN).
  5. Guardrails on output: guardrail.output_deny.<name> → deny; guardrail.pii_redact → rewrite, decision stays allow.

authz.services matching: a rule applies when its identity (if set) equals the caller identity, its agent (if set) equals the calling agent and its service equals the target. Among applying rules, any deny match wins (authz.services.<rule-name>); else any allow match allows (authz.services.<rule-name>); else authz.default. When no rule applies, authz.default (authz.default.deny / authz.default.allow). A pattern is "<METHOD> <path>" with * as the method wildcard and an optional trailing * on the path (/stock/* matches /stock/x and /stock/x/y, not /stock).

Path canonicalisation (guardrail.path): before any authz.services match, on both sides, the path is resolved with the same WHATWG URL parser the sidecar uses to send the request, so ./.. and percent-encoded dot segments (%2e%2e) are removed first — /stock/../admin/reset is matched, audited and sent as /admin/reset. A path that does not start with /, or contains whitespace, a control character, \, #, or an encoded /, \ or NUL (%2f, %5c, %00), is denied with guardrail.path.

identity: local in authz.services matches calls made by this pod’s own agents (egress and self calls); a SPIFFE identity matches inbound calls from that peer.

6. Audit record

One JSON object per line, written by attached only (sink stdout, readable with kubectl logs -c attached, or file). Field order is fixed as below so grep and humans see the same thing.

{
  "ts": "2026-10-20T19:02:11.482Z",
  "kind": "agent_invocation | model_call | tool_call | agent_call | service_call | http_call | authz | guardrail | budget",
  "request_id": "0c4a3f8e-…",             // one per top-level invocation, propagated unchanged across hops
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",   // W3C, propagated via traceparent
  "span_id": "00f067aa0ba902b7",           // this hop
  "parent_span_id": "b7ad6b7169203331",    // or null
  "service": "orders", "namespace": "attache-demo",
  "agent": "ops-assistant",                // the agent this record belongs to (null for pure authz failures before resolution)
  "caller": "local | spiffe://attache/ns/attache-demo/svc/orders",
  "caller_agent": "ops-assistant",         // when another agent made the call, else null
  "callee": "ops-assistant | anthropic:claude-opus-5-5 | mock | inventory/stock-analyst | inventory GET /stock | https://…",
  "direction": "inbound | outbound | internal",
  "input":  <json or string, redacted>,    // what went in (prompt/messages for model_call, args for tools, body for service calls)
  "output": <json or string, redacted>,    // what came out (null when denied)
  "input_sha256": "hex of the full, unredacted input serialisation",
  "output_sha256": "hex or null",
  "tokens": { "input": 812, "output": 96 } | null,
  "cost_usd": 0.0052 | null,
  "latency_ms": 1180,
  "decision": "allow | deny",
  "rule": "authz.services.deny-admin | guardrail.input_deny.no-secrets | budget.max_calls_per_minute | null",
  "reason": "human readable or null",
  "error": "string or null"
}

Redaction, in this order, over the full payloads:

  1. input_sha256 / output_sha256 are computed over the full original bytes, so a consumer can prove a payload later without the sidecar storing it.
  2. audit.redact dotted paths are replaced with "[redacted]" inside input/output (arrays are walked). Values found at those paths (strings and numbers of 4+ characters) are also remembered per request_id for 15 minutes and scrubbed from every later record of the same request in that sidecar — they reappear as text inside model prompts and tool results.
  3. guardrails.pii_redact rewrites emails to [redacted:email] and phone numbers to [redacted:phone] in the agent’s output returned to the caller and in every string of every audit input/output that sidecar writes.
  4. Payloads larger than 8 KiB are truncated to 8 KiB plus …(truncated, N bytes) — after redaction, so a cut never exposes part of a redacted value.

A mesh call refused for its certificate (authz.identity.invalid) is audited as kind authz with caller: "unknown".

Which records a single demo call produces (orders pod unless noted):

[orders]     model_call        outbound  ops-assistant → anthropic:<model> | mock          allow   (reported by the runtime)
[inventory]  model_call        outbound  stock-analyst → anthropic:<model> | mock          allow
[inventory]  tool_call         internal  stock-analyst → get_stock                         allow
[inventory]  service_call      internal  stock-analyst → inventory GET /stock/WIDGET-1     allow   (self → 127.0.0.1:app_port)
[inventory]  model_call        outbound  stock-analyst → …                                 allow
[inventory]  guardrail         internal  stock-analyst                                     allow   rule guardrail.pii_redact
[inventory]  agent_invocation  inbound   spiffe://…/svc/orders (ops-assistant) → stock-analyst   allow
[orders]     tool_call         internal  ops-assistant → stock_analyst                     allow
[orders]     agent_call        outbound  ops-assistant → inventory/stock-analyst           allow
[orders]     model_call        outbound  ops-assistant → …                                 allow
[orders]     agent_invocation  inbound   local → ops-assistant                             allow

Records are written when each step finishes, so an invocation’s own record comes after its children’s.

And the denied call: on the inventory pod service_call inbound spiffe://…/svc/orders (ops-assistant) → inventory POST /admin/reset deny rule=authz.services.deny-admin; on the orders pod the matching tool_call with decision: deny and the same request_id and trace_id.

7. Kubernetes packaging

deploy/ is a kustomize tree, applied with kubectl apply -k deploy/:

8. CLI

attache charter validate <file>        # exit 0 "valid", exit 1 with one error per line
attache charter schema                 # JSON Schema to stdout (== schemas/charter.schema.json)
attache logs <svc>[,<svc>…] [-n NS] [--context C] [--agent A] [--trace T] [--request R] [--kind K] [--denied] [-f] [--raw] [--file F|-] [--tail N]
attache version

attache logs runs kubectl logs -n NS -l 'attache.io/service in (<svcs>)' -c attached --tail N [-f], keeps only lines that parse as §6 records (sidecar diagnostics share the container log on stderr and are skipped), applies the filters (--trace/--request match by prefix; --agent matches agent or caller_agent), sorts by ts unless following, and prints one line per record:

19:02:11.482 orders    ops-assistant   agent_invocation inbound  local → ops-assistant            allow            1412ms
19:02:12.904 inventory stock-analyst   service_call     inbound  …/svc/orders → inventory POST /admin/reset   DENY authz.services.deny-admin 4ms

--file F (or - for stdin) reads records from a file instead of kubectl.

9. Agent runtime semantics

10. Testing gates

11. Known gaps (MVP)