# 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 `/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/ \ -selector gsap:principal_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/ \ -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 ```