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
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,11 @@ Approval creation and consumption append distinct format-versioned lifecycle ent
tenant chain in the exact transaction that mutates the single-use grant. An audit failure therefore
rolls back the approval mutation. Each lifecycle pair carries only a one-way, domain-separated
digest of the immutable grant binding—never raw targets, arguments, or justification content.
New grants use format 3: PostgreSQL mints one immutable absolute expiry exactly 10 minutes after
approval, checks `approved_at <= statement_timestamp() < expires_at` in the same conditional
consumption update, and binds both timestamps into the lifecycle evidence digest. An expired,
legacy, missing, foreign, mismatched, or replayed grant returns the same unavailable result; an
expired refusal retains the row, leaves `consumed_at` unset, and appends no success event.
Both audit routes use the dedicated `export-audit` action and `audit.export` PEP verb. Sith durably
appends an authorization decision before every read. The complete route verifies the head and all
retained history in one forced-RLS Repeatable Read snapshot and remains limited to 512 entries. For
Expand Down Expand Up @@ -515,15 +520,22 @@ migration ledger, creates the tenant-scoped policy-audit chain and exact single-
store, narrows the application role's audit and approval-table privileges, audits forced RLS plus
both immutable-entry contracts, attempts to close its one owner connection, and exits. Approval
rows contain only opaque identifiers, proposer/approver identity, the resolved proposal digest,
and lifecycle timestamps. The application role may insert them and update only `consumed_at`; it
cannot rewrite or delete the approved identity or digest. The migration process never opens the hub
listener, creates a Kubernetes client, or starts collection.
an evidence version, and lifecycle timestamps. The application role may insert them and update only
`consumed_at`; it cannot rewrite or delete the approved identity, digest, approval time, expiry, or
evidence version. The migration process never opens the hub listener, creates a Kubernetes client,
or starts collection.

Migration 0011 preserves defaults for older format-1 audit writers, but older verifiers do not
understand format-2 approval lifecycle entries. During a rolling upgrade, run the migration, upgrade
all verifier-capable hub instances, and only then enable traffic that creates or consumes approvals.
Migration 0012 only extends the retained action constraint with the closed `export-audit` value;
deploy it before exposing the audit-export route so its authorizing decision can be appended.
Migration 0013 backfills legacy approval rows under one transactional access-exclusive owner lock,
immediately restores forced RLS, and marks those rows as legacy so they cannot be consumed by the
new evidence contract. It also enables audit format 3. Run the migration before deploying format-3
writers and upgrade every verifier before enabling approval traffic; older writers fail closed
because the new immutable fields have no permissive defaults. Sith currently exposes no runtime
approval/dispatch path, so this ordering does not interrupt a supported write API.

The normal hub process continues to use only `SITH_HUB_DATABASE_URL` for the non-owner application
role. Do not reuse the migration-owner credential in the hub Deployment or place either database
Expand Down
12 changes: 9 additions & 3 deletions docs/EPICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1699,9 +1699,15 @@ same-workspace, distinct-approver grant bound to the existing immutable resolved
The non-owner application role can insert the forced-RLS row and atomically set only
`consumed_at`; it cannot rewrite or delete the intent, identities, or digest. Missing, foreign,
mismatched, and replayed grants share one fail-closed refusal, and a real PostgreSQL concurrency
test proves exactly one consumer wins. MCP elicitation transport, Ardur PDP policy, approval expiry,
multi-approver counting, credential minting, and dispatch remain later slices; this status does not
claim F5.9 complete.
test proves exactly one consumer wins.

**Implementation status (F5.9b, 2026-07-21).** Every new grant has one immutable 10-minute
absolute lifetime minted from PostgreSQL statement time. Consumption checks the half-open
`approved_at <= consumed_at < expires_at` interval in the same conditional update that spends the
grant. Expiry is bound into versioned lifecycle evidence; legacy grants are retained but fail
closed, and format-1/2 audit records remain independently verifiable beside format 3. MCP
elicitation transport, Ardur PDP policy, multi-approver counting, credential minting, and dispatch
remain later slices; this status does not claim F5.9 complete.

**Key risk / guardrail.** Approve-then-swap (approve a benign action, then change args) is the
classic agent bypass. Guardrail: the approval is bound to an arg-hash re-checked at dispatch, so a
Expand Down
9 changes: 9 additions & 0 deletions docs/SITH-NOTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -1756,6 +1756,15 @@ sequenceDiagram
- Approvals are per-action, single-use, and bound to the resolved-args hash.
- Changing the args after approval invalidates it (approve-then-swap is blocked).

