Initialize repository snapshot

This commit is contained in:
Vault Sovereign
2025-12-27 00:10:32 +00:00
commit 110d644e10
281 changed files with 40331 additions and 0 deletions

View File

@@ -0,0 +1,935 @@
#!/usr/bin/env python3
"""
VaultMesh Console Receipts HTTP Bridge
A minimal FastAPI server that exposes the Console receipt emitter
for the OpenCode plugin to call via HTTP.
Usage:
python scripts/console_receipts_server.py
# Or with uvicorn directly:
uvicorn scripts.console_receipts_server:app --host 127.0.0.1 --port 9110
"""
import os
import sys
from pathlib import Path
from typing import Any, Dict, List, Literal, Optional
# Add parent directory to path for imports
SCRIPT_DIR = Path(__file__).parent.absolute()
VAULTMESH_ROOT = SCRIPT_DIR.parent
sys.path.insert(0, str(VAULTMESH_ROOT))
# Set environment variable for the emitter
os.environ.setdefault("VAULTMESH_ROOT", str(VAULTMESH_ROOT))
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from dataclasses import asdict
from engines.console.receipts import (
ConsoleReceiptEmitter,
ReceiptType,
emit_console_receipt,
get_emitter,
)
from engines.console.approvals import get_approval_manager, ApprovalRequest
# ============================================================================
# FastAPI App
# ============================================================================
app = FastAPI(
title="VaultMesh Console Receipts API",
description="HTTP bridge for emitting Console receipts to the Civilization Ledger",
version="0.1.0",
)
# Allow CORS for local development
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ============================================================================
# Request/Response Models
# ============================================================================
class ReceiptIn(BaseModel):
"""Request body for emitting a receipt."""
type: ReceiptType
session_id: Optional[str] = None
payload: Dict[str, Any]
class ReceiptOut(BaseModel):
"""Response after emitting a receipt."""
ok: bool
record: Dict[str, Any]
class RootInfo(BaseModel):
"""Console scroll root information."""
engine_id: str
merkle_root: str
events: int
updated_at: Optional[str] = None
class AnchorRequest(BaseModel):
"""Request to trigger Guardian anchor."""
scrolls: Optional[List[str]] = None
class SearchRequest(BaseModel):
"""Request to search receipts."""
scroll: Optional[str] = "Console"
receipt_type: Optional[str] = None
limit: int = 50
class ApprovalRequestIn(BaseModel):
"""Request to create an approval."""
session_id: str
action_type: str
action_details: Dict[str, Any]
requested_by: str
approvers: List[str]
timeout_minutes: int = 60
class ApprovalDecisionIn(BaseModel):
"""Request to decide on an approval."""
approved: bool
approver: str
reason: str = ""
# ============================================================================
# Endpoints
# ============================================================================
@app.get("/health")
async def health_check():
"""Health check endpoint."""
return {"status": "ok", "engine": "console"}
@app.post("/v1/console/receipt", response_model=ReceiptOut)
async def post_receipt(req: ReceiptIn):
"""
Emit a Console receipt.
This is the main endpoint called by the OpenCode plugin.
"""
try:
record = emit_console_receipt(
receipt_type=req.type,
payload=req.payload,
session_id=req.session_id,
)
return ReceiptOut(ok=True, record=record)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/v1/console/root", response_model=RootInfo)
async def get_root():
"""
Get Console scroll Merkle root info.
"""
try:
emitter = get_emitter()
info = emitter.get_root_info()
return RootInfo(**info)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/v1/guardian/anchor")
async def trigger_anchor(req: AnchorRequest):
"""
Trigger a Guardian anchor cycle.
NOTE: This is a stub - in production, this would call the
actual Guardian engine to anchor the specified scrolls.
"""
scrolls = req.scrolls or ["Console"]
# TODO: Integrate with actual Guardian engine
return {
"ok": True,
"message": f"Anchor requested for scrolls: {scrolls}",
"note": "Stub implementation - Guardian integration pending",
}
@app.post("/v1/receipts/search")
async def search_receipts(req: SearchRequest):
"""
Search Console receipts.
NOTE: This is a simple implementation that reads from JSONL.
In production, use a proper index or database.
"""
import json
from pathlib import Path
emitter = get_emitter()
events_path = Path(emitter.events_path)
if not events_path.exists():
return {"results": [], "total": 0}
results = []
with open(events_path, "r", encoding="utf-8") as f:
for line in f:
if not line.strip():
continue
try:
record = json.loads(line)
# Filter by receipt type if specified
if req.receipt_type and record.get("type") != req.receipt_type:
continue
results.append(record)
except json.JSONDecodeError:
continue
# Return most recent first, with limit
results = list(reversed(results))[:req.limit]
return {
"results": results,
"total": len(results),
"scroll": req.scroll,
}
@app.get("/v1/console/receipts")
async def list_receipts(limit: int = 20, offset: int = 0):
"""
List Console receipts with pagination.
"""
import json
from pathlib import Path
emitter = get_emitter()
events_path = Path(emitter.events_path)
if not events_path.exists():
return {"receipts": [], "total": 0, "limit": limit, "offset": offset}
all_receipts = []
with open(events_path, "r", encoding="utf-8") as f:
for line in f:
if not line.strip():
continue
try:
all_receipts.append(json.loads(line))
except json.JSONDecodeError:
continue
# Most recent first
all_receipts = list(reversed(all_receipts))
total = len(all_receipts)
page = all_receipts[offset : offset + limit]
return {
"receipts": page,
"total": total,
"limit": limit,
"offset": offset,
}
# ============================================================================
# Session Query Endpoints
# ============================================================================
@app.get("/v1/console/sessions")
async def list_sessions(status: str = "all", limit: int = 20):
"""List Console sessions."""
import json
emitter = get_emitter()
events_path = Path(emitter.events_path)
if not events_path.exists():
return {"sessions": [], "total": 0}
sessions: Dict[str, Dict[str, Any]] = {}
for line in events_path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
try:
r = json.loads(line)
except Exception:
continue
sid = r.get("session_id")
if not sid:
continue
if sid not in sessions:
sessions[sid] = {
"session_id": sid,
"status": "active",
"started_at": None,
"ended_at": None,
"events": 0,
}
sessions[sid]["events"] += 1
r_type = r.get("type")
if r_type == "console_session_start":
sessions[sid]["started_at"] = r["ts"]
elif r_type == "console_session_end":
sessions[sid]["ended_at"] = r["ts"]
sessions[sid]["status"] = "ended"
results = list(sessions.values())
if status == "active":
results = [s for s in results if s["status"] == "active"]
elif status == "ended":
results = [s for s in results if s["status"] == "ended"]
return {"sessions": results[-limit:], "total": len(results)}
@app.get("/v1/console/sessions/{session_id}")
async def get_session(session_id: str):
"""Get detailed session status."""
import json
emitter = get_emitter()
events_path = Path(emitter.events_path)
if not events_path.exists():
raise HTTPException(status_code=404, detail="Session not found")
session: Optional[Dict[str, Any]] = None
for line in events_path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
try:
r = json.loads(line)
except Exception:
continue
if r.get("session_id") != session_id:
continue
r_type = r.get("type")
payload = r.get("payload") or {}
if r_type == "console_session_start":
session = {
"session_id": session_id,
"status": "active",
"started_at": r["ts"],
"agent_type": payload.get("agent_type"),
"model_id": payload.get("model_id"),
"caller": payload.get("caller"),
}
elif r_type == "console_session_end" and session is not None:
session["status"] = "ended"
session["ended_at"] = r["ts"]
if session is None:
raise HTTPException(status_code=404, detail="Session not found")
return session
# ============================================================================
# Approval Endpoints
# ============================================================================
@app.post("/v1/console/approvals/request")
async def request_approval(req: ApprovalRequestIn):
"""Request approval for an action."""
manager = get_approval_manager()
request = manager.request_approval(
session_id=req.session_id,
action_type=req.action_type,
action_details=req.action_details,
requested_by=req.requested_by,
approvers=req.approvers,
timeout_minutes=req.timeout_minutes,
)
return {"ok": True, "approval_id": request.approval_id}
@app.get("/v1/console/approvals/pending")
async def list_pending_approvals(session_id: Optional[str] = None):
"""List pending approval requests."""
manager = get_approval_manager()
pending = manager.list_pending(session_id)
return {"pending": [asdict(r) for r in pending]}
@app.post("/v1/console/approvals/{approval_id}/decide")
async def decide_approval(approval_id: str, req: ApprovalDecisionIn):
"""Approve or reject a pending action."""
manager = get_approval_manager()
try:
success = manager.decide(
approval_id=approval_id,
approved=req.approved,
approver=req.approver,
reason=req.reason,
)
return {
"ok": success,
"decision": "approved" if req.approved else "rejected",
}
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
except KeyError:
raise HTTPException(status_code=404, detail="Approval not found")
# ============================================================================
# GitLab Webhook Handler
# ============================================================================
@app.post("/gitlab/webhook")
async def gitlab_webhook(request: Request):
"""
Handle GitLab webhook events and emit Console receipts.
Supports:
- Push events → console_gitlab_push
- Merge request events → console_gitlab_mr
- Pipeline events → console_gitlab_pipeline
Configure in GitLab: Settings → Webhooks
"""
event_type = request.headers.get("X-Gitlab-Event", "unknown")
try:
body = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON body")
emitter = get_emitter()
if event_type == "Push Hook":
# Push event
project = body.get("project", {})
commits = body.get("commits", [])
session_id = f"gitlab-push-{body.get('checkout_sha', 'unknown')[:12]}"
emit_console_receipt(
"console_command", # Reuse console_command for push events
{
"command": "git_push",
"args_hash": body.get("checkout_sha", ""),
"exit_code": 0,
"duration_ms": 0,
"gitlab_event": "push",
"project_path": project.get("path_with_namespace", ""),
"ref": body.get("ref", ""),
"commits_count": len(commits),
"user": body.get("user_name", ""),
},
session_id=session_id,
)
return {"ok": True, "event": "push", "session_id": session_id}
elif event_type == "Merge Request Hook":
# Merge request event
mr = body.get("object_attributes", {})
project = body.get("project", {})
session_id = f"gitlab-mr-{mr.get('iid', 'unknown')}"
action = mr.get("action", "update") # open, close, merge, update, etc.
emit_console_receipt(
"console_command",
{
"command": f"mr_{action}",
"args_hash": mr.get("last_commit", {}).get("id", ""),
"exit_code": 0,
"duration_ms": 0,
"gitlab_event": "merge_request",
"project_path": project.get("path_with_namespace", ""),
"mr_iid": mr.get("iid"),
"mr_title": mr.get("title", ""),
"mr_state": mr.get("state", ""),
"source_branch": mr.get("source_branch", ""),
"target_branch": mr.get("target_branch", ""),
"user": body.get("user", {}).get("name", ""),
},
session_id=session_id,
)
return {"ok": True, "event": "merge_request", "action": action, "session_id": session_id}
elif event_type == "Pipeline Hook":
# Pipeline event
pipeline = body.get("object_attributes", {})
project = body.get("project", {})
session_id = f"gitlab-pipeline-{pipeline.get('id', 'unknown')}"
status = pipeline.get("status", "unknown")
# Only emit for significant status changes
if status in ("running", "success", "failed", "canceled"):
receipt_type = "console_session_start" if status == "running" else "console_session_end"
if status == "running":
emit_console_receipt(
"console_session_start",
{
"agent_type": "gitlab-ci",
"model_id": "none",
"caller": "did:vm:service:gitlab-webhook",
"project_path": project.get("path_with_namespace", ""),
"pipeline_id": pipeline.get("id"),
"ref": pipeline.get("ref", ""),
"commit": pipeline.get("sha", ""),
},
session_id=session_id,
)
else:
emit_console_receipt(
"console_session_end",
{
"duration_ms": pipeline.get("duration", 0) * 1000 if pipeline.get("duration") else 0,
"commands_executed": 0,
"files_modified": 0,
"exit_reason": f"pipeline-{status}",
},
session_id=session_id,
)
return {"ok": True, "event": "pipeline", "status": status, "session_id": session_id}
else:
# Unknown event type - log but don't fail
return {"ok": True, "event": event_type, "note": "Unhandled event type"}
# ============================================================================
# HTML Dashboard
# ============================================================================
from fastapi.responses import HTMLResponse
def format_time_ago(ts_str: str) -> str:
"""Format timestamp as relative time."""
from datetime import datetime, timezone
try:
ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
now = datetime.now(timezone.utc)
diff = now - ts
seconds = diff.total_seconds()
if seconds < 60:
return f"{int(seconds)}s ago"
elif seconds < 3600:
return f"{int(seconds/60)}m ago"
elif seconds < 86400:
return f"{int(seconds/3600)}h ago"
else:
return f"{int(seconds/86400)}d ago"
except:
return ts_str[:19]
def get_event_color(event_type: str) -> str:
"""Get color for event type."""
colors = {
"console_session_start": "#22c55e", # green
"console_session_end": "#6b7280", # gray
"console_command": "#3b82f6", # blue
"console_file_edit": "#a855f7", # purple
"console_tool_call": "#06b6d4", # cyan
"console_approval_request": "#f59e0b", # amber
"console_approval": "#10b981", # emerald
"console_git_commit": "#ec4899", # pink
}
return colors.get(event_type, "#9ca3af")
def get_event_icon(event_type: str) -> str:
"""Get icon for event type."""
icons = {
"console_session_start": "",
"console_session_end": "",
"console_command": "",
"console_file_edit": "",
"console_tool_call": "",
"console_approval_request": "",
"console_approval": "",
"console_git_commit": "",
}
return icons.get(event_type, "")
@app.get("/console/dashboard", response_class=HTMLResponse)
async def console_dashboard():
"""
HTML dashboard showing Console status at a glance.
Shows:
- Active and recent sessions
- Pending approvals
- Recent events stream
"""
import json
from datetime import datetime, timezone
emitter = get_emitter()
events_path = Path(emitter.events_path)
# Collect data
sessions: Dict[str, Dict[str, Any]] = {}
all_events: List[Dict[str, Any]] = []
if events_path.exists():
for line in events_path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
try:
r = json.loads(line)
all_events.append(r)
sid = r.get("session_id")
if sid:
if sid not in sessions:
sessions[sid] = {
"session_id": sid,
"status": "active",
"started_at": None,
"ended_at": None,
"events": 0,
"agent_type": None,
"caller": None,
}
sessions[sid]["events"] += 1
if r.get("type") == "console_session_start":
sessions[sid]["started_at"] = r.get("ts")
payload = r.get("payload", {})
sessions[sid]["agent_type"] = payload.get("agent_type")
sessions[sid]["caller"] = payload.get("caller")
elif r.get("type") == "console_session_end":
sessions[sid]["ended_at"] = r.get("ts")
sessions[sid]["status"] = "ended"
except:
continue
# Get pending approvals
manager = get_approval_manager()
pending = manager.list_pending()
# Get root info
root_info = emitter.get_root_info()
# Sort sessions by most recent activity
session_list = sorted(
sessions.values(),
key=lambda s: s.get("started_at") or "",
reverse=True
)[:20]
# Recent events (last 30)
recent_events = list(reversed(all_events[-30:]))
# Build HTML
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="10">
<title>VaultMesh Console Dashboard</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace;
background: #0f172a;
color: #e2e8f0;
padding: 1rem;
min-height: 100vh;
}}
.header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid #334155;
}}
.header h1 {{
font-size: 1.5rem;
font-weight: 600;
color: #f8fafc;
}}
.header h1 span {{
color: #22c55e;
}}
.stats {{
display: flex;
gap: 2rem;
font-size: 0.875rem;
color: #94a3b8;
}}
.stats strong {{
color: #f8fafc;
}}
.grid {{
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}}
@media (max-width: 900px) {{
.grid {{ grid-template-columns: 1fr; }}
}}
.panel {{
background: #1e293b;
border-radius: 0.5rem;
padding: 1rem;
border: 1px solid #334155;
}}
.panel h2 {{
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #94a3b8;
margin-bottom: 1rem;
}}
.session {{
padding: 0.75rem;
margin-bottom: 0.5rem;
background: #0f172a;
border-radius: 0.375rem;
border-left: 3px solid;
}}
.session.active {{ border-color: #22c55e; }}
.session.ended {{ border-color: #6b7280; }}
.session-id {{
font-family: monospace;
font-size: 0.875rem;
color: #f8fafc;
}}
.session-meta {{
font-size: 0.75rem;
color: #64748b;
margin-top: 0.25rem;
}}
.approval {{
padding: 0.75rem;
margin-bottom: 0.5rem;
background: #451a03;
border-radius: 0.375rem;
border-left: 3px solid #f59e0b;
}}
.approval-id {{
font-family: monospace;
font-size: 0.75rem;
color: #fbbf24;
}}
.approval-action {{
font-size: 0.875rem;
color: #f8fafc;
margin-top: 0.25rem;
}}
.approval-cmd {{
font-family: monospace;
font-size: 0.75rem;
color: #94a3b8;
background: #0f172a;
padding: 0.5rem;
margin-top: 0.5rem;
border-radius: 0.25rem;
word-break: break-all;
}}
.event {{
display: flex;
gap: 0.75rem;
padding: 0.5rem 0;
border-bottom: 1px solid #1e293b;
font-size: 0.8125rem;
}}
.event:last-child {{ border-bottom: none; }}
.event-icon {{
width: 1.5rem;
text-align: center;
flex-shrink: 0;
}}
.event-time {{
width: 4rem;
flex-shrink: 0;
color: #64748b;
font-size: 0.75rem;
}}
.event-type {{
font-family: monospace;
font-size: 0.75rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
flex-shrink: 0;
}}
.event-detail {{
color: #94a3b8;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}}
.no-data {{
color: #64748b;
font-style: italic;
padding: 1rem;
text-align: center;
}}
.full-width {{ grid-column: 1 / -1; }}
</style>
</head>
<body>
<div class="header">
<h1>🜂 <span>VaultMesh</span> Console Dashboard</h1>
<div class="stats">
<span>Sessions: <strong>{len(sessions)}</strong></span>
<span>Events: <strong>{root_info.get('events', 0)}</strong></span>
<span>Pending: <strong>{len(pending)}</strong></span>
<span>Root: <strong>{root_info.get('merkle_root', '0')[:12]}...</strong></span>
</div>
</div>
<div class="grid">
<div class="panel">
<h2>⏳ Pending Approvals ({len(pending)})</h2>
"""
if pending:
for p in pending:
html += f"""
<div class="approval">
<div class="approval-id">{p.approval_id}</div>
<div class="approval-action">{p.action_type}</div>
<div class="session-meta">Session: {p.session_id}</div>
<div class="approval-cmd">vm console approve {p.approval_id} --reason "..."</div>
</div>
"""
else:
html += '<div class="no-data">No pending approvals</div>'
html += """
</div>
<div class="panel">
<h2>📡 Sessions</h2>
"""
if session_list:
for s in session_list[:10]:
status_class = "active" if s["status"] == "active" else "ended"
agent = s.get("agent_type") or "unknown"
started = format_time_ago(s["started_at"]) if s.get("started_at") else "?"
html += f"""
<div class="session {status_class}">
<div class="session-id">{s['session_id']}</div>
<div class="session-meta">{agent}{s['events']} events • {started}</div>
</div>
"""
else:
html += '<div class="no-data">No sessions yet</div>'
html += """
</div>
<div class="panel full-width">
<h2>📜 Recent Events</h2>
"""
if recent_events:
for e in recent_events:
etype = e.get("type", "unknown")
ts = format_time_ago(e.get("ts", ""))
color = get_event_color(etype)
icon = get_event_icon(etype)
payload = e.get("payload", {})
# Build detail string based on event type
detail = ""
if etype == "console_command":
detail = payload.get("command", "")
elif etype == "console_file_edit":
detail = payload.get("file_path", "")
elif etype == "console_approval_request":
detail = f"{payload.get('action_type', '')}{payload.get('approval_id', '')}"
elif etype == "console_approval":
approved = "" if payload.get("approved") else ""
detail = f"{approved} {payload.get('action_type', '')} by {payload.get('approver', '')}"
elif etype == "console_session_start":
detail = f"{payload.get('agent_type', '')} by {payload.get('caller', '')}"
elif etype == "console_session_end":
detail = payload.get("exit_reason", "")
short_type = etype.replace("console_", "")
html += f"""
<div class="event">
<div class="event-icon" style="color: {color}">{icon}</div>
<div class="event-time">{ts}</div>
<div class="event-type" style="background: {color}20; color: {color}">{short_type}</div>
<div class="event-detail">{detail}</div>
</div>
"""
else:
html += '<div class="no-data">No events yet</div>'
html += """
</div>
</div>
<script>
// Auto-refresh indicator
setTimeout(() => {
document.querySelector('.header h1').innerHTML = '🜂 <span>VaultMesh</span> Console Dashboard <small style="color:#64748b;font-size:0.75rem">refreshing...</small>';
}, 9000);
</script>
</body>
</html>
"""
return html
# ============================================================================
# Main
# ============================================================================
def main():
"""Run the server."""
import uvicorn
print(f"[VaultMesh] Console Receipts Server")
print(f"[VaultMesh] Root: {VAULTMESH_ROOT}")
print(f"[VaultMesh] Listening on http://127.0.0.1:9110")
uvicorn.run(
app,
host="127.0.0.1",
port=9110,
log_level="info",
)
if __name__ == "__main__":
main()

190
scripts/gitlab_console_session.sh Executable file
View File

@@ -0,0 +1,190 @@
#!/usr/bin/env bash
# ============================================================================
# VaultMesh GitLab Console Session Helper
#
# Wires GitLab CI pipelines into the Console engine as sessions.
# Each pipeline becomes a Console session with start/end receipts.
#
# Usage:
# ./scripts/gitlab_console_session.sh start
# ./scripts/gitlab_console_session.sh end
# ./scripts/gitlab_console_session.sh cmd <name> <exit_code>
# ./scripts/gitlab_console_session.sh request_approval <action_type>
#
# Environment Variables (set in GitLab CI/CD Settings → Variables):
# VAULTMESH_CONSOLE_BASE - Console HTTP bridge URL
# VAULTMESH_CALLER_DID - DID for GitLab CI service
# VAULTMESH_APPROVER_DID - Default approver DID
# VM_ENV - Environment: dev, staging, or prod
#
# GitLab CI Variables (automatic):
# CI_PIPELINE_ID, CI_PROJECT_PATH, CI_COMMIT_SHA, CI_JOB_STATUS
# ============================================================================
set -euo pipefail
# Defaults
BASE="${VAULTMESH_CONSOLE_BASE:-http://127.0.0.1:9110/v1/console}"
ENDPOINT="$BASE/receipt"
CALLER="${VAULTMESH_CALLER_DID:-did:vm:service:gitlab-ci}"
APPROVER="${VAULTMESH_APPROVER_DID:-did:vm:human:karol}"
SESSION_ID="gitlab-pipeline-${CI_PIPELINE_ID:-unknown}"
PROJECT="${CI_PROJECT_PATH:-unknown}"
COMMIT="${CI_COMMIT_SHA:-unknown}"
ENV="${VM_ENV:-prod}" # Default to prod (most restrictive)
# Emit a receipt to Console
emit() {
local type="$1"
local payload="$2"
curl -sS -X POST "$ENDPOINT" \
-H 'Content-Type: application/json' \
-d "{
\"type\":\"$type\",
\"session_id\":\"$SESSION_ID\",
\"payload\":$payload
}" >/dev/null || echo "[VaultMesh] Warning: Failed to emit $type receipt"
}
# Request approval and return approval_id
request_approval() {
local action_type="$1"
local details="${2:-{}}"
local resp
resp=$(curl -sS -X POST "$BASE/approvals/request" \
-H 'Content-Type: application/json' \
-d "{
\"session_id\":\"$SESSION_ID\",
\"action_type\":\"$action_type\",
\"action_details\":$details,
\"requested_by\":\"$CALLER\",
\"approvers\":[\"$APPROVER\"],
\"timeout_minutes\": 120
}")
echo "$resp"
}
# Check if approval is still pending
check_pending() {
local approval_id="$1"
local resp
resp=$(curl -sS "$BASE/approvals/pending?session_id=$SESSION_ID")
# Check if approval_id is in pending list
if echo "$resp" | jq -e ".pending[] | select(.approval_id == \"$approval_id\")" >/dev/null 2>&1; then
echo "pending"
else
echo "decided"
fi
}
# Main dispatch
case "${1:-}" in
start)
echo "[VaultMesh] Starting Console session: $SESSION_ID (env: $ENV)"
emit "console_session_start" "{
\"agent_type\":\"gitlab-ci\",
\"model_id\":\"none\",
\"caller\":\"$CALLER\",
\"project_path\":\"$PROJECT\",
\"pipeline_id\":\"${CI_PIPELINE_ID:-unknown}\",
\"commit\":\"$COMMIT\",
\"env\":\"$ENV\"
}"
echo "[VaultMesh] Session started"
;;
end)
STATUS="${CI_JOB_STATUS:-unknown}"
echo "[VaultMesh] Ending Console session: $SESSION_ID (status: $STATUS)"
emit "console_session_end" "{
\"duration_ms\":0,
\"commands_executed\":0,
\"files_modified\":0,
\"exit_reason\":\"pipeline-$STATUS\"
}"
echo "[VaultMesh] Session ended"
;;
cmd)
CMD_NAME="${2:-unknown}"
EXIT_CODE="${3:-0}"
echo "[VaultMesh] Recording command: $CMD_NAME (exit: $EXIT_CODE)"
emit "console_command" "{
\"command\":\"$CMD_NAME\",
\"args_hash\":\"$COMMIT\",
\"exit_code\":$EXIT_CODE,
\"duration_ms\":0
}"
;;
request_approval)
ACTION_TYPE="${2:-deploy}"
DETAILS="${3:-{\"env\":\"$ENV\",\"commit\":\"$COMMIT\",\"pipeline_id\":\"${CI_PIPELINE_ID:-unknown}\"}}"
echo "[VaultMesh] Requesting approval for: $ACTION_TYPE (env: $ENV)"
RESP=$(request_approval "$ACTION_TYPE" "$DETAILS")
APPROVAL_ID=$(echo "$RESP" | jq -r '.approval_id')
echo "[VaultMesh] Approval requested: $APPROVAL_ID"
echo ""
echo "============================================================"
echo "ACTION REQUIRES APPROVAL"
echo "============================================================"
echo ""
echo "Approval ID: $APPROVAL_ID"
echo "Action Type: $ACTION_TYPE"
echo ""
echo "To approve, run on VaultMesh host:"
echo ""
echo " export VAULTMESH_ACTOR_DID=\"$APPROVER\""
echo " vm console approve $APPROVAL_ID --reason \"Approved from GitLab\""
echo ""
echo "============================================================"
# Exit 1 to fail the job (approval required)
exit 1
;;
check_approval)
APPROVAL_ID="${2:-}"
if [ -z "$APPROVAL_ID" ]; then
echo "Usage: $0 check_approval <approval_id>" >&2
exit 1
fi
STATUS=$(check_pending "$APPROVAL_ID")
echo "[VaultMesh] Approval $APPROVAL_ID status: $STATUS"
if [ "$STATUS" = "pending" ]; then
echo "Approval still pending. Cannot proceed."
exit 1
else
echo "Approval decided. Proceeding."
exit 0
fi
;;
*)
echo "VaultMesh GitLab Console Session Helper"
echo ""
echo "Usage: $0 {start|end|cmd|request_approval|check_approval}"
echo ""
echo "Commands:"
echo " start - Emit console_session_start"
echo " end - Emit console_session_end"
echo " cmd <name> <exit_code> - Emit console_command"
echo " request_approval <type> - Request approval and exit 1"
echo " check_approval <id> - Check if approval decided"
echo ""
echo "Environment:"
echo " VAULTMESH_CONSOLE_BASE - Console HTTP bridge URL"
echo " VAULTMESH_CALLER_DID - DID for GitLab CI service"
echo " VAULTMESH_APPROVER_DID - Default approver DID"
exit 1
;;
esac

View File

@@ -0,0 +1,123 @@
"""
Thin client for talking to the OffSec Shield Node (offsec-agents MCP backend).
Usage:
from scripts.offsec_node_client import OffsecNodeClient
client = OffsecNodeClient() # uses OFFSEC_NODE_URL env
agents = await client.command("agents list")
status = await client.command("tem status")
"""
from __future__ import annotations
import asyncio
import json
import os
from dataclasses import dataclass
from typing import Any, Dict, Optional
import aiohttp
DEFAULT_OFFSEC_NODE_URL = "http://shield-vm:8081"
@dataclass
class OffsecNodeError(Exception):
message: str
status: Optional[int] = None
details: Optional[Dict[str, Any]] = None
def __str__(self) -> str:
base = self.message
if self.status is not None:
base += f" (status={self.status})"
if self.details:
base += f" details={self.details}"
return base
@dataclass
class OffsecNodeClient:
base_url: str = DEFAULT_OFFSEC_NODE_URL
timeout_seconds: int = 10
@classmethod
def from_env(cls) -> "OffsecNodeClient":
url = os.getenv("OFFSEC_NODE_URL", DEFAULT_OFFSEC_NODE_URL)
return cls(base_url=url)
async def health(self) -> Dict[str, Any]:
url = f"{self.base_url.rstrip('/')}/health"
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=self.timeout_seconds)) as session:
async with session.get(url) as resp:
if resp.status != 200:
raise OffsecNodeError("Shield node health check failed", status=resp.status)
return await resp.json()
async def command(
self,
command: str,
session_id: str = "vaultmesh-client",
user: str = "vaultmesh",
) -> Dict[str, Any]:
"""
Send a command to the Shield Node MCP backend.
Example commands:
"agents list"
"status"
"shield status"
"proof latest"
"agent spawn recon"
"""
url = f"{self.base_url.rstrip('/')}/mcp/command"
payload: Dict[str, Any] = {
"session_id": session_id,
"user": user,
"command": command,
}
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=self.timeout_seconds)) as session:
async with session.post(url, json=payload) as resp:
text = await resp.text()
if resp.status != 200:
# Try to parse JSON details if present
try:
data = json.loads(text)
except json.JSONDecodeError:
data = None
raise OffsecNodeError(
"Shield node command failed",
status=resp.status,
details=data or {"raw": text},
)
try:
return json.loads(text)
except json.JSONDecodeError as exc:
raise OffsecNodeError("Invalid JSON from Shield node", details={"raw": text}) from exc
# Optional: CLI entrypoint for quick manual tests
async def _demo() -> None:
client = OffsecNodeClient.from_env()
print(f"[offsec-node] base_url={client.base_url}")
try:
health = await client.health()
print("Health:", json.dumps(health, indent=2))
except OffsecNodeError as e:
print("Health check failed:", e)
return
try:
agents = await client.command("agents list")
print("Agents:", json.dumps(agents, indent=2))
except OffsecNodeError as e:
print("Command failed:", e)
if __name__ == "__main__":
asyncio.run(_demo())