Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,9 @@ RISKKERNEL_DEFAULT_SECONDS=
OTEL_EXPORTER_OTLP_ENDPOINT=
OTEL_EXPORTER_OTLP_PROTOCOL=grpc
OTEL_SERVICE_NAME=riskkernel

# Human-in-the-loop approval gate.
# DEFAULT_SAFE=true requires approval for any side-effecting tool call (fail closed).
RISKKERNEL_APPROVAL_DEFAULT_SAFE=true
# Optional: POST a JSON notification here when an approval becomes pending.
RISKKERNEL_APPROVAL_WEBHOOK=
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,15 @@ surface is governed by [`COMPATIBILITY.md`](COMPATIBILITY.md).
gRPC/HTTP via standard `OTEL_*` env vars. **Off unless an endpoint is
configured** — spans go only to the user's backend. Example Jaeger backend +
dashboard guidance in `examples/otel/`.
- **Human-in-the-loop approval gate (Surface: HITL)** — a side-effecting tool call
that policy gates pauses until a human approves or denies it. Deterministic
policy match (exact tool or side-effect glob, plus a fail-closed default-safe
mode); the gate blocks the call and is resolved via three channels: the CLI
(`riskkernel approvals list / approve / deny`), a local embedded admin page
(`/admin/approvals`), and an optional webhook (`RISKKERNEL_APPROVAL_WEBHOOK`).
`POST /v1/runs/{id}/approve`, `GET /v1/approvals`, and `GET /v1/runs/{id}`
(surfaces `pendingApproval` + `waiting_approval` status). Approvals are persisted
(migration `00003`) as an audit trail. Webhook is user-configured egress only
(see SECURITY.md).

[Unreleased]: https://github.com/prashar32/riskkernel/commits/main
4 changes: 2 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ RiskKernel is a reliability and governance daemon for AI agents. It runs on infr

This is verifiable, and we intend for you to verify it:

- **Read the code.** All network egress originates in `internal/provider/` (calls to LLM providers) and `internal/otel/` (only when *you* configure an OTLP endpoint). There is no other outbound HTTP client in the codebase.
- **Watch the wire.** Run RiskKernel under `tcpdump`/`Little Snitch`/`mitmproxy` and confirm the only destinations are your configured provider and OTLP endpoints.
- **Read the code.** All network egress originates in three places, each going only where *you* point it: `internal/provider/` (calls to the LLM providers you configure), `internal/otel/` (spans, only when you set an OTLP endpoint), and `internal/approval/` (the approval webhook, only when you set `RISKKERNEL_APPROVAL_WEBHOOK`). The webhook payload carries the pending approval's metadata (run id, tool, side effect, arguments) — never provider keys or other secrets. There is no other outbound HTTP client in the codebase.
- **Watch the wire.** Run RiskKernel under `tcpdump`/`Little Snitch`/`mitmproxy` and confirm the only destinations are your configured provider, OTLP, and (if set) approval-webhook endpoints.
- **Build gates.** CI fails if a disallowed network import appears outside the provider/otel packages.

Any future "you're N versions behind" hint is a local startup log line comparing against a build-time constant — it makes no network call. An optional `--share-anonymous-usage` flag may exist someday; it will be **OFF by default** and clearly documented.
Expand Down
43 changes: 42 additions & 1 deletion api/v1/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,33 @@ paths:
'404':
$ref: '#/components/responses/NotFound'

/v1/approvals:
get:
tags: [approvals]
operationId: listApprovals
summary: List approval requests
description: |
Lists human-in-the-loop approval requests, optionally filtered by status.
Used by the CLI and the local admin page to surface what is pending.
parameters:
- name: status
in: query
required: false
schema:
type: string
enum: [pending, approved, denied]
responses:
'200':
description: Matching approvals, newest first.
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/ApprovalRequest'
'401':
$ref: '#/components/responses/Unauthorized'

