Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
9c80c08
feat(elevate): scaffold chunk 1 interface-first daemon layout
aporcupine Feb 21, 2026
077805d
chore(elevate): initialize standalone go module
aporcupine Feb 21, 2026
c9f80e4
feat(elevate): implement unix ipc with injected deps and unit coverage
aporcupine Feb 22, 2026
79cc7ba
feat(elevate): add verifier with embedded rotated key support
aporcupine Feb 22, 2026
194632a
elevate: implement linux grant engine with sudoers preflight
aporcupine Feb 22, 2026
d7ce91e
elevate: require explicit linux grant config
aporcupine Feb 22, 2026
0d76a7c
elevate: add persistent state store and expiry cleanup
aporcupine Feb 22, 2026
97c424c
elevate: complete handler auth flow and harden logging/errors
aporcupine Feb 22, 2026
7d8a32f
elevate: fix cleanup test data race under -race
aporcupine Feb 22, 2026
2a5ce89
elevate: add socket gid config and signing tool
aporcupine Feb 22, 2026
184d34b
elevate: refine audit logging and cleanup logger wiring
aporcupine Feb 22, 2026
2ed5abc
remove committed keys and add keygen-based test flow
aporcupine Feb 22, 2026
b3dc4e0
refactor platform wiring and add linux boot clock
aporcupine Feb 22, 2026
3fdd7eb
validate sudoers temp file before activation
aporcupine Feb 22, 2026
7e62d63
harden grant activation and cap ipc frame size
aporcupine Feb 22, 2026
2541ddb
refactor ipc access by os and switch socket ownership to names
aporcupine Feb 23, 2026
8ae7495
advance elevate windows support and clock refactor
aporcupine Feb 27, 2026
b98647d
Merge branch 'feat/elevate-ipc-os-access' into feat/elevate-chunk1
aporcupine Feb 27, 2026
4971bfb
fix cleanup expiry evaluation
aporcupine Feb 27, 2026
af528db
fix ipc frame reads across timeouts
aporcupine Feb 27, 2026
745319f
tighten elevate logging and grant validation
aporcupine Feb 27, 2026
8115737
retain completed elevate grants for safe revoke handling
aporcupine Feb 27, 2026
b37f863
handle duplicate elevate requests safely
aporcupine Feb 27, 2026
e020e95
remove embedded manual test key
aporcupine Feb 27, 2026
939387c
tighten username validation and split handler tests
aporcupine Feb 28, 2026
af4e469
validate trusted key material at startup
aporcupine Feb 28, 2026
3457d72
support modern sudo includedir directives
aporcupine Feb 28, 2026
5123814
make state request ID handling exact
aporcupine Feb 28, 2026
c891ffe
use powershell for windows group management
aporcupine Feb 28, 2026
2cff532
stabilize cleanup periodic test
aporcupine Feb 28, 2026
2da2741
stabilize windows membership JSON parsing
aporcupine Feb 28, 2026
d939e88
align shared username validation with linux rules
aporcupine Feb 28, 2026
7ae1455
validate config identities at startup
aporcupine Feb 28, 2026
2a5db97
make grant replay checks order-independent
aporcupine Feb 28, 2026
0968ee6
untangle grant constructors from handler
aporcupine Mar 1, 2026
f37cb09
ignore stale grants during admission
aporcupine Mar 1, 2026
cf8b9c6
harden revoke completion and cleanup shutdown
aporcupine Mar 1, 2026
4d7b947
centralize grant state lifecycle checks
aporcupine Mar 1, 2026
4017606
bind revoke requests to stored identity
aporcupine Mar 1, 2026
a4f2d10
return protocol errors for unsupported actions
aporcupine Mar 1, 2026
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
162 changes: 162 additions & 0 deletions cmd/elevate/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# `cmd/elevate`

Local privileged helper for temporary local-admin elevation.

## Overview

`elevate` is a standalone daemon intended to run as root (Linux target today).
It accepts local IPC requests, verifies a challenge-response signature using pinned public keys, performs OS-level grant/revoke operations, and persists grant state for cleanup/recovery.

### Core Components