**Implementation status (F5.9a/F5.9b, 2026-07-21).** The durable server-side core persists a
same-workspace, distinct-approver grant bound to the immutable resolved proposal digest and spends
it with one conditional PostgreSQL update. Every new grant has one immutable 10-minute absolute
lifetime minted from PostgreSQL statement time; consumption enforces the half-open
`approved_at <= consumed_at < expires_at` interval in that same update. Expiry is bound into
versioned lifecycle evidence, legacy grants are retained but fail closed, and historical audit
formats remain independently verifiable. MCP transport, Ardur PDP policy, multi-approver counting,
credential minting, and dispatch remain later slices; this does not claim F5.9 complete.

**Key risk / guardrail.** Approve-then-swap (approve a benign action, then change args) is the
classic agent bypass. Guardrail: the approval is bound to an arg-hash re-checked at dispatch, so a
valid signature and a valid approval are both necessary but neither is sufficient if the args
Expand Down
12 changes: 9 additions & 3 deletions docs/adr/0005-ai-mcp-ardur-pdp.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ governance, not the product.**
Verified MCP facts (July 2026):
- Tool annotations (`readOnlyHint`/`destructiveHint`/`idempotentHint`/`openWorldHint`)
shipped in the **2025-03-26** spec and are **hints, not guarantees — enforce server-side**.
- **Elicitation** (a server requesting structured user input mid-flow via `elicitation/create`
+ JSON schema) shipped in the **2025-06-18** spec — the native primitive for
human-in-the-loop approval.
- **Elicitation** (a server requesting structured user input mid-flow via `elicitation/create`)
shipped in the **2025-06-18** spec. The latest published
[**2025-11-25** contract](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation)
adds URL mode;
Sith uses form mode with a constrained schema for non-secret human approval. MCP does not define
the lifetime of the resulting server-side grant, so Sith enforces that boundary independently.

