Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
35f4de7
Add slot occupancy and rebind declarations to the AgentClass API
josephschorr Oct 2, 2026
27db9e0
Enforce single-occupancy slot pinning with atomic SpiceDB preconditions
josephschorr Oct 2, 2026
d13e176
Carry slot occupancy onto every binding and move pins only by approve…
josephschorr Oct 2, 2026
91ff120
Mirror slot pins onto session status and surface refusals to the model
josephschorr Oct 2, 2026
73a8e64
Sweep stale slot tuples before mint-time binds and notice pin refusals
josephschorr Oct 2, 2026
d0be00c
Manage the pin lifecycle: fork copy, teardown delete, admission sweep
josephschorr Oct 2, 2026
ae30e35
Prove slot pinning end to end against real SpiceDB
josephschorr Oct 2, 2026
97e6c39
Document slot pinning and correct the fill-once claims
josephschorr Oct 2, 2026
28fc672
Add private durable goals and management tools
josephschorr Oct 5, 2026
a4da691
Authorize and dispatch bounded private goal sessions
josephschorr Oct 5, 2026
03032ee
Retain goal results, delivery receipts and conversation history
josephschorr Oct 5, 2026
1f66d9c
Retain bounded goal accounting and reported tool costs
josephschorr Oct 5, 2026
3b88443
Forward local service connections to ready running pods
josephschorr Oct 5, 2026
6b44fb6
Include reminder consent in plans and alert on new goal sessions
josephschorr Oct 5, 2026
748e691
Add finite recurring schedules and timezone-aware quiet hours
josephschorr Oct 5, 2026
9c06050
Derive per-run plan approval from finite private delivery consent
josephschorr Oct 5, 2026
a7faf88
Retain verified observations and finite event subscriptions
josephschorr Oct 5, 2026
0cf2c2a
Launch event-driven goals and separately approved monitoring proposals
josephschorr Oct 5, 2026
984043f
Fence goal authority and recover exact approval outcomes
josephschorr Oct 5, 2026
32f323c
Guide goal setup with bounded defaults and discoverable event feeds
josephschorr Oct 5, 2026
19c1035
Publish private observations from scheduled goal results
josephschorr Oct 5, 2026
05242e4
Format goals documentation with the pinned formatter
josephschorr Oct 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
25 changes: 25 additions & 0 deletions PRIMITIVES.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,31 @@ knowledge-graph layer and durable artifacts.
`authzdecision/`, `scopeaudit/`, `relwritesaudit/`, `toolguardaudit/`,
`toolchainaudit/`, `contentguardaudit/`, `metaagentaudit/`,
`infoleakage{audit,decision,taint}/`
- **Session schedules:** `pkg/agent/sessionschedule/` resolves bounded one-time,
interval, daily, and weekly schedules with timezone-aware quiet hours. It is
independent of goals and grants no execution authority. Goals currently supply
the approval, durable run ledger, and session activation adapter.
- **Event sessions:** `pkg/agent/sessionevents/` and `pkg/memory/sessionevents/`
retain verified events and finite watches, with quiet hours, duplicate
suppression, and current source access. Registered consumers decide what a
watch can do; an event itself grants no authority.
- **Private durable goals:** `pkg/agent/goals/` (lifecycle and ownership),
`pkg/memory/goals/` (transactional SQLite/PostgreSQL stores), `pkg/web/goals/`
(authenticated API and signed outbox),
`pkg/agent/tool/meta/capability/goals.go` (opt-in tools), and
`cmd/oap/internal/goalscmd/` (CLI). See `docs/operating/goals.md`.
`pkg/controllers/goals/` activates bounded private report sessions only after
signed human consent and an expiring execution grant. Active state alone
records intent; each session requires a fresh enforcing plan. Explicit finite
standing consent can authorize its private-delivery phase without another
human decision; the signed phase record names the original consent and run.
Scheduled collectors can publish explicitly approved private observations from
immutable run results. A registered source adapter retains source
dependencies; a durable outbox retries publication independently of runner
cleanup. These records authenticate the agent report, not independent external
facts. Separately approved discovery policies can suggest monitoring goals.
Each proposal needs exact human acceptance before a goal or watch exists;
finite question limits, expiry, and source access constrain those suggestions.
- **Authorization of recall:** `pkg/memory/spicedbauthorizer/`
- **Artifacts:** `pkg/platform/artifacts/` (versioning, labels, resolve),
`pkg/platform/artifactstore/` (the `Store` interface + `blob/`, a gocloud.dev
Expand Down
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,9 @@ places the decision outside the model.
text can change what the model wants to do. It cannot change what the model is
allowed to do.
- **Slots.** A session commits to the specific resource it is working on, as a
relationship that can be written only once. It cannot drift to another
resource even when its credentials would reach one.
pinned relationship. A different resource is refused unless a human approves a
plan amendment that moves the pin and revokes the old resource's access — and
a slot set `rebind: never` admits no move at all.
- **Approvals the model cannot reword.** The platform generates the description
of the action from the call itself. The model contributes only its reasoning,
so it cannot restate the action in different terms.
Expand Down Expand Up @@ -367,10 +368,11 @@ For architecture and implementation:
## Defense in depth, enforced by code

To misuse an OAP agent, an attacker has to get past a plan a human approved, a
slot that cannot be reopened, a tool lens that cannot be widened, an
authorization check on every call, and a sandbox that never held the credential
in the first place. None of them is sufficient alone, and each is built on the
assumption that the others may fail.
slot that cannot be silently reopened — a different instance needs a human's
approval, and `rebind: never` needs a new session — a tool lens that cannot be
widened, an authorization check on every call, and a sandbox that never held the
credential in the first place. None of them is sufficient alone, and each is
built on the assumption that the others may fail.

## Contributing

Expand Down
4 changes: 4 additions & 0 deletions cmd/oap/internal/auditcmd/truncation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,7 @@ func TestRequireCompleteAuditRead(t *testing.T) {
assert.NoError(t, requireCompleteAuditRead(memory.QueryResult{}))
})
}

