guildhouse-spire-plugins/GSAP-ATTESTOR-DESIGN.md
Tyler J King fe5e2cf3c6 feat(spire): gsap-attestor WorkloadAttestor plugin
SPIRE WorkloadAttestor that reads governance env vars from /proc/{pid}/environ
(walking up the process tree to find gsh) and emits gsap: selectors on workload
SVIDs. Maps BASCULE_* vars set by bascule-shell and future GSH_* vars to the
11-selector vocabulary defined in gsap-types/src/selectors.rs.

- pkg/gsap/selectors.go: shared Go constants mirroring Rust vocabulary
- cmd/gsap-attestor/: plugin implementation with /proc reading, process tree
  walking, capability ceiling translation, and fail-open for non-governed processes
- 28 tests covering selector extraction, proc parsing, tree walking, and depth limits
- Makefile, Dockerfile, deploy config updated

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-13 03:59:08 -04:00

173 lines
6.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# GSAP Attestor — Design Document
SPIRE WorkloadAttestor plugin that reads governance context from the process
environment and emits GSAP selectors on workload SVIDs.
## Selector Vocabulary
The Go constants in `pkg/gsap/selectors.go` mirror the Rust definitions in
`substrate-gsap/crates/gsap-types/src/selectors.rs` exactly.
| Rust Constant | Go Constant | Selector Key |
|---------------------------|----------------------------|--------------------|
| `SELECTOR_CONTEXT_ID` | `SelectorContextID` | `context_id` |
| `SELECTOR_CAPABILITY_MASK`| `SelectorCapabilityMask` | `capability_mask` |
| `SELECTOR_CORPUS_CID` | `SelectorCorpusCID` | `corpus_cid` |
| `SELECTOR_PARAMETERS_CID` | `SelectorParametersCID` | `parameters_cid` |
| `SELECTOR_ACCORD_TEMPLATE`| `SelectorAccordTemplate` | `accord_template` |
| `SELECTOR_PLAYBOOK` | `SelectorPlaybook` | `playbook` |
| `SELECTOR_PRINCIPAL_DID` | `SelectorPrincipalDID` | `principal_did` |
| `SELECTOR_DRIVER_ID` | `SelectorDriverID` | `driver_id` |
| `SELECTOR_SESSION_MODE` | `SelectorSessionMode` | `session_mode` |
| `SELECTOR_SHELL_CLASS` | `SelectorShellClass` | `shell_class` |
| `SELECTOR_POSTURE_LEVEL` | `SelectorPostureLevel` | `posture_level` |
Selector type prefix: `gsap`. Wire format: `gsap:key:value`.
## Env Var to Selector Mapping
### Currently set by bascule-shell and gsh
Source: `bascule-oss/crates/bascule-shell/src/main.rs` (lines 117190),
`gsh/gsh/src/human.rs` (lines 261262).
| Env Var | Selector | Transform |
|------------------------------|--------------------|----------------------------------------|
| `GSH_DID` | `principal_did` | Direct. Priority over BASCULE_PRINCIPAL|
| `BASCULE_PRINCIPAL` | `principal_did` | Direct. Fallback. |
| `BASCULE_AUTH_METHOD` | `driver_id` | Direct. |
| `BASCULE_POSTURE_LEVEL` | `posture_level` | Direct (integer string). Priority. |
| `BASCULE_DEFCON_LEVEL` | `posture_level` | Direct. Fallback. |
| `GSH_CAPABILITY_MASK` | `capability_mask` | Direct (already hex). Priority. |
| `BASCULE_CAPABILITY_CEILING` | `capability_mask` | Name → hex via CapabilityCeilingToHex. |
| `BASCULE_SESSION_ID` | `context_id` | Direct. |
| `BASCULE_CORPUS_CID` | `corpus_cid` | Direct. |
### Future (not yet set by any component)
| Env Var | Selector |
|----------------------|-------------------|
| `GSH_ACCORD_TEMPLATE`| `accord_template` |
| `GSH_PLAYBOOK` | `playbook` |
| `GSH_PARAMETERS_CID` | `parameters_cid` |
| `GSH_SESSION_MODE` | `session_mode` |
| `GSH_SHELL_CLASS` | `shell_class` |
### Capability Ceiling Translation
`BASCULE_CAPABILITY_CEILING` stores a name like `CAP_MUTATE`. The GSAP selector
vocabulary uses hex masks. The ceiling is cumulative ("up to and including"):
| Ceiling Name | Hex | Bits |
|---------------|--------|-----------------------------------|
| `CAP_NONE` | `0x00` | (none) |
| `CAP_READ` | `0x01` | READ |
| `CAP_PROPOSE` | `0x03` | READ \| PROPOSE |
| `CAP_MUTATE` | `0x07` | READ \| PROPOSE \| MUTATE |
| `CAP_GOVERN` | `0x0f` | READ \| PROPOSE \| MUTATE \| GOVERN|
Unrecognized ceiling names produce no `capability_mask` selector (fail-open).
## Process Tree Walking Algorithm
```
Attest(pid):
current = pid
for depth = 0; depth < max_depth; depth++:
env = read /proc/{current}/environ
if error (process gone):
return nil, nil # fail-open
if isGoverned(env): # any of BASCULE_PRINCIPAL, GSH_DID,
# BASCULE_SESSION_ID, BASCULE_AUTH_METHOD present
return extractSelectors(env), nil
parent = read PPid from /proc/{current}/status
if parent <= 1 or parent == current:
break
current = parent
return nil, nil # not governed — fail-open
```
Key properties:
- **No network calls.** Reads `/proc` only.
- **Fail-open.** Non-governed processes get zero selectors, not errors.
- **Configurable depth.** Default 10 levels. Prevents runaway on deep trees.
- **Race-safe.** If a process exits mid-walk, returns nil (not error).
## SPIRE Plugin Interface
Uses `hashicorp/go-plugin` v1.6.3 (same as oidc-attestor).
- Handshake: `ProtocolVersion:1`, `MagicCookieKey:"ServerAgent"`, `MagicCookieValue:"GuildhouseSpire"`
- Plugin map key: `"workload_attestor"`
- Served via `plugin.DefaultGRPCServer`
- Loaded as external process via `plugin_cmd` in SPIRE agent config
## SPIRE Registration Entries
For governed shells to receive SVIDs with gsap selectors, create SPIRE entries
that match on gsap: selectors:
```bash
# Generic governed shell entry
spire-server entry create \
-spiffeID spiffe://ffc-hetzner-nur01.ops.guildhouse.dev/governed/shell \
-parentID spiffe://ffc-hetzner-nur01.ops.guildhouse.dev/spire/agent/k8s_psat/ffc-hetzner-nur01/<agent-id> \
-selector gsap:principal_did:<did> \
-ttl 3600
# Per-accord-template entries (when GSH_ACCORD_TEMPLATE is available)
spire-server entry create \
-spiffeID spiffe://ffc-hetzner-nur01.ops.guildhouse.dev/governed/m365-governance \
-parentID spiffe://ffc-hetzner-nur01.ops.guildhouse.dev/spire/agent/k8s_psat/ffc-hetzner-nur01/<agent-id> \
-selector gsap:accord_template:m365-governance \
-ttl 3600
```
## Deployment to Hetzner
### Prerequisites
- SPIRE v1.14.5 running in `spire-system` namespace (already deployed)
- gsap-attestor binary built for linux/amd64
### Steps
1. Build the plugin image (adds gsap-attestor to the existing initContainer image):
```bash
podman build -t ghcr.io/guildhouse-cooperative/spire-plugins:latest .
```
2. Push to registry and update the SPIRE agent DaemonSet's initContainer image tag.
3. Add to SPIRE agent HCL config:
```hcl
WorkloadAttestor "gsap" {
plugin_cmd = "/opt/spire/plugins/gsap-attestor"
plugin_data {
proc_root = "/proc"
max_depth = 10
}
}
```
4. Restart SPIRE agent DaemonSet to pick up the new plugin.
5. Verify:
```bash
kubectl -n spire-system logs daemonset/spire-agent | grep gsap
```
## File Layout
```
pkg/gsap/
selectors.go # Shared Go constants (mirrors Rust vocabulary)
selectors_test.go # Contract tests
cmd/gsap-attestor/
main.go # Plugin entry point
attestor.go # GsapAttestor: Configure, Attest, extractSelectors
attestor_test.go # 28 tests (unit + integration with mock /proc)
proc.go # readProcEnviron, getParentPid
```