/v1/checkpoints/{run_id}:
get:
tags: [checkpoints]
Expand Down Expand Up @@ -412,11 +439,14 @@ components:
ApprovalRequest:
type: object
required: [id, tool, createdAt]
description: A side-effecting action paused awaiting human approval.
description: A side-effecting action gated for human approval (pending or resolved).
properties:
id:
type: string
format: uuid
runId:
type: string
format: uuid
stepIndex:
type: integer
format: int32
Expand All @@ -430,9 +460,20 @@ components:
type: object
additionalProperties: true
description: The proposed call arguments, for the human to review.
status:
type: string
enum: [pending, approved, denied]
reason:
type: string
description: Human note recorded with the decision (once resolved).
decidedBy:
type: string
createdAt:
type: string
format: date-time
decidedAt:
type: string
format: date-time

ApprovalDecision:
type: object
Expand Down
175 changes: 175 additions & 0 deletions cmd/riskkernel/approvals.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package main

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"text/tabwriter"
"time"

"github.com/prashar32/riskkernel/internal/config"
)

// runApprovals implements `riskkernel approvals <list|approve|deny>`.
//
// Unlike `runs`/`audit` (which read the local store), these talk to the running
// daemon's HTTP API — resolving an approval must wake the goroutine blocked inside
// the daemon, which a direct store write cannot do.
func runApprovals(args []string) error {
if len(args) == 0 {
return fmt.Errorf("usage: riskkernel approvals <list | approve <id> | deny <id>>")
}
switch args[0] {
case "list":
return approvalsList()
case "approve", "deny":
if len(args) < 2 {
return fmt.Errorf("usage: riskkernel approvals %s <id> [--reason <text>]", args[0])
}
reason := flagValue(args[2:], "--reason")
return approvalsResolve(args[1], args[0] == "approve", reason)
default:
return fmt.Errorf("unknown approvals subcommand %q (want list|approve|deny)", args[0])
}
}