- `main.go`
- Loads config from env.
- Builds dependencies.
- Starts server + cleanup runner.
- `ipc/`
- Unix socket transport (Linux/macOS/Windows).
- Newline-delimited JSON framing.
- Socket permissions + optional socket owner/group (`THAND_ELEVATE_SOCKET_USER`, `THAND_ELEVATE_SOCKET_GROUP`).
- `handler/`
- Request router (`grant`/`revoke`).
- Challenge/response signature verification flow.
- Sanitized error responses and structured `slog` logging.
- `verify/`
- Nonce handling + signed payload validation.
- Ed25519 verification against compile-time pinned keys (`verify/keys/*.pem`).
- `grant/`
- Linux grant engine (`/etc/sudoers.d/thand-<request_id>`).
- `visudo -cf` validation.
- Revoke removes request-specific sudoers entry.
- `state/`
- Atomic state persistence (`tmp + fsync + rename + dir fsync`).
- `cleanup.go`
- Startup and periodic sweep of expired grants.
- Revokes expired entries and removes state records.
- `tools/sign_request/`
- Local test utility to generate `request` + `signed_response` frames from a private key.

## Configuration

Environment variables:

- `THAND_ELEVATE_SOCKET_PATH` (default: `/var/run/thand/elevate.sock`)
- `THAND_ELEVATE_SOCKET_USER` (default: unset; set socket owner user by name)
- `THAND_ELEVATE_SOCKET_GROUP` (default: unset; set socket group by name)
- `THAND_ELEVATE_SUDOERS_DIR` (default: `/etc/sudoers.d`)
- `THAND_ELEVATE_SUDOERS_FILE` (default: `/etc/sudoers`)
- `THAND_ELEVATE_VISUDO_BIN` (default: `visudo`)
- `THAND_ELEVATE_STATE_PATH` (default: `/var/lib/thand/elevate/state.json`)
- `THAND_ELEVATE_CLEANUP_INTERVAL` (default: `1m`)
- `THAND_ELEVATE_REQUEST_TIMEOUT` (default: `30s`)
- `THAND_ELEVATE_LOG_LEVEL` (default: `info`; `debug|info|warn|error`)

## Testing

### Unit and race tests

```bash
cd cmd/elevate
go test ./...
go test -race ./...
```

### Build

```bash
cd cmd/elevate
go build -o /home/tom/dev/agent/elevate ./
```

### Run with real system paths (root)

```bash
sudo mkdir -p /var/run/thand /var/lib/thand/elevate
sudo chown root:root /var/run/thand /var/lib/thand/elevate
sudo chmod 755 /var/run/thand /var/lib/thand/elevate
```

```bash
sudo env \
THAND_ELEVATE_SOCKET_PATH=/var/run/thand/elevate.sock \
THAND_ELEVATE_SOCKET_USER="tom" \
THAND_ELEVATE_SOCKET_GROUP="tom" \
THAND_ELEVATE_SUDOERS_DIR=/etc/sudoers.d \
THAND_ELEVATE_SUDOERS_FILE=/etc/sudoers \
THAND_ELEVATE_VISUDO_BIN=visudo \
THAND_ELEVATE_STATE_PATH=/var/lib/thand/elevate/state.json \
THAND_ELEVATE_CLEANUP_INTERVAL=1m \
THAND_ELEVATE_REQUEST_TIMEOUT=15m \
THAND_ELEVATE_LOG_LEVEL=debug \
/home/tom/dev/agent/elevate
```

## Manual Protocol Smoke Test

1. Open socket session (same connection for both frames):

```bash
socat - UNIX-CONNECT:/var/run/thand/elevate.sock
```

2. Send a request frame:

```json
{"type":"request","action":"grant","workflow_id":"wf-manual-1","request_id":"req-manual-1","username":"alice","duration_seconds":600}
```

3. Copy nonce from challenge response.

4. (Success path) Generate a local test keypair and pin the public key:

```bash
KEYDIR="$(mktemp -d /tmp/elevate-keys-XXXXXX)"
cd /home/tom/dev/agent/cmd/elevate
go run ./tools/generate_test_key \
-out-dir "$KEYDIR" \
-key-id local-test-key
```

Copy the generated public key into pinned keys, rebuild, restart:

```bash
KEY_ID="local-test-key"
cp "$KEYDIR/${KEY_ID}.pem" "/home/tom/dev/agent/cmd/elevate/verify/keys/${KEY_ID}.pem"
cd /home/tom/dev/agent/cmd/elevate
go build -o /home/tom/dev/agent/elevate ./
# restart your running elevate process
```

5. Generate frames with signer tool using matching private key + key id:

```bash
cd /home/tom/dev/agent/cmd/elevate
GOCACHE=/tmp/gocache go run ./tools/sign_request \
-private-key "$KEYDIR/${KEY_ID}.private.pem" \
-key-id "$KEY_ID" \
-nonce "<CHALLENGE_NONCE>" \
-action grant \
-workflow-id wf-manual-1 \
-request-id req-manual-1 \
-username alice \
-duration-seconds 600
```

This outputs two lines:
- `request` JSON
- `signed_response` JSON

