Skip to content

Commit 3c15893

Browse files
hyperpolymathclaude
andcommitted
feat(rokur-mcp): add container pre-start secrets gate cartridge
New 3-layer cartridge for Rokur (Stapeln container secrets gate): - Idris2 ABI: 5-state gate machine (Idle→Checking→Allowed/Denied/Error→Idle) with ValidGateTransition proofs. 4 actions, AuthVerdict record. - Zig FFI: Calls Rokur sidecar REST API (:9090) via curl. Thread-safe proxy with state machine enforcement. 5 passing tests. - V adapter: authorize-start, secrets/status, reload, health endpoints. - MCP tools: 4 tool definitions for BoJ's MCP surface. - PanLL panels: gate state badge, check counter, secrets presence counts. Zero-knowledge: secret values never exposed — only presence/absence verdicts and counts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f8ac6c3 commit 3c15893

10 files changed

Lines changed: 993 additions & 0 deletions

File tree

cartridges/rokur-mcp/README.adoc

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
= rokur-mcp
3+
:author: Jonathan D.A. Jewell
4+
:email: j.d.a.jewell@open.ac.uk
5+
6+
Container pre-start secrets gate for the Stapeln container ecosystem.
7+
8+
== What It Does
9+
10+
Rokur validates that all required secrets are present before allowing a container
11+
to start. It communicates with the Rokur sidecar service (Deno, port 9090) via
12+
REST, and exposes the authorization verdict through BoJ's MCP protocol.
13+
14+
**Zero-knowledge invariant**: Secret values are never exposed through this
15+
cartridge. Only presence/absence verdicts and counts are returned.
16+
17+
== Relationship to vault-mcp (RGTV)
18+
19+
[cols="1,1"]
20+
|===
21+
| **vault-mcp (RGTV)** | **rokur-mcp**
22+
23+
| Stores and delivers credentials
24+
| Validates credentials are present
25+
26+
| Post-quantum encrypted vault
27+
| Lightweight HTTP gate
28+
29+
| AI agent zero-knowledge proxy
30+
| Container orchestration gate
31+
32+
| Long-lived service
33+
| Pre-start check (ephemeral)
34+
|===
35+
36+
In production: RGTV delivers secrets to the container environment, then Rokur
37+
verifies they all arrived before Svalinn allows the container to start.
38+
39+
== MCP Tools
40+
41+
* `boj_rokur_authorize_start` — Pre-start authorization check
42+
* `boj_rokur_status` — Secrets presence status (counts only)
43+
* `boj_rokur_reload` — Hot-reload secrets from environment
44+
* `boj_rokur_health` — Sidecar liveness check
45+
46+
== Architecture
47+
48+
----
49+
Idris2 ABI (SafeGate.idr)
50+
↓ state machine proofs
51+
Zig FFI (rokur_mcp_ffi.zig)
52+
↓ curl → Rokur :9090
53+
V-lang Adapter (rokur_mcp_adapter.v)
54+
↓ REST bridge
55+
BoJ MCP SSE Transport
56+
----
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
-- SPDX-License-Identifier: PMPL-1.0-or-later
2+
-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
--
4+
-- RokurMcp.SafeGate — Type-safe ABI for the rokur-mcp cartridge.
5+
--
6+
-- Container pre-start secrets gate. Validates that all required secrets
7+
-- are present before allowing a container to start. Delegates to the
8+
-- Rokur sidecar service (Deno, port 9090) for policy evaluation.
9+
-- Never exposes secret values — only presence/absence verdicts.
10+
11+
module RokurMcp.SafeGate
12+
13+
%default total
14+
15+
-- ---------------------------------------------------------------------------
16+
-- Gate state machine
17+
-- ---------------------------------------------------------------------------
18+
19+
||| Gate lifecycle states for container pre-start authorization.
20+
|||
21+
||| - Idle: no authorization request pending.
22+
||| - Checking: secrets presence check in progress.
23+
||| - Allowed: all required secrets present, container may start.
24+
||| - Denied: one or more required secrets missing, container blocked.
25+
||| - Error: policy engine failure (fail-closed = deny).
26+
public export
27+
data GateState = Idle | Checking | Allowed | Denied | Error
28+
29+
||| Proof that a gate state transition is valid.
30+
|||
31+
||| Transition graph:
32+
||| Idle -> Checking (begin authorization)
33+
||| Checking -> Allowed (all secrets present)
34+
||| Checking -> Denied (missing secrets)
35+
||| Checking -> Error (policy engine failure)
36+
||| Allowed -> Idle (reset after container started)
37+
||| Denied -> Idle (reset after denial logged)
38+
||| Error -> Idle (reset after error handled)
39+
public export
40+
data ValidGateTransition : GateState -> GateState -> Type where
41+
BeginCheck : ValidGateTransition Idle Checking
42+
Approve : ValidGateTransition Checking Allowed
43+
Reject : ValidGateTransition Checking Denied
44+
Fault : ValidGateTransition Checking Error
45+
ResetAllowed : ValidGateTransition Allowed Idle
46+
ResetDenied : ValidGateTransition Denied Idle
47+
ResetError : ValidGateTransition Error Idle
48+
49+
-- ---------------------------------------------------------------------------
50+
-- Gate actions
51+
-- ---------------------------------------------------------------------------
52+
53+
||| Actions that can be performed through the MCP rokur interface.
54+
public export
55+
data GateAction
56+
= AuthorizeStart -- ^ Request pre-start authorization for a container
57+
| CheckStatus -- ^ Query current secrets presence status
58+
| ReloadSecrets -- ^ Hot-reload required secrets from environment
59+
| QueryHealth -- ^ Liveness check on the Rokur sidecar
60+
61+
||| Whether an action requires the gate to be in a specific state.
62+
||| Health is always available. AuthorizeStart requires Idle.
63+
||| CheckStatus and ReloadSecrets available in Idle or Allowed.
64+
export
65+
actionRequiresIdle : GateAction -> Bool
66+
actionRequiresIdle AuthorizeStart = True
67+
actionRequiresIdle CheckStatus = False
68+
actionRequiresIdle ReloadSecrets = False
69+
actionRequiresIdle QueryHealth = False
70+
71+
-- ---------------------------------------------------------------------------
72+
-- Policy verdict
73+
-- ---------------------------------------------------------------------------
74+
75+
||| Authorization verdict from the policy engine.
76+
||| Secret names are intentionally NEVER exposed — only counts.
77+
public export
78+
record AuthVerdict where
79+
constructor MkVerdict
80+
allowed : Bool
81+
policy : String -- "allow" or "deny"
82+
code : String -- decision reason code
83+
requiredCount : Nat
84+
missingCount : Nat
85+
policyEngine : String -- "builtin" or "external"
86+
87+
-- ---------------------------------------------------------------------------
88+
-- C-ABI integer encoding — gate state
89+
-- ---------------------------------------------------------------------------
90+
91+
export
92+
gateStateToInt : GateState -> Int
93+
gateStateToInt Idle = 0
94+
gateStateToInt Checking = 1
95+
gateStateToInt Allowed = 2
96+
gateStateToInt Denied = 3
97+
gateStateToInt Error = 4
98+
99+
export
100+
intToGateState : Int -> Maybe GateState
101+
intToGateState 0 = Just Idle
102+
intToGateState 1 = Just Checking
103+
intToGateState 2 = Just Allowed
104+
intToGateState 3 = Just Denied
105+
intToGateState 4 = Just Error
106+
intToGateState _ = Nothing
107+
108+
-- ---------------------------------------------------------------------------
109+
-- C-ABI integer encoding — gate action
110+
-- ---------------------------------------------------------------------------
111+
112+
export
113+
gateActionToInt : GateAction -> Int
114+
gateActionToInt AuthorizeStart = 0
115+
gateActionToInt CheckStatus = 1
116+
gateActionToInt ReloadSecrets = 2
117+
gateActionToInt QueryHealth = 3
118+
119+
export
120+
intToGateAction : Int -> Maybe GateAction
121+
intToGateAction 0 = Just AuthorizeStart
122+
intToGateAction 1 = Just CheckStatus
123+
intToGateAction 2 = Just ReloadSecrets
124+
intToGateAction 3 = Just QueryHealth
125+
intToGateAction _ = Nothing
126+
127+
-- ---------------------------------------------------------------------------
128+
-- C-ABI transition validator
129+
-- ---------------------------------------------------------------------------
130+
131+
export
132+
rokur_mcp_can_transition : Int -> Int -> Int
133+
rokur_mcp_can_transition from to =
134+
case (intToGateState from, intToGateState to) of
135+
(Just Idle, Just Checking) => 1 -- BeginCheck
136+
(Just Checking, Just Allowed) => 1 -- Approve
137+
(Just Checking, Just Denied) => 1 -- Reject
138+
(Just Checking, Just Error) => 1 -- Fault
139+
(Just Allowed, Just Idle) => 1 -- ResetAllowed
140+
(Just Denied, Just Idle) => 1 -- ResetDenied
141+
(Just Error, Just Idle) => 1 -- ResetError
142+
_ => 0
143+
144+
-- ---------------------------------------------------------------------------
145+
-- MCP tool declarations
146+
-- ---------------------------------------------------------------------------
147+
148+
public export
149+
data McpTool
150+
= ToolAuthorizeStart -- ^ rokur/authorize-start
151+
| ToolCheckStatus -- ^ rokur/status
152+
| ToolReloadSecrets -- ^ rokur/reload
153+
| ToolHealth -- ^ rokur/health
154+
155+
export
156+
toolCount : Nat
157+
toolCount = 4
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
-- SPDX-License-Identifier: PMPL-1.0-or-later
2+
package rokur_mcp
3+
4+
version = "0.1.0"
5+
authors = "Jonathan D.A. Jewell"
6+
brief = "Container pre-start secrets gate — Idris2 ABI for rokur-mcp"
7+
8+
depends = base
9+
10+
modules = RokurMcp.SafeGate
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
//
4+
// rokur_mcp_adapter.v — V-lang REST adapter for the rokur-mcp cartridge.
5+
//
6+
// Container pre-start secrets gate. Bridges the Zig FFI C-ABI exports to
7+
// the unified BoJ adapter protocol. Secret values are never exposed —
8+
// only presence/absence verdicts and counts.
9+
10+
module rokur_mcp_adapter
11+
12+
// ---------------------------------------------------------------------------
13+
// FFI declarations (from Zig shared library)
14+
// ---------------------------------------------------------------------------
15+
16+
#flag -L../../ffi/zig-out/lib
17+
#flag -lrokur_mcp
18+
19+
fn C.rokur_mcp_can_transition(from int, to int) int
20+
fn C.rokur_mcp_state() int
21+
fn C.rokur_mcp_transition(to int) int
22+
fn C.rokur_mcp_action_permitted(action int) int
23+
fn C.rokur_mcp_authorize(token_ptr &u8, token_len int, image_ptr &u8, image_len int) int
24+
fn C.rokur_mcp_health(token_ptr &u8, token_len int) int
25+
fn C.rokur_mcp_secrets_status(token_ptr &u8, token_len int) int
26+
fn C.rokur_mcp_reload(token_ptr &u8, token_len int) int
27+
fn C.rokur_mcp_read_result(out_ptr &u8, max_len int) int
28+
fn C.rokur_mcp_read_error(out_ptr &u8, max_len int) int
29+
fn C.rokur_mcp_last_verdict() int
30+
fn C.rokur_mcp_check_count() int
31+
fn C.rokur_mcp_reset()
32+
33+
// ---------------------------------------------------------------------------
34+
// Type definitions (matching Zig/Idris2 exactly)
35+
// ---------------------------------------------------------------------------
36+
37+
enum GateState {
38+
idle = 0
39+
checking = 1
40+
allowed = 2
41+
denied = 3
42+
err = 4
43+
}
44+
45+
fn state_label(s int) string {
46+
return match s {
47+
0 { 'idle' }
48+
1 { 'checking' }
49+
2 { 'allowed' }
50+
3 { 'denied' }
51+
4 { 'error' }
52+
else { 'unknown' }
53+
}
54+
}
55+
56+
// ---------------------------------------------------------------------------
57+
// Response types
58+
// ---------------------------------------------------------------------------
59+
60+
struct StateResponse {
61+
state string
62+
}
63+
64+
struct AuthResponse {
65+
allowed bool
66+
raw_result string
67+
}
68+
69+
struct GateStatusResponse {
70+
state string
71+
check_count int
72+
last_verdict string
73+
}
74+
75+
// ---------------------------------------------------------------------------
76+
// Internal helpers
77+
// ---------------------------------------------------------------------------
78+
79+
const result_buf_size = 4096
80+
81+
fn read_result() string {
82+
mut buf := []u8{len: result_buf_size}
83+
n := C.rokur_mcp_read_result(buf.data, result_buf_size)
84+
if n <= 0 {
85+
return ''
86+
}
87+
return buf[..n].bytestr()
88+
}
89+
90+
fn read_error() string {
91+
mut buf := []u8{len: 512}
92+
n := C.rokur_mcp_read_error(buf.data, 512)
93+
if n <= 0 {
94+
return 'unknown error'
95+
}
96+
return buf[..n].bytestr()
97+
}
98+
99+
// ---------------------------------------------------------------------------
100+
// Adapter functions — REST API bridge
101+
// ---------------------------------------------------------------------------
102+
103+
/// GET /rokur/state — query current gate state.
104+
pub fn rokur_state() StateResponse {
105+
s := C.rokur_mcp_state()
106+
return StateResponse{
107+
state: state_label(s)
108+
}
109+
}
110+
111+
/// POST /rokur/authorize-start — request pre-start container authorization.
112+
/// Calls the Rokur sidecar to validate required secrets presence.
113+
/// Returns allow/deny verdict without exposing secret values.
114+
pub fn rokur_authorize(token string, image string) !AuthResponse {
115+
result := C.rokur_mcp_authorize(
116+
token.str, token.len,
117+
image.str, image.len,
118+
)
119+
if result < 0 {
120+
return match result {
121+
-1 { error('gate not in idle state') }
122+
-2 { error('rokur sidecar unreachable: ${read_error()}') }
123+
-3 { error('invalid parameters') }
124+
else { error('unknown error (code ${result})') }
125+
}
126+
}
127+
return AuthResponse{
128+
allowed: C.rokur_mcp_last_verdict() == 1
129+
raw_result: read_result()
130+
}
131+
}
132+
133+
/// GET /rokur/health — Rokur sidecar liveness check.
134+
pub fn rokur_health(token string) !string {
135+
result := C.rokur_mcp_health(token.str, token.len)
136+
if result < 0 {
137+
return error('health check failed: ${read_error()}')
138+
}
139+
return read_result()
140+
}
141+
142+
/// GET /rokur/secrets/status — query secrets presence status.
143+
pub fn rokur_secrets_status(token string) !string {
144+
result := C.rokur_mcp_secrets_status(token.str, token.len)
145+
if result < 0 {
146+
return error('secrets status failed: ${read_error()}')
147+
}
148+
return read_result()
149+
}
150+
151+
/// POST /rokur/reload — hot-reload required secrets from environment.
152+
pub fn rokur_reload(token string) !string {
153+
result := C.rokur_mcp_reload(token.str, token.len)
154+
if result < 0 {
155+
return error('reload failed: ${read_error()}')
156+
}
157+
return read_result()
158+
}
159+
160+
/// GET /rokur/status — gate operational status.
161+
pub fn rokur_status() GateStatusResponse {
162+
return GateStatusResponse{
163+
state: state_label(C.rokur_mcp_state())
164+
check_count: C.rokur_mcp_check_count()
165+
last_verdict: if C.rokur_mcp_last_verdict() == 1 { 'allowed' } else { 'denied' }
166+
}
167+
}
168+
169+
/// Reset gate to initial state (test/debug only).
170+
pub fn reset() {
171+
C.rokur_mcp_reset()
172+
}

0 commit comments

Comments
 (0)