type approvalItem struct {
ID string `json:"id"`
RunID string `json:"runId"`
StepIndex int `json:"stepIndex"`
Tool string `json:"tool"`
SideEffect string `json:"sideEffect"`
Arguments map[string]any `json:"arguments"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
}

func approvalsList() error {
c, err := newDaemonClient()
if err != nil {
return err
}
var items []approvalItem
if err := c.getJSON("/v1/approvals?status=pending", &items); err != nil {
return err
}
if len(items) == 0 {
fmt.Println("no pending approvals")
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tRUN\tSTEP\tTOOL\tSIDE EFFECT\tCREATED")
for _, a := range items {
fmt.Fprintf(tw, "%s\t%s\t%d\t%s\t%s\t%s\n", a.ID, a.RunID, a.StepIndex, a.Tool, dash(a.SideEffect), a.CreatedAt)
}
return tw.Flush()
}

func approvalsResolve(id string, approve bool, reason string) error {
c, err := newDaemonClient()
if err != nil {
return err
}
// Discover the approval's run id from the pending list (the resolve endpoint
// is keyed by run id per the api/v1 contract).
var items []approvalItem
if err := c.getJSON("/v1/approvals?status=pending", &items); err != nil {
return err
}
runID := ""
for _, a := range items {
if a.ID == id {
runID = a.RunID
break
}
}
if runID == "" {
return fmt.Errorf("no pending approval with id %s", id)
}

decision := "deny"
if approve {
decision = "approve"
}
body, _ := json.Marshal(map[string]string{
"approvalId": id, "decision": decision, "reason": reason, "decidedBy": "cli",
})
if err := c.post("/v1/runs/"+runID+"/approve", body); err != nil {
return err
}
fmt.Printf("%sd approval %s\n", decision, id)
return nil
}

// --- minimal daemon HTTP client ---

type daemonClient struct {
base string
token string
client *http.Client
}

func newDaemonClient() (*daemonClient, error) {
cfg, err := config.Load()
if err != nil {
return nil, err
}
return &daemonClient{
base: fmt.Sprintf("http://localhost:%d", cfg.Port),
token: cfg.APIToken,
client: &http.Client{Timeout: 10 * time.Second},
}, nil
}

func (c *daemonClient) do(method, path string, body []byte) ([]byte, error) {
var rdr io.Reader
if body != nil {
rdr = bytes.NewReader(body)
}
req, err := http.NewRequest(method, c.base+path, rdr)
if err != nil {
return nil, err
}
if body != nil {
req.Header.Set("content-type", "application/json")
}
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot reach daemon at %s (is `riskkernel serve` running?): %w", c.base, err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("daemon returned %d: %s", resp.StatusCode, string(raw))
}
return raw, nil
}

func (c *daemonClient) getJSON(path string, out any) error {
raw, err := c.do(http.MethodGet, path, nil)
if err != nil {
return err
}
return json.Unmarshal(raw, out)
}

func (c *daemonClient) post(path string, body []byte) error {
_, err := c.do(http.MethodPost, path, body)
return err
}

// flagValue returns the value following name in args, or "".
func flagValue(args []string, name string) string {
for i := 0; i < len(args)-1; i++ {
if args[i] == name {
return args[i+1]
}
}
return ""
}
7 changes: 6 additions & 1 deletion cmd/riskkernel/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ func main() {
err = runRuns(args)
case "audit":
err = runAudit(args)
case "approvals":
err = runApprovals(args)
case "version", "--version", "-v":
fmt.Println("riskkernel", version.String())
case "help", "--help", "-h":
Expand All @@ -69,6 +71,9 @@ Usage:
riskkernel runs list List persisted governed runs
riskkernel runs resume <id> Show a run's resumable state after a crash
riskkernel audit export <id> Export a run's cost ledger as JSON
riskkernel approvals list List pending human-in-the-loop approvals
riskkernel approvals approve <id> [--reason ...] Approve a pending request
riskkernel approvals deny <id> [--reason ...] Deny a pending request
riskkernel version Print build identity
riskkernel help Show this help

Expand Down Expand Up @@ -112,7 +117,7 @@ func runServe(_ []string) error {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

srv := httpapi.New(cfg, deps.Gateway, deps.Runs, deps.Log)
srv := httpapi.New(cfg, deps.Gateway, deps.Runs, deps.Approvals, deps.Log)
addr := fmt.Sprintf(":%d", cfg.Port)
return srv.Serve(ctx, addr)
}
Expand Down
9 changes: 9 additions & 0 deletions internal/app/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"path/filepath"
"time"

"github.com/prashar32/riskkernel/internal/approval"
"github.com/prashar32/riskkernel/internal/config"
"github.com/prashar32/riskkernel/internal/gateway"
"github.com/prashar32/riskkernel/internal/governor"
Expand All @@ -32,6 +33,7 @@ type Deps struct {
Gateway *gateway.Gateway
Store storage.Store
Tracer *otel.Tracer
Approvals *approval.Gate
}

// Close releases dependencies that hold resources (the tracer's buffered spans,
Expand Down Expand Up @@ -78,6 +80,12 @@ func Build(cfg *config.Config) (*Deps, error) {
mgr := runs.NewManager(toGovernorBudget(cfg.DefaultBudget)).WithStore(store, log)
gw := gateway.New(registry, mgr, prices, tracer, log)

var notifier approval.Notifier
if wh := approval.NewWebhookNotifier(cfg.Approval.WebhookURL, log); wh != nil {
notifier = wh
}
gate := approval.NewGate(store, approval.Policy{DefaultSafe: cfg.Approval.DefaultSafe}, notifier, log)

return &Deps{
Config: cfg,
Log: log,
Expand All @@ -87,6 +95,7 @@ func Build(cfg *config.Config) (*Deps, error) {
Gateway: gw,
Store: store,
Tracer: tracer,
Approvals: gate,
}, nil
}

Expand Down
Loading
Loading