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>
6.9 KiB
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 117–190),
gsh/gsh/src/human.rs (lines 261–262).
| 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
/proconly. - 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_cmdin SPIRE agent config
SPIRE Registration Entries
For governed shells to receive SVIDs with gsap selectors, create SPIRE entries that match on gsap: selectors:
# 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-systemnamespace (already deployed) - gsap-attestor binary built for linux/amd64
Steps
-
Build the plugin image (adds gsap-attestor to the existing initContainer image):
podman build -t ghcr.io/guildhouse-cooperative/spire-plugins:latest . -
Push to registry and update the SPIRE agent DaemonSet's initContainer image tag.
-
Add to SPIRE agent HCL config:
WorkloadAttestor "gsap" { plugin_cmd = "/opt/spire/plugins/gsap-attestor" plugin_data { proc_root = "/proc" max_depth = 10 } } -
Restart SPIRE agent DaemonSet to pick up the new plugin.
-
Verify:
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