Skip to content

Record decisions and telemetry

A box records every policy decision it makes, every change to its own authority, and every span, log record, and metric the workload exports. A [telemetry.<label>] table in box.toml declares the target those records reach, and include states which records that target receives.

This guide covers a file target, which appends records to a path you name. A box can also send records to an OTLP collector with kind = "otlp"; [telemetry.<label>] in the box.toml reference lists its keys, including the collector’s secret.

A box with no [telemetry] table writes one file inside its own box directory:

<box_dir>/private/telemetry/records.jsonl

That target receives every record: each effective denial, each effective permit, the box’s own control-plane records, and the workload’s relayed spans, log records, and metrics. The workload can’t reach the file.

The file grows for as long as the box is used. Rotating or removing it is your task.

The file isn’t tamper evident. Box signs no record and chains no record to the next, so anything running under your own account can edit it afterwards. What the box keeps out is the workload.

A declared target replaces this default rather than adding to it, so a box that declares a file of its own writes no record to private/telemetry/records.jsonl.

box.toml
[telemetry.decisions] # every decision this box takes, recorded
kind = "file"
destination = "~/boxes/codex/records.jsonl"
include = ["deny", "permit", "trace"]

decisions is a label you choose. An unknown key is refused.

KeyTypeDefaultMeaning
kindstringNone, requiredfile appends records to a file.
destinationstringNone, requiredThe file the records go to.
includearray of stringsEvery recordWhich records reach this target, as Filter with include describes.

A file target receives one OTLP JSON request per line, appended. Each line is a complete resourceLogs, resourceSpans, or resourceMetrics request.

The destination is an absolute path, ~, or a path that begins with ~/. Box expands a leading ~ to your home. These destinations are refused:

DestinationMessage
A relative path, such as records.jsonla file destination must be absolute or begin with `~/`
A path that carries a .. componentcarries a `..` component
A path inside box_dir and outside private/is inside the box directory ... but outside its private tree

Declare one target per file. Two boxes that append to one file can tear a line.

Omit include and the target receives every record. Write include and the target receives only the records the words in the list name:

WordThe target then receives
denyEvery effective denial.
permitEvery effective permit.
traceThe workload’s own spans, and this box’s control-plane records.

So include = ["deny"] writes a file of refusals, and leaves out the permits and the control-plane records.

The workload’s log records and metrics are on by default. They reach every target, and no value of include keeps them out. The file include = ["deny"] writes therefore holds the denials, plus whatever log records and metrics the workload itself exported.

trace names two things at once. A target that omits trace receives no control-plane record, so this box’s box_started, policy_installed, and box_stopped records are absent from it.

An empty list is refused, and so is a repeated word.

OpenTelemetry calls each kind of telemetry a signal. Three of them apply here: traces, logs, and metrics.

Box relays the workload’s spans, log records, and metrics, and generates no signal of that kind itself. A target receives one only when the workload exports it, so two settings apply: the workload’s own exporter setting, and a target that accepts the signal.

Box binds one loopback port for the workload and sets three variables in its environment:

VariableValue
OTEL_EXPORTER_OTLP_ENDPOINThttp://127.0.0.1:<port>, where the port is this box’s own
OTEL_EXPORTER_OTLP_TRACES_ENDPOINTThe same address with /v1/traces appended
OTEL_EXPORTER_OTLP_PROTOCOLhttp/protobuf

Box owns every name that begins with OTEL_EXPORTER_OTLP_, so [agent] env can’t set one. The receiver answers /v1/traces, /v1/logs, and /v1/metrics, and answers 404 on every other route.

The exporter switches belong to the workload, so write them in [agent] env:

box.toml
[agent.env]
OTEL_TRACES_EXPORTER = "otlp"
OTEL_LOGS_EXPORTER = "otlp"
OTEL_METRICS_EXPORTER = "otlp"

A switch left at none means that signal never arrives, whatever include names. The OpenTelemetry default metric interval is 60 seconds, so a short run ends before the first metric leaves. Set OTEL_METRIC_EXPORT_INTERVAL in [agent] env for a short run.

The Strands CLI reads OTEL_TRACES_EXPORTER but sets up no meter. Collect the Strands CLI’s traces and metrics turns on both for the box from Getting started.

Each box record names its origin on its resource:

KeyValue
service.namestrands-box
strands.box.nameThis box’s generated identifier, as box- and 16 hexadecimal characters
strands.box.sourcebox for a record the box wrote, and agent for a relayed payload
strands.box.run.idOne identifier per box run invocation

Each effective decision produces one log record and one span that share a trace ID and a span ID. The log record’s event_name field is strands.box.policy.decision, and the scope is strands-box.policy.

GroupKeys
The decisionstrands.box.policy.action, .resource, .verdict, .cause, .reason on a denial, and .principal
The governing rulestrands.box.policy.rule, .description, .category, and .determining.ids, which lists every policy that determined the decision
Correlationstrands.box.request.id on an egress decision, jsonrpc.request.id on an MCP request, and strands.box.trace.parent_span_id on the log record
What it was aboutserver.address and server.port on a network decision, http.request.method on a request, file.path on an fs:* decision, and process.command, process.command_args, and process.working_directory on a shell decision

strands.box.policy.verdict holds deny or permit. strands.box.policy.cause holds one of six values: permitted, forbidden, no_match, policy_pending, internal_fault, and enforcement. A record whose cause is internal_fault also carries the key error.type, with the value strands.box.policy.evaluation_error.

The action value carries the vocabulary term alone, so a reader sees fs:read rather than Box::Action::"fs:read". Every other value is reported as the enforcement point holds it. Two keys are excluded: url.full and process.command_line.

process.command_args reports an argument the script stated literally. For an argument that arrived from an expansion, the record reports:

The expanded argumentWhat the record carries
A short flag, such as -lThe flag
A long flag of at most 32 lowercase letters and dashes, such as --verboseThe flag
A long flag with a value, such as --output=secretThe name, and <redacted> for the value
Anything else<redacted>

At most 64 arguments are reported. One attribute carries at most 4 KiB, and a longer value is cut on a character boundary and ends with … (truncated).

A control-plane record states one change to the authority this box holds. The scope is strands-box.control, and each operation also produces a span named control <operation>.

KeyValue
strands.box.control.operationpolicy_installed, policy_refused, schema_installed, discovery_complete, box_started, or box_stopped
strands.box.control.outcomeok, or refused
strands.box.control.subjectWhat the operation was about
strands.box.control.reasonWhy a refusal happened
strands.box.control.detailFurther text the operation carries

A records file holds both scopes, so a query selects one. A box records that it started before it decides anything, so the first record in the file is a control-plane record.

Every denial, one per line:

Terminal window
jq -r 'select(.resourceLogs)
| .resourceLogs[].scopeLogs[]
| select(.scope.name == "strands-box.policy")
| .logRecords[].attributes
| map({(.key): .value.stringValue}) | add
| select(.["strands.box.policy.verdict"] == "deny")
| "\(.["strands.box.policy.action"]) \(.["strands.box.policy.resource"])"' \
~/boxes/codex/records.jsonl
fs:read ~/.ssh/id_rsa

What this box did to its own authority:

Terminal window
jq -r 'select(.resourceLogs)
| .resourceLogs[].scopeLogs[]
| select(.scope.name == "strands-box.control")
| .logRecords[].attributes
| map({(.key): .value.stringValue}) | add
| "\(.["strands.box.control.operation"]) \(.["strands.box.control.outcome"])"' \
~/boxes/codex/records.jsonl
box_started ok
policy_installed ok
discovery_complete ok
box_stopped ok

Select on strands.box.source to tell a box record from a relayed one. A severity number is the box’s own routing, and it isn’t a filter for a reader.

The file needs no conversion, so any viewer that reads OTLP JSON opens it directly. otel-tui is one such tool:

Terminal window
otel-tui --from-json-file ~/boxes/codex/records.jsonl

A telemetry error stops the run before the workload starts, so the agent never runs. Box prints the reason on stderr and exits 1. A workload is free to exit 1 as well, so the exit code alone doesn’t prove that Box refused. The message does: Box prefixes every line it prints with strands-box:.

What you seeCause
names no signal, so nothing would reach itinclude is an empty list.
names <word> twiceinclude repeats a word.
a file destination must be absolute or begin with `~/` A file destination is relative.
carries a `..` componentA destination carries ...
is inside the box directory ... but outside its private treeA file destination sits where a process could truncate it.

Each message names the table it came from.

A target that fails while the box runs loses the record. Box reports no such loss, and it doesn’t retry.