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>
173 lines
6.9 KiB
Markdown
173 lines
6.9 KiB
Markdown
# 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/<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
|
||
```
|