func TestRequireCompleteAuditReadRejectsPartialExport(t *testing.T) {
require.ErrorContains(t, requireCompleteAuditRead(memory.QueryResult{Partial: true}), "incomplete audit export")
}
14 changes: 8 additions & 6 deletions cmd/oap/internal/auditcmd/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ publisher's recorded chain head cannot be decoded — that head is what makes
tail truncation detectable at all, so an undecodable one leaves the tail
unverified. Unsigned entries (pre-provenance or written by an unsigned
writer) are reported as a warning and do NOT, on their own, fail
verification.`,
verification. Requires administrative access to export the complete audit evidence.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runAuditVerify(cmd, g, args[0], asJSON)
Expand All @@ -79,7 +79,7 @@ func runAuditVerify(cmd *cobra.Command, g *apcmd.Globals, sessionName string, as
if err != nil {
return err
}
conn, err := memclient.Connect(ctx, b, sessionName, "")
conn, err := memclient.ConnectAudit(ctx, b, sessionName)
if err != nil {
return err
}
Expand All @@ -93,10 +93,9 @@ func runAuditVerify(cmd *cobra.Command, g *apcmd.Globals, sessionName string, as
}
keys := buildKeySet(ctx, b, &sess, errOut)

// Every append-only entry in the scope. Empty Kinds = all kinds; we
// filter to append-only locally (kinds/all blank-import makes
// KindAppendOnly authoritative here).
res, err := conn.Client.Query(ctx, memory.Query{Scope: conn.Scope, Limit: auditReadLimit})
// Administrative export includes platform-only links that a session read
// would omit. Still filter locally to keep the verifier authoritative.
res, err := conn.Client.QueryAudit(ctx, conn.Scope)
if err != nil {
return fmt.Errorf("query memory: %w", err)
}
Expand Down Expand Up @@ -155,6 +154,9 @@ var errAuditTruncated = errors.New("audit verification failed: the session holds
// the reader has no flag to raise, so the only honest answer is to say the
// verification could not be performed.
func requireCompleteAuditRead(res memory.QueryResult) error {
if res.Partial || len(res.DroppedPredicates) != 0 {
return fmt.Errorf("audit verification failed: incomplete audit export")
}
if !res.Truncated {
return nil
}
Expand Down
131 changes: 131 additions & 0 deletions cmd/oap/internal/goalscmd/root.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Package goalscmd implements session-authenticated management of private goals.
package goalscmd

import (
"encoding/json"
"fmt"
"time"

"github.com/authzed/openagentprimitives/cmd/oap/internal/apcmd"
"github.com/authzed/openagentprimitives/cmd/oap/internal/memclient"
"github.com/authzed/openagentprimitives/pkg/agent/goals"
"github.com/spf13/cobra"
)

// NewCmd builds the oap goals subtree. Mutation request IDs are explicit so a
// retry after a connection failure can return the original accepted revision.
func NewCmd(g *apcmd.Globals) *cobra.Command {
root := &cobra.Command{Use: "goals", Short: "Manage private goals for a session's owner and agent class", Long: "Manage durable goals using an authenticated, non-delegated session. Execution requires separate bounded human consent. Responses are JSON."}
for _, op := range []string{"list", "get", "runs", "create", "update"} {
root.AddCommand(newOperation(g, op))
}
return root
}

func newOperation(g *apcmd.Globals, op string) *cobra.Command {
var req goals.Request
req.Operation = op
var due, timezone, title, outcome string
use := op + " <session>"
argc := 1
if op == "get" || op == "runs" || op == "update" {
use += " <goal-id>"
argc = 2
}
short := op + " goals"
if op == "runs" {
short = "List execution history for a goal"
}
cmd := &cobra.Command{Use: use, Short: short, Args: cobra.ExactArgs(argc)}
f := cmd.Flags()
switch op {
case "list", "runs":
if op == "list" {
f.StringVar((*string)(&req.List.State), "state", "", "Filter by state")
}
f.StringVar(&req.List.After, "after", "", "Continue from the previous response's next cursor")
f.IntVar(&req.List.Limit, "limit", 50, "Page size (1–100)")
case "create", "update":
f.StringVar(&title, "title", "", "Short goal title")
f.StringVar(&outcome, "outcome", "", "Desired outcome")
f.StringVar(&due, "due-at", "", "Intent only: RFC3339 due timestamp")
f.StringVar(&timezone, "timezone", "", "IANA timezone")
if op == "create" {
f.StringVar(&req.Create.RequestID, "request-id", "", "Stable idempotency key; reuse for identical retries")
} else {
f.StringVar(&req.Change.RequestID, "request-id", "", "Stable idempotency key; reuse for identical retries")
f.Int64Var(&req.Change.Revision, "revision", 0, "Expected current revision (required)")
f.StringVar(&req.Change.Action, "action", "revise", "revise, activate, pause, resume, cancel, or complete")
f.BoolVar(&req.Change.ClearDue, "clear-due", false, "Remove due timestamp")
req.Change.Result = &goals.Result{}
f.StringVar(&req.Change.Result.Summary, "summary", "", "Reported completion summary")
f.StringSliceVar(&req.Change.Result.Evidence, "evidence", nil, "Completion evidence references")
}
}
cmd.RunE = func(cmd *cobra.Command, args []string) error {
if argc == 2 {
req.ID = args[1]
req.Change.ID = args[1]
}
var dueAt *time.Time
if due != "" {
t, err := time.Parse(time.RFC3339, due)
if err != nil {
return fmt.Errorf("--due-at: %w", err)
}
dueAt = &t
}
if op == "create" {
if req.Create.RequestID == "" || title == "" || outcome == "" {
return fmt.Errorf("--request-id, --title, and --outcome are required")
}
req.Create.Title = title
req.Create.Outcome = outcome
req.Create.DueAt = dueAt
req.Create.Timezone = timezone
}
if op == "update" {
if req.Change.RequestID == "" || req.Change.Revision < 1 {
return fmt.Errorf("--request-id and --revision are required")
}
if f.Changed("title") {
req.Change.Title = &title
}
if f.Changed("outcome") {
req.Change.Outcome = &outcome
}
if f.Changed("timezone") {
req.Change.Timezone = &timezone
}
req.Change.DueAt = dueAt
if !f.Changed("summary") && !f.Changed("evidence") {
req.Change.Result = nil
}
}
b, err := g.Bundle()
if err != nil {
return err
}
conn, err := memclient.Connect(cmd.Context(), b, args[0], "")
if err != nil {
return err
}
defer conn.Close()
if op == "create" || op == "update" {
// Resolve the domain through the server; local flags never select an owner.
res, err := conn.Client.Goals(cmd.Context(), b.Namespace, args[0], goals.Request{Operation: "list", List: goals.ListRequest{Limit: 1}})
if err != nil {
return err
}
req.Resource = res.Resource
}
res, err := conn.Client.Goals(cmd.Context(), b.Namespace, args[0], req)
if err != nil {
return err
}
enc := json.NewEncoder(cmd.OutOrStdout())
enc.SetIndent("", " ")
return enc.Encode(res)
}
return cmd
}
22 changes: 22 additions & 0 deletions cmd/oap/internal/goalscmd/root_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package goalscmd

import (
"testing"

"github.com/authzed/openagentprimitives/cmd/oap/internal/apcmd"
"github.com/stretchr/testify/require"
)

func TestInvalidGoalCLIRequestRefusedBeforeConnecting(t *testing.T) {
for _, args := range [][]string{
{"create", "session", "--title", "Agenda", "--outcome", "Prepare"},
{"update", "session", "goal-id", "--request-id", "retry"},
{"create", "session", "--request-id", "retry", "--title", "Agenda", "--outcome", "Prepare", "--due-at", "tomorrow"},
} {
cmd := NewCmd(&apcmd.Globals{})
cmd.SetArgs(args)
err := cmd.Execute()
require.Error(t, err)
require.NotContains(t, err.Error(), "kubeconfig")
}
}
33 changes: 18 additions & 15 deletions cmd/oap/internal/installcmd/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -3164,22 +3164,24 @@ func ensureWebhookTLSWithClient(ctx context.Context, c client.Client) ([]byte, e
// silently skipped rather than failing install — the patch will succeed once
// the manifest bundle is applied.
func patchWebhookVWCBundle(ctx context.Context, c client.Client, caPEM []byte) error {
var vwc admissionregistrationv1.ValidatingWebhookConfiguration
if err := c.Get(ctx, types.NamespacedName{Name: "spicebox-settings"}, &vwc); err != nil {
if apierrors.IsNotFound(err) {
// VWC not yet created (Task 6 lands it in a later unit). Skip the
// caBundle patch — install must not fail before the manifest bundle
// includes the VWC. The patch will succeed on the next oap install
// after the manifest is applied.
return nil
for _, configurationName := range []string{"spicebox-settings", "spicebox-goal-execution"} {
var vwc admissionregistrationv1.ValidatingWebhookConfiguration
if err := c.Get(ctx, types.NamespacedName{Name: configurationName}, &vwc); err != nil {
if apierrors.IsNotFound(err) {
// VWC not yet created (Task 6 lands it in a later unit). Skip the
// caBundle patch — install must not fail before the manifest bundle
// includes the VWC. The patch will succeed on the next oap install
// after the manifest is applied.
continue
}
return fmt.Errorf("get ValidatingWebhookConfiguration %s: %w", configurationName, err)
}
for i := range vwc.Webhooks {
vwc.Webhooks[i].ClientConfig.CABundle = caPEM
}
if err := c.Update(ctx, &vwc); err != nil {
return fmt.Errorf("update ValidatingWebhookConfiguration %s caBundle: %w", configurationName, err)
}
return fmt.Errorf("get ValidatingWebhookConfiguration spicebox-settings: %w", err)
}
for i := range vwc.Webhooks {
vwc.Webhooks[i].ClientConfig.CABundle = caPEM
}
if err := c.Update(ctx, &vwc); err != nil {
return fmt.Errorf("update ValidatingWebhookConfiguration spicebox-settings caBundle: %w", err)
}
return nil
}
Expand Down Expand Up @@ -3237,6 +3239,7 @@ var webdNATSGrant = apnats.UserGrant{
"ap.session.*.*.in.view_message",
"ap.session.*.*.in.interrupt_request",
"ap.session.*.*.in.interaction_decision",
"ap.component.interaction_decision.*.*",
"ap.session.*.*.in.resurface_request",
"ap.session.*.*.in.app_tool_call",
// ui_data_binding is the agent-UI's own data path: pkg/web/webui/agentui's
Expand Down
1 change: 1 addition & 0 deletions cmd/oap/internal/installcmd/nats_grant_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ func TestWebdNATSGrantPublishSurface(t *testing.T) {

// The inbound subjects webd genuinely publishes.
permitted := map[string]string{
"ap.component.interaction_decision.ns1.sess1": "client-hosted verified human decisions outside runner grants",
"ap.session.ns1.sess1.in.view_message": "chat SubmitUserMessage + /interact user_message|annotation_batch|mcp_ui_action → RequestViewMessage",
"ap.session.ns1.sess1.in.interrupt_request": "chat SubmitInterrupt → clienthosted.SubmitInterrupt",
"ap.session.ns1.sess1.in.interaction_decision": "chat SubmitDecision → clienthosted.SubmitInteractionDecision (the browser approve/deny button)",
Expand Down
24 changes: 23 additions & 1 deletion cmd/oap/internal/memclient/memclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,31 @@ func (c *Conn) Close() {
// BaseURL is the port-forwarded operator base URL (no trailing slash).
func (c *Conn) BaseURL() string { return c.pf.URL() }

// Token is the per-session memory bearer token.
// Token is the bearer used by this connection.
func (c *Conn) Token() string { return c.token }

// ConnectAudit uses the operator's administrative credential for a complete
// audit export. Reading the Secret requires Kubernetes administrative access;
// no agent/session credential is elevated or reused.
func ConnectAudit(ctx context.Context, b *kube.Bundle, sessionName string) (*Conn, error) {
var sec corev1.Secret
if err := b.Controller.Get(ctx, client.ObjectKey{Namespace: "agentprimitives-system", Name: "spicebox-operator-debug-token"}, &sec); err != nil {
return nil, fmt.Errorf("read administrative audit credential: %w", err)
}
token := string(sec.Data["token"])
if token == "" {
return nil, fmt.Errorf("administrative audit credential is empty")
}
pf, err := portforward.New(b.REST, "agentprimitives-system", "spicebox-operator", "app.kubernetes.io/name=spicebox-operator", 8082, 0)
if err != nil {
return nil, err
}
if err := pf.Start(ctx, io.Discard); err != nil {
return nil, fmt.Errorf("audit port-forward: %w", err)
}
return &Conn{Client: httpclient.New(pf.URL(), token), Scope: memory.Scope{Kind: "session", ID: b.Namespace + "/" + sessionName}, pf: pf, token: token}, nil
}

// Connect resolves the memory bearer token from the session's K8s Secret,
// opens a port-forward to the operator, and returns a connected Conn.
// backend may be "" (default), "primary", or "secondary" to override
Expand Down
23 changes: 22 additions & 1 deletion cmd/oap/internal/portforward/portforward.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"net/http"
"net/url"

corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
Expand Down Expand Up @@ -83,7 +84,10 @@ func (p *PortForwarder) Start(ctx context.Context, out io.Writer) error {
if len(pods.Items) == 0 {
return fmt.Errorf("no pods in %s matching %q (service %q)", p.namespace, p.selector, p.service)
}
pod := pods.Items[0]
pod := readyPod(pods.Items)
if pod == nil {
return fmt.Errorf("no ready running pods in %s matching %q (service %q)", p.namespace, p.selector, p.service)
}

roundTripper, upgrader, err := spdy.RoundTripperFor(p.cfg)
if err != nil {
Expand Down Expand Up @@ -139,6 +143,23 @@ func (p *PortForwarder) Start(ctx context.Context, out io.Writer) error {
return nil
}

// Retained evicted pods and terminating rollout replicas can precede the live
// service pod in a list. Forward only to a replica that can serve requests.
func readyPod(pods []corev1.Pod) *corev1.Pod {
for i := range pods {
pod := &pods[i]
if pod.DeletionTimestamp != nil || pod.Status.Phase != corev1.PodRunning {
continue
}
for _, condition := range pod.Status.Conditions {
if condition.Type == corev1.PodReady && condition.Status == corev1.ConditionTrue {
return pod
}
}
}
return nil
}

// URL returns the localhost HTTP URL for the forwarded port. Convenience for
// HTTP callers — gRPC callers should read LocalPort and compose their own
// dial string.
Expand Down
Loading
Loading