Ardur (ArdurAI's runtime-governance runtime) is purpose-built to be a policy decision point,
identity broker, and decision-ledger for agent actions.
Expand All @@ -29,6 +32,9 @@ identity broker, and decision-ledger for agent actions.
carry `destructiveHint: true` (+ correct `idempotentHint`), and require **Elicitation-based
approval bound to a hash of the resolved args** (the agent cannot approve-then-swap).
`intent.gitops-open-pr` ships first.
- The durable approval is single-use and valid for one immutable absolute 10-minute window.
PostgreSQL statement time mints and consumes it; the same atomic update requires
`approved_at <= consumed_at < expires_at`, and the lifecycle evidence digest binds the expiry.
- **Annotations are hints ⇒ enforcement is server-side.** The MCP layer is a **thin adapter
over the same PEP** the UI uses. There is no privileged agent path: an external agent
(Claude Code, Codex, kagent) gets **exactly** the governance a human does.
Expand Down
44 changes: 25 additions & 19 deletions internal/auditrecord/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ const (
// MaxDocumentBytes bounds one portable JSON document before offline parsing.
MaxDocumentBytes = 1 << 20

policyAuditHashDomain = "sith-policy-audit-chain/v1"
approvalAuditHashDomain = "sith-approval-audit-chain/v2"
policyAuditHashDomain = "sith-policy-audit-chain/v1"
approvalAuditHashDomain = "sith-approval-audit-chain/v2"
approvalExpiryAuditHashDomain = "sith-approval-audit-chain/v3"
)

// Export is one complete, verified workspace snapshot. It is constructed only after the backing
Expand All @@ -52,20 +53,22 @@ type Chain struct {
// Entry is the privacy-minimized, independently rehashable projection of one retained event. The
// workspace is carried once by Export and is nevertheless bound into each entry hash.
type Entry struct {
Sequence int64 `json:"sequence"`
FormatVersion int16 `json:"format_version"`
RecordedAt time.Time `json:"recorded_at"`
TraceID string `json:"trace_id"`
Actor string `json:"actor"`
Role string `json:"role"`
Action string `json:"action"`
Verb string `json:"verb"`
Verdict string `json:"verdict"`
ReasonCode string `json:"reason_code"`
EventKind string `json:"event_kind"`
EvidenceDigest string `json:"evidence_digest"`
PreviousHash string `json:"previous_hash"`
EntryHash string `json:"entry_hash"`
Sequence int64 `json:"sequence"`
FormatVersion int16 `json:"format_version"`
RecordedAt time.Time `json:"recorded_at"`
TraceID string `json:"trace_id"`
Actor string `json:"actor"`
Role string `json:"role"`
Action string `json:"action"`
Verb string `json:"verb"`
Verdict string `json:"verdict"`
ReasonCode string `json:"reason_code"`
EventKind string `json:"event_kind"`
// EvidenceDigest is opaque at this privacy-minimized boundary. Offline verification binds it
// into the versioned chain; the database writer proves its grant-field semantics before append.
EvidenceDigest string `json:"evidence_digest"`
PreviousHash string `json:"previous_hash"`
EntryHash string `json:"entry_hash"`
}

// ValidateForWorkspace rechecks the portable disclosure boundary independently of the backing
Expand Down Expand Up @@ -157,8 +160,11 @@ func RecomputeEntryHash(workspaceID tenancy.WorkspaceID, entry Entry) (string, e
}

domain := policyAuditHashDomain
if entry.FormatVersion == 2 {
switch entry.FormatVersion {
case 2:
domain = approvalAuditHashDomain
case 3:
domain = approvalExpiryAuditHashDomain
}
canonical := make([]byte, 0, 512)
canonical = appendCanonicalString(canonical, domain)
Expand All @@ -172,7 +178,7 @@ func RecomputeEntryHash(workspaceID tenancy.WorkspaceID, entry Entry) (string, e
} {
canonical = appendCanonicalString(canonical, value)
}
if entry.FormatVersion == 2 {
if entry.FormatVersion == 2 || entry.FormatVersion == 3 {
canonical = appendCanonicalString(canonical, entry.EventKind)
canonical = appendCanonicalString(canonical, entry.EvidenceDigest)
}
Expand Down Expand Up @@ -222,7 +228,7 @@ func validEntryShape(entry Entry) bool {
return false
}
return true
case 2:
case 2, 3:
if !validHash(entry.EvidenceDigest) || entry.Verb != "approval.grant" || verdict != pep.VerdictAllow ||
entry.ReasonCode != entry.EventKind {
return false
Expand Down
25 changes: 23 additions & 2 deletions internal/auditrecord/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (
"github.com/ArdurAI/sith/internal/tenancy"
)

const expiringApprovalEvidenceFixture = "sha256:25edbb61ecc55494ed155e14b30733b08ab090469a789b79eed3bf871ddbd1b4"

func TestExportValidateForWorkspaceAcceptsClosedPortableChain(t *testing.T) {
t.Parallel()

Expand All @@ -31,6 +33,11 @@ func TestExportValidateForWorkspaceAcceptsClosedPortableChain(t *testing.T) {
if err := approval.ValidateForWorkspace("workspace-a"); err != nil {
t.Fatalf("approval ValidateForWorkspace() error = %v", err)
}
approval.Entries[0].FormatVersion = 3
approval.Entries[0].EvidenceDigest = expiringApprovalEvidenceFixture
if err := approval.ValidateForWorkspace("workspace-a"); err != nil {
t.Fatalf("expiring approval ValidateForWorkspace() error = %v", err)
}
}

func TestExportValidateForWorkspaceRejectsForeignAndMalformedDocuments(t *testing.T) {
Expand Down Expand Up @@ -95,6 +102,7 @@ func TestRecomputeEntryHashGoldenFormats(t *testing.T) {
}{
{name: "format 1 policy decision", entry: mixed.Entries[0], want: "sha256:67544ba8ac180f834bc221aa136c7d121c0e63228b02bc7c7dce2508de26c4ea"},
{name: "format 2 approval lifecycle", entry: mixed.Entries[1], want: "sha256:dfbfb98dda5768b259314faa5ed57f40ae4575c466e899ad79c23cea06277ece"},
{name: "format 3 expiring approval lifecycle", entry: mixed.Entries[2], want: "sha256:3cd45877bc466f0a61700a8f13b91e1f5b31c2b9ac382032be2410131d4da338"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
Expand Down Expand Up @@ -233,8 +241,21 @@ func validMixedTestExport() Export {
}
second.EntryHash = secondHash
exported.Entries = append(exported.Entries, second)
exported.Chain.HeadSequence = 2
exported.Chain.HeadHash = secondHash
third := Entry{
Sequence: 3, FormatVersion: 3,
RecordedAt: time.Date(2026, time.July, 18, 9, 32, 0, 123456000, time.UTC),
TraceID: strings.Repeat("3", 32), Actor: "user:alice", Role: "operator", Action: "propose-intent",
Verb: "approval.grant", Verdict: "allow", ReasonCode: "approval-consumed",
EventKind: "approval-consumed", EvidenceDigest: expiringApprovalEvidenceFixture, PreviousHash: secondHash,
}
thirdHash, err := RecomputeEntryHash("workspace-a", third)
if err != nil {
panic(err)
}
third.EntryHash = thirdHash
exported.Entries = append(exported.Entries, third)
exported.Chain.HeadSequence = 3
exported.Chain.HeadHash = thirdHash
return exported
}

Expand Down
Loading
Loading