Paste the `signed_response` line into the open socket session as the second frame.

Expected:
- success: `{"type":"result","status":"ok",...}`

Negative-path tip:
- Use an unknown `key_id` or mismatched private key to get `{"status":"error","error":"unauthorized"}`.

## Notes

- Pinned key IDs come from filenames in `cmd/elevate/verify/keys/*.pem`.
- No production keys are committed by default. You must add at least one `.pem` key file before starting the daemon without override options.
- Changing pinned keys requires rebuilding/restarting the helper.
- Helper has no network code path; signature authority is external to this binary.
155 changes: 155 additions & 0 deletions cmd/elevate/cleanup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package main

import (
"context"
"errors"
"fmt"
"log/slog"
"time"

"github.com/thand-io/agent/cmd/elevate/domain"
"github.com/thand-io/agent/cmd/elevate/handler"
)

// CleanupRunner revokes and removes expired grants on startup and periodically.
type CleanupRunner struct {
store handler.StateStore
grants handler.GrantEngine
clock handler.Clock
interval time.Duration
retention time.Duration
logger *slog.Logger
}

// NewCleanupRunner builds a cleanup runner that sweeps expired grants.
func NewCleanupRunner(store handler.StateStore, grants handler.GrantEngine, clock handler.Clock, interval time.Duration, retention time.Duration, logger *slog.Logger) (*CleanupRunner, error) {
if store == nil {
return nil, fmt.Errorf("state store is required")
}
if grants == nil {
return nil, fmt.Errorf("grant engine is required")
}
if clock == nil {
return nil, fmt.Errorf("clock is required")
}
if interval <= 0 {
return nil, fmt.Errorf("cleanup interval must be > 0")
}
if retention <= 0 {
return nil, fmt.Errorf("state retention must be > 0")
}
if logger == nil {
logger = slog.Default()
}

return &CleanupRunner{
store: store,
grants: grants,
clock: clock,
interval: interval,
retention: retention,
logger: logger,
}, nil
}

// Run executes one startup sweep and then continues periodic sweeps until context cancellation.
func (c *CleanupRunner) Run(ctx context.Context) error {
if err := c.runOnce(ctx); err != nil {
if isCleanupShutdownError(ctx, err) {
return nil
}
return err
}

ticker := time.NewTicker(c.interval)
defer ticker.Stop()

for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
if err := c.runOnce(ctx); err != nil {
if isCleanupShutdownError(ctx, err) {
return nil
}
return err
}
}
}
}

func (c *CleanupRunner) runOnce(ctx context.Context) error {
grants, err := c.store.List(ctx)
if err != nil {
return fmt.Errorf("list state grants: %w", err)
}

nowMono := c.clock.NowMonoNS()
nowWall := c.clock.NowWallUTC()

for _, g := range grants {
if isCompleted(g) {
if !isRetentionExpired(g, nowWall, c.retention) {
continue
}
if err := c.store.Delete(ctx, g.RequestID); err != nil {
return fmt.Errorf("delete retained grant %q: %w", g.RequestID, err)
}
continue
}

if !isExpired(g, nowMono, nowWall) {
continue
}

if !g.WasAlreadyPrivileged {
if err := c.grants.Revoke(ctx, domain.RevokeRequest{
RequestID: g.RequestID,
WorkflowID: g.WorkflowID,
Username: g.Username,
}); err != nil {
return fmt.Errorf("revoke expired grant %q: %w", g.RequestID, err)
}
c.logger.Info("admin revoked by cleanup",
"component", "elevate_cleanup",
"request_id", g.RequestID,
"workflow_id", g.WorkflowID,
"username", g.Username,
"reason", "expired",
)
}

g.CompletedAtWallUTC = nowWall
if err := c.store.Put(ctx, g); err != nil {
return fmt.Errorf("persist completed grant %q: %w", g.RequestID, err)
}
}

return nil
}

func isCompleted(grant domain.GrantState) bool {
return !grant.CompletedAtWallUTC.IsZero()
}

func isRetentionExpired(grant domain.GrantState, nowWallUTC time.Time, retention time.Duration) bool {
if grant.CompletedAtWallUTC.IsZero() || nowWallUTC.IsZero() || retention <= 0 {
return false
}
return !grant.CompletedAtWallUTC.Add(retention).After(nowWallUTC)
}

func isExpired(grant domain.GrantState, nowMonoNS int64, nowWallUTC time.Time) bool {
return domain.IsExpiredGrantState(grant, nowMonoNS, nowWallUTC)
}

func isCleanupShutdownError(ctx context.Context, err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return true
}
return ctx.Err() != nil && errors.Is(err, ctx.Err())
}
Loading