Security¶
This is a demo meant for a sandbox AWS account and a demo Grafana Cloud stack. It makes some choices on purpose that you would not make in production. This page lists them so you can decide before you apply.
Content capture¶
content_capture = true (the default) puts prompt text, assistant responses and tool content in telemetry:
- Claude Code: the gateway pushes
OTEL_LOG_USER_PROMPTS,OTEL_LOG_ASSISTANT_RESPONSESandOTEL_LOG_TOOL_CONTENT, and full content mode for the Agent Observability plugin. Prompts, responses and tool output land in Loki and Agent Observability. OTEL_LOG_TOOL_DETAILS=1is pushed regardless ofcontent_capture, because without it MCP server and tool names export ascustom. It also logs tool inputs, including Bash commands.- The in-app agents record prompts, responses and tool content in their generations and logs.
- The LLM-judge evaluators send sampled content to the judge model, which Grafana Cloud calls with the Bedrock credentials you configured for the Agent Observability judge provider.
The demo only ever handles fictional data (invented clubs, bookmakers and offers, public open source repositories, and PII probes built from test card numbers and reserved example domains). With real users, capture content only for groups that need it, and treat the stack's logs and Agent Observability data as sensitive.
content_capture = false switches the content settings off for Claude Code and the agents. The prompt-level panels and the content-based evaluators then have nothing to work with.
Credentials in Terraform state¶
Terraform generates or receives every credential the demo uses, so they are all in state:
- Grafana Cloud access policy tokens (ingest: metrics, logs, traces and Agent Observability write; Firehose: metrics and logs write), the PDC network token, and an Admin service account token for the experiments;
- the Cognito app client secret and every developer's password;
- the gateway session signing secret and admin read and write keys, the Postgres passwords;
- the private CA and gateway TLS private keys.
Use an encrypted remote backend with tight access (for example S3 with encryption and a lock, or OpenTofu state encryption). The examples ship without a backend block: local state on your laptop is fine for a short demo, and nothing else.
The same values also live in:
- Kubernetes Secrets in the demo namespace (
<prefix>-grafana-otlp,<prefix>-agento11y,<prefix>-faro,<prefix>-experiments). The experiments Secret holds a stack Admin service account token, so anyone who can read Secrets in that namespace has Admin on the stack. Restrict Secret access in the namespace accordingly. The experiments account is Admin because Agent Observability's evaluation and suite writes are Admin-only today and there is no narrower Grafana role to name. A custom role scoped to those permissions replaces it once their exact names are confirmed and documented. - Secrets Manager (
<prefix>/agent-hostand the Firehose credentials), readable only by the agent host role and the Firehose roles. They are created with a zero-day recovery window so destroy removes them at once.
The agent host's user data holds no credentials; it reads them from Secrets Manager at boot.
The agent host and the gateway¶
- The instance has no key pair and no inbound security group rules. Access is through SSM Session Manager, so anyone with
ssm:StartSessionon it is effectively root on the host and can read every credential on it. The instance role itself is narrower than the usual SSM role: it grants Session Manager and Run Command only (notAmazonSSMManagedInstanceCore'sssm:GetParameter*on every parameter in the account), and its ECR pull permissions are scoped to this module's own image repositories rather than every repository in the account. - The gateway is published on
127.0.0.1:8443on the host only. Developer containers reach it on the compose network. The gateway additionally only accepts clients from private address ranges. - TLS uses a private CA generated by Terraform. The login bot checks the gateway certificate against it before signing in. The CA is trusted only inside the developer containers; on your laptop,
just login-tunnelpins the gateway's leaf key in a throwaway browser profile instead (see coding-agents.md). Never add the CA to a system or browser trust store: it has no name constraints and its private key is in Terraform state. - The gateway pushes a separate Agent Observability client token (
sigil:write,metrics:write,traces:write) to every signed-in developer as a managed environment variable, so any session can read it. It is scoped to the Claude Code plugin's own generations, metrics and traces, and shares nothing with the ingest token. The ingest token (metrics, logs, traces and Agent Observability write) never enters a developer container: on the host only the gateway'stelemetry.forward_toexport and the host's own Alloy use it, and in the cluster it sits in the<prefix>-grafana-otlpand<prefix>-agento11ySecrets for Alloy and the in-app agents. - The network is the boundary around the developer containers. Developers run on their own bridge network, and a host firewall (
agent-host/host/agent-host-firewall) keeps that network off everything except DNS, the gateway on 443 and the public internet. The firewall runs before Docker starts and before every compose run. It drops instance metadata (169.254.0.0/16), every RFC 1918 and CGNAT range and the host itself, and drops all IPv6 from the developer and PDC bridges; the instance's IMDS IPv6 endpoint is also disabled. Only the gateway's own fixed address may reach IMDS, for its Bedrock credentials. The same holds on the trusted core bridge, where the gateway also has a fixed address and every other service is dropped on the way to169.254.169.254. A developer container cannot reach IMDS, the VPC or Postgres. Postgres and the PDC agent sit on a third bridge that the developer network has no route to. - Developer containers can reach the gateway admin API. It is served on the same port 443 the developers use for inference, so the network does not separate them; the admin endpoints are protected only by the admin read and write keys, which never enter a developer container. A developer's session JWT should be refused there: confirm in your lab that a request to
/v1/organizations/spend_limits/effectivewith a session token instead ofx-api-keygets 401 or 403 before relying on it. - Each developer session auto-approves
python3,nodeandawk(plus read-only git, file tools,jqand the MCP servers) with nocurl,rm,npmornpx- the traffic loop's own scripted prompts need an interpreter to write and run small scripts. A prompt that talked a session into running an arbitrary script still runs it inside that isolated network, with nowhere to send anything private and no path to a credential beyond what the container already has. Edits and writes are auto-approved only inside the session's own temporary working directory;Read,GlobandGrepare approved anywhere, and reads and edits under~/.claudeare denied; becausepython3andnodecan still write anywhere the container user can, the container re-seeds its user-scope Claude config (settings.json, the MCP server list, userCLAUDE.md, agents, commands and skills) on every start and before every session, so anything a session persists there is dropped before the next one runs. Treat the containers as semi-trusted scripted demo traffic. Nobody has proven them safe against adversarial input, and the network isolation is what limits a misbehaving script.
Guards and evaluation scope¶
Every Agent Observability object the module creates is prefixed touchline_ and scoped so it only acts on the demo's traffic:
- In-app agent rules match the demo's agent names (
touchline-orchestratorand the four specialists). - The Claude Code online evaluation rule matches agent name
claude-codeand the tagservice.namespace=touchline, which the gateway sets on the demo developers' plugin, so other Claude Code users of the same stack are not scored. - The Claude Code guards (the hook rules below) match agent name
claude-code/touchlineand its subagents (claude-code/touchline/*), which the gateway sets for the demo developers throughAGENTO11Y_AGENT_NAME. They cannot use the tag: the plugin's guard calls carry no tags (agento11y plugin v0.48.0). Other Claude Code users of the same stack are not matched.
What the guards do:
| Rule | Phase | Action |
|---|---|---|
touchline_claude_code_pii_gate | preflight | deny: card number, US SSN or UK National Insurance number in a prompt |
touchline_claude_code_secrets | preflight | warn: API keys, tokens, private keys |
touchline_claude_code_redact_api_keys, _redact_common_pii | preflight | redact rules, warn mode |
touchline_claude_code_redact_tool_secrets | postflight | redacts secrets in tool content |
touchline_claude_code_content_safety | preflight | warn: toxic content (LLM judge) |
touchline_agents_injected_tool_result | preflight | warn: instructions injected through tool results |
Guards are a demonstration of the mechanism, not a data-loss control. The plugin's guard calls time out after 5 seconds, and a timeout fails open. Warn rules record and never block. Check redaction behaviour on your own stack before claiming it covers prompts.
Bedrock invocation logging¶
bedrock_invocation_logging_enabled is off by default, for good reasons:
- The setting is account- and region-wide. It captures prompt and completion text for every
bedrock-runtimecall in the region, from every workload in the account, not only this demo. - It replaces any existing invocation logging configuration.
- On destroy the module removes the configuration; it does not restore whatever was there before.
- The log group is KMS-encrypted with 7-day retention, large payloads go to an S3 bucket that expires objects after 7 days, and the logs are forwarded to Grafana Cloud Logs.
Enable it only in a dedicated sandbox account.
Other exposure¶
- The EKS Auto Mode example enables the public API endpoint, open to
0.0.0.0/0unless you setendpoint_public_access_cidrs. Narrow it, or run Terraform from inside the VPC. - The site has no Ingress by default; you reach it with
kubectl port-forward. Withsite_ingressset, it is as public as your ingress class makes it: read the warning in apps/site/README.md first, becausePOST /api/pickscalls the orchestrator (and so Bedrock) with no authentication or rate limiting, so anyone who reaches it spends your Bedrock budget. - The Faro collector URL is public by design (it is in every page). Without an Ingress, the Frontend Observability app allows any origin.
- The default contact point delivers nowhere, so the demo alerts page nobody. If you set
alert_contact_point, the alerts go to a real destination. - Container images are public on GHCR and contain no credentials and no Claude Code binary.
images.tag(a plain tag) defaults to this module's own release;images.digestslets you pin one or more images immutably by SHA-256 instead, and the agent host never silently re-pulls an image once it has one matching the configured tag or digest. Pin by digest (images.digests) for anything longer-lived than a demo. - Every published image is signed keylessly with cosign by the shared
container-publishreusable workflow that.github/workflows/images.ymlcalls, so the signing identity is that reusable workflow, not this repository's own workflow file. Verify an image (and its digest) before you pin it, with cosign 3.x (the signatures use the newer bundle format, which cosign 2 reports as "no signatures found"):
cosign verify \
--certificate-identity-regexp '^https://github\.com/rknightion/\.github/\.github/workflows/container-publish\.yml@' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-github-workflow-repository rknightion/grafana-aio11y-demo \
ghcr.io/rknightion/grafana-aio11y-demo-gateway:<tag>
--certificate-github-workflow-repository ties the signature to builds started from this repository, since the reusable workflow's identity alone is shared by every repository that calls it.