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
32 changes: 32 additions & 0 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Python SDK

on:
push:
branches: [main]
paths: ["sdks/python/**", ".github/workflows/python.yml"]
pull_request:
branches: [main]
paths: ["sdks/python/**", ".github/workflows/python.yml"]

permissions:
contents: read

jobs:
test:
name: test (py${{ matrix.python }})
runs-on: ubuntu-latest
strategy:
matrix:
python: ["3.9", "3.12"]
defaults:
run:
working-directory: sdks/python
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
- name: Install (editable, stdlib-only core)
run: pip install -e .
- name: Test
run: python -m unittest discover -s tests -t . -v
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,14 @@ surface is governed by [`COMPATIBILITY.md`](COMPATIBILITY.md).
(surfaces `pendingApproval` + `waiting_approval` status). Approvals are persisted
(migration `00003`) as an audit trail. Webhook is user-configured egress only
(see SECURITY.md).
- **Run-control API** — `POST /v1/runs` (create with budget),
`POST /v1/runs/{id}/steps` (loop/time enforcement → 402 on halt),
`POST /v1/runs/{id}/checkpoints`, `POST /v1/runs/{id}/cancel`,
`POST /v1/runs/{id}/approvals` (request → poll), `GET /v1/approvals/{id}`.
- **Python SDK (Surface 2)** — `pip install riskkernel`, a stdlib-only thin client:
`Runtime`, `governed_run`, `Budget`, `Run.step/checkpoint/cancel/proxy_config`,
`ApprovalGate`, `@governed_tool`. Lazy-imported framework adapters for LangChain
(callback handler), the Claude Agent SDK (PreToolUse hook), and the OpenAI Agents
SDK (RunHooks). Verified end-to-end against the daemon; CI on Python 3.9/3.12.

[Unreleased]: https://github.com/prashar32/riskkernel/commits/main
140 changes: 140 additions & 0 deletions api/v1/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,146 @@ paths:
schema:
$ref: '#/components/schemas/Error'

/v1/runs/{id}/steps:
post:
tags: [runs]
operationId: beginStep
summary: Register a loop iteration (enforce loop + time budgets)
description: |
The Python SDK calls this once per agent loop iteration. It increments the
loop counter and enforces the loop and wall-clock budgets. Returns 402 with
the HaltReason when the run is out of budget.
parameters:
- $ref: '#/components/parameters/RunId'
responses:
'200':
description: Step registered.
content:
application/json:
schema:
type: object
properties:
stepIndex:
type: integer
format: int32
'402':
description: Run halted — loop or time budget exceeded.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'

/v1/runs/{id}/checkpoints:
post:
tags: [checkpoints]
operationId: saveCheckpoint
summary: Save a crash-resumable checkpoint
description: |
Persists an opaque payload (e.g. conversation messages, scratch state)
plus a usage snapshot, so a crashed run can resume from here.
parameters:
- $ref: '#/components/parameters/RunId'
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name:
type: string
payload:
type: object
additionalProperties: true
responses:
'201':
description: Checkpoint saved.
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'

/v1/runs/{id}/approvals:
post:
tags: [approvals]
operationId: requestApproval
summary: Request approval for a (possibly side-effecting) tool call
description: |
The SDK's @governed_tool path. If policy allows the call, returns status
"approved" immediately. Otherwise creates a pending approval the caller
polls via GET /v1/approvals/{id} (resolved by a human via
POST /v1/runs/{id}/approve).
parameters:
- $ref: '#/components/parameters/RunId'
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [tool]
properties:
tool:
type: string
sideEffect:
type: string
stepIndex:
type: integer
format: int32
arguments:
type: object
additionalProperties: true
responses:
'200':
description: Allowed by policy — no human approval required.
content:
application/json:
schema:
type: object
properties:
status:
type: string
required:
type: boolean
'201':
description: Approval is pending a human decision.
content:
application/json:
schema:
$ref: '#/components/schemas/ApprovalRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'

/v1/approvals/{id}:
get:
tags: [approvals]
operationId: getApproval
summary: Get a single approval (poll for resolution)
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: The approval and its current status.
content:
application/json:
schema:
$ref: '#/components/schemas/ApprovalRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'

/v1/runs/{id}/cancel:
post:
tags: [runs]
Expand Down
31 changes: 31 additions & 0 deletions internal/approval/gate.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,37 @@ func (g *Gate) Request(ctx context.Context, req Request) (Decision, string, erro
}
}

// Create evaluates policy and, if approval is required, persists a pending
// approval and notifies push channels, returning the record (required=true).
// Unlike Request, it does NOT block — the caller (e.g. the SDK over HTTP) polls
// the approval's status instead. Returns required=false when policy allows the
// call outright.
func (g *Gate) Create(ctx context.Context, req Request) (storage.ApprovalRecord, bool, error) {
if !g.policy.Requires(req.Tool, req.SideEffect) {
return storage.ApprovalRecord{}, false, nil
}
rec := storage.ApprovalRecord{
ID: g.newID(),
RunID: req.RunID,
StepIndex: req.StepIndex,
Tool: req.Tool,
SideEffect: req.SideEffect,
Arguments: req.Arguments,
Status: storage.ApprovalPending,
CreatedAt: g.now(),
}
if g.store != nil {
if err := g.store.CreateApproval(ctx, rec); err != nil {
return storage.ApprovalRecord{}, false, err
}
}
if g.notifier != nil {
g.notifier.Notify(ctx, rec)
}
g.log.Info("approval required", "id", rec.ID, "run", rec.RunID, "tool", rec.Tool, "side_effect", rec.SideEffect)
return rec, true, nil
}

// Resolve records a human decision and wakes any blocked waiter. Returns
// storage.ErrNotFound if the approval is unknown or already resolved.
func (g *Gate) Resolve(ctx context.Context, id string, approved bool, reason, by string) error {
Expand Down
Loading
Loading