Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,13 @@ internal/githubapp/ GitHub App JWT signing + installation-token exchange.
internal/attestation/ Resolves the ATTESTED invoking identity via a fixed-order
provider chain (configured -> sidecar -> built-in
fallback). Pure resolution, no policy — mint decides
what an identity is allowed to do.
what an identity is allowed to do. The sidecar layer's
read contract (spawn-scoped vs. session-scoped
classes, resolution order, fail-closed miss handling,
symlink hard-fail) is specified generically in
docs/SIDECAR-READ-CONTRACT.md, with this package's own
config and implementation cited there as the worked
example.

internal/mint/ Orchestration. Ties attestation + roles + broker +
githubapp together:
Expand Down
9 changes: 9 additions & 0 deletions docs/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,15 @@ not `sidecars` (the sidecar path exists for a harness that writes a
per-spawn identity file external to Gatekeeper — see "Wiring your agents"
below if you are building that harness yourself).

The generalized read contract this layer implements — spawn-scoped vs.
session-scoped sidecar classes, spawn-first resolution order, fail-closed
handling of a miss, and symlink-safe reads — is specified independently of
Gatekeeper's own naming in
[`docs/SIDECAR-READ-CONTRACT.md`](SIDECAR-READ-CONTRACT.md). That doc treats
Gatekeeper's deployed `attestation.sidecars` config and
`internal/attestation/sidecar.go` as one worked example, not as required
naming for any other consumer implementing the same contract.

**Config location:** this is Gatekeeper's own `config.yaml` (copied from
`config.example.yaml` per the main [README](../README.md#configuration)).
Gatekeeper itself does not read a `.clagentic/loadout/` path — that path is
Expand Down
258 changes: 258 additions & 0 deletions docs/SIDECAR-READ-CONTRACT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
# Sidecar identity file — the read contract

This is the canonical spec for **reading** an identity sidecar file: a small
file an external harness writes to disk so a consuming tool can resolve
"who is asking" without that tool trusting a caller-supplied value. It exists
because the write side (the harness) is already solid and largely uniform
across the tools that use this pattern, while independent read
implementations have drifted in rigor. This doc fixes the read contract once,
in generalized shapes, so any reader can be brought into line with it without
adopting another tool's naming or file layout.

**Scope: read-side only.** This doc does not define how or when a harness
writes the sidecar file — that is the harness's own contract, described
wherever the harness itself is documented. Nothing here assumes a specific
harness exists.

**No tool names are normative here.** Directory paths, filename prefixes, and
environment variable names below are illustrative placeholders. Section
"Worked example" cites one deployed configuration and one reference
implementation to make the shapes concrete — it documents an example, not a
required naming scheme.

## Convention, not coupling

This is a `.netrc`-style convention: every consumer that wants to read a
sidecar carries (or links to) this spec and implements the read against its
**own** configuration. There is no shared library, no runtime dependency
between consumers, and no requirement that consumers agree on directory or
prefix values. Two independently built tools can each implement this
contract, point at the same on-disk file by coincidence of matching config,
and interoperate — or run entirely independently with no knowledge of each
other. The contract is the shape of the read, not a wire protocol.

## 1. Two sidecar classes

A deployment may need to attest more than one *kind* of caller at once, and
the two kinds need different identity keys because their lifetimes differ:

- **Spawn-scoped.** Identifies one short-lived unit of work — a single
spawned subagent invocation. Keyed by a **spawn-id** environment variable
whose value is unique per spawn. Required when multiple spawns can be
in flight concurrently under the same parent process/session: a
session-keyed file would let a second concurrent spawn overwrite the
first spawn's identity file mid-flight. A spawn-keyed file does not have
this problem — each spawn gets its own file.

- **Session-scoped.** Identifies one longer-lived process — for example, an
interactive lead/orchestrator that itself makes identity-bearing calls
without going through a per-spawn harness. Keyed by a **session-id**
environment variable that is stable for the life of that process.

A single deployment commonly configures both classes as independent
namespaces (own directory/prefix/env-var triple each), because a session
process and the subagents it spawns are different callers with different
lifetimes, even when they run as the same OS user.

## 2. Resolution order: spawn-first, then session

When both classes are configured, a reader tries the **spawn-scoped**
namespace first, then the **session-scoped** namespace. The first namespace
whose identity file is present, for the current invocation's key value,
wins — resolution stops there; later namespaces are not consulted.

Rationale: a spawned subagent's own identity must win over a session
identity it may also see in its inherited environment. Checking spawn first
means a subagent is attested as itself, not silently re-attested as its
parent session, when both sidecar files happen to be resolvable in the same
process.

If a deployment configures only one class, that class is the entire chain —
there is nothing to order.

## 3. No hit = fail closed for identity-bearing decisions

If neither configured sidecar namespace has a file present for the current
invocation, that is **absence of an attested identity from this layer**, not
an error — a well-behaved reader treats it as "this layer declines" and lets
the caller decide what happens next (e.g. fall through to another
configured identity source, or refuse outright).

What a reader must **never** do on a miss, when the caller is meant to be a
specific named identity: silently substitute the OS-reported invoking user
(`getpass.getuser()`, `os/user.Current()`, `whoami`, or equivalent) as if it
were the attested identity. An OS-user fallback answers a different
question — "what account is this process running as" — not "which named
caller is this." Conflating the two means every caller sharing an account
(a common case: multiple agents/services run as the same container user)
becomes indistinguishable, silently, with no error to signal the
degradation.

A generic "no identity source configured at all, so fail open to *some*
built-in default" behavior may still be a deliberate, documented design
choice at a higher layer (see "Worked example" below for how the reference
implementation frames this trade-off explicitly). What this section
forbids is a **sidecar reader** locally improvising an OS-user substitution
as if it satisfied the sidecar contract — that decision, if made at all,
belongs one layer up, explicitly, not folded silently into the read path.

## 4. Atomic read, symlink-safe

Reading the identity file is a security-sensitive operation: the sidecar
directory is often a shared, world-writable location (e.g. a temp
directory), so the read must not be redirectable by another process placing
a symlink or non-regular file at the expected path between check and read.

The generalized read shape:

1. Open the path with the platform's no-follow-symlink open flag (e.g.
`O_NOFOLLOW` on POSIX, or the platform equivalent) so a symlink at the
expected path fails the open rather than being silently followed to an
attacker-chosen target.
2. `fstat` the **open file descriptor** (not a separate path-based stat
call) and verify it reports a regular file before reading any bytes.
Using the descriptor closes the TOCTOU window between "check" and
"read" that a path-based stat-then-open (or open-then-separate-stat)
sequence leaves open.
3. Only then read the file's contents.

Runtimes without a portable no-follow open flag may approximate this with
the strongest available equivalent (for example: `lstat` the path first to
detect a symlink or non-regular file before opening, refusing before any
open is attempted) — see "Worked example" for how the reference
implementation does this within Go's standard library idiom. The intent
that must be preserved regardless of language: **no read is ever allowed to
silently traverse a symlink**, and the regular-file check happens close
enough to the read that a race cannot substitute a different file in
between.

## 5. Symlink or non-regular file = hard failure

If the path resolves to a symlink, or to anything that is not a regular
file (device, socket, FIFO, directory, etc.), the reader must treat this as
a **hard failure of that provider**, not as "no identity" and not as
something to silently work around by resolving through it. A planted
symlink at the expected path is exactly the attack this guards against, and
treating it as a soft miss would let an attacker cause exactly the "safe"
fallback behavior described in section 3.

This is different from "file does not exist," which is a normal miss (see
section 3). A present-but-wrong-type file is treated more severely, because
its presence is itself suspicious.

## 6. Prefixes, directories, and env var names always come from config

No consumer of this contract hardcodes a sidecar's directory, filename
prefix, or key environment-variable name in its source code. All three are
read from that consumer's **own** configuration (its adapter/config
values), the same way any other deployment-specific value is configured.
This holds even when a consumer's default config ships with a specific
value pre-filled (see "Worked example") — the value lives in config, is
overridable, and the code path that reads it is generic.

This is what makes the convention work like `.netrc`: the *shape* of the
contract (two classes, spawn-first resolution, fail-closed miss handling,
symlink hard-fail) is fixed and documented once, here; the *values*
(where the file lives, what it's called, which env var keys it) are
deployment-local and never assumed by code.

## 7. The session sidecar's `lore-agent-name-` prefix is not up for renaming

One specific, already-deployed session-scoped sidecar convention uses the
filename prefix `lore-agent-name-`. That prefix is **owned by the LORE
harness that writes it**, and the name itself is the ownership signal — it
tells a reader (or a human debugging a resolution failure) which harness is
responsible for that file's contents and lifecycle. This spec does not
propose renaming it, and no consumer of this contract should propose
renaming it either. A consumer's own config simply points its session-scoped
namespace's `file_prefix` at that value if it wants to read LORE's
session sidecar; the value is still config-driven per section 6, it is just
a config value that should not casually change once other readers depend on
it.

## Worked example (illustrative — not normative naming)

Clagentic: Gatekeeper's own deployed configuration and Go reference
implementation are cited here as one concrete instance of the shapes above.
Nothing in this section is a required name, path, or environment variable
for any other consumer.

**Config shape** (same fields as `config.example.yaml`'s
`attestation.sidecars`, reordered here to lead with spawn-first per
section 2 — see the note below on Gatekeeper's currently deployed order):

```yaml
attestation:
sidecars:
# Spawn-scoped namespace — checked first, per section 2's spawn-first
# resolution order. Short-lived subagent invocations, one file per
# concurrent spawn.
- dir: /tmp
file_prefix: crew-agent-spawn-
session_id_env: CREW_SPAWN_AGENT_ID
# Session-scoped namespace — checked second. A lead/interactive
# process. Owned by LORE; prefix is not renamed (see section 7 above).
- dir: /tmp
file_prefix: lore-agent-name-
session_id_env: CLAUDE_CODE_SESSION_ID
```

Both `dir`, `file_prefix`, and the id-env-var name are ordinary config
values (section 6) — nothing about `/tmp`, `lore-agent-name-`, or
`CLAUDE_CODE_SESSION_ID` is hardcoded in Gatekeeper's Go source.

The order above follows section 2's spawn-first resolution
recommendation. A local deployment MAY choose a different order as its own
config decision — the list order is read straight from `config.yaml` and
Gatekeeper's chain does not enforce spawn-before-session itself (see
"Reference read implementation" below). Gatekeeper's own currently
deployed `config.example.yaml` lists the session-scoped entry first; that
is a config ordering value to reconcile locally against section 2 (a
separate, non-doc change), and does not change the contract itself. This
worked example leads with spawn-first so it illustrates the contract's own
recommendation rather than appearing to contradict it.

**Reference read implementation**
([`internal/attestation/sidecar.go`](../internal/attestation/sidecar.go)):

- One `sidecarProvider` is built per configured entry in the `sidecars`
list (`internal/attestation/chain.go`, `NewChain`); the chain tries each
in the configured order and returns the first identity found, giving the
ordered "first hit wins" behavior of section 2 once entries are ordered
per that section.
- A miss (`os.IsNotExist`) returns `ErrNoIdentity` so the chain falls
through to the next provider — normal absence, per section 3. The
package's built-in fallback (OS-reported invoking user) is a distinct,
explicitly documented final layer of the chain, not something the
sidecar provider itself substitutes on a miss — see
[`docs/SETUP.md`](SETUP.md) for the full three-layer trust model and why
that fallback exists as its own opt-in-by-omission layer rather than
being folded into the sidecar read path.
- The path is checked with `os.Lstat` before any read, so a symlink at the
expected path is detected as itself rather than resolved through — the
Go-idiomatic approximation of section 4's no-follow-open requirement
described there for runtimes without a portable atomic
open-with-no-follow-and-fstat primitive in common use. `info.Mode().IsRegular()`
is checked before the file is read; anything else (symlink, device,
socket) is a hard failure (`fmt.Errorf`, not `ErrNoIdentity`), per
section 5.
- The id value read from the environment is validated as a single safe
path segment before it is joined into the file path
(`isSafePathSegment`), and the resulting path is verified to still be a
direct child of the configured directory (`requireContained`) — hardening
specific to Gatekeeper's implementation, complementary to but outside
the scope of this contract.
- `dir`, `file_prefix`, and `session_id_env` are all fields of
`SidecarConfig`, populated only from `config.yaml` — see section 6.

## See also

- [`docs/DESIGN.md`](DESIGN.md) — where `internal/attestation` sits in
Gatekeeper's module boundaries.
- [`docs/SETUP.md`](SETUP.md) — the full three-layer trust model
(attested identity -> role entitlement -> credential grantor), the
built-in fallback, and the harness-wiring contract for writing sidecar
files in the first place.
- [`internal/attestation/sidecar.go`](../internal/attestation/sidecar.go),
[`internal/attestation/chain.go`](../internal/attestation/chain.go) —
the reference read implementation cited above.
Loading