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
61 changes: 58 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,33 @@ permissions:
contents: read
packages: write

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

jobs:
verify:
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version-file: go.mod }
- uses: actions/setup-node@v4
with: { node-version: "24" }
- name: Check release version
run: |
test "v$(node -p 'require("./package.json").version')" = "$GITHUB_REF_NAME"
test "$(go run . --version)" = "draftcat ${GITHUB_REF_NAME#v}"
- run: go test -short -count=1 -timeout 60s ./...
- run: go test -short -tags voice -count=1 -timeout 60s ./...
- run: go run . validate
- run: npm ci --ignore-scripts
- run: npm test
- run: npm pack --dry-run

binaries:
needs: verify
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
strategy:
Expand All @@ -27,15 +52,15 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: "1.25" }
with: { go-version-file: go.mod }
- name: Build native binary
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
run: |
asset="draftcat-${GITHUB_REF_NAME}-${{ matrix.platform }}-${{ matrix.arch }}${{ matrix.extension }}"
go build -trimpath -buildvcs=false -ldflags="-s -w" -o "$asset" .
go build -trimpath -buildvcs=false -ldflags="-s -w -X main.version=${GITHUB_REF_NAME#v}" -o "$asset" .
gzip -n "$asset"
- uses: actions/upload-artifact@v4
with:
Expand All @@ -61,9 +86,15 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
sha256sum draftcat-*.gz > SHA256SUMS
gh release create "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --generate-notes --title "$GITHUB_REF_NAME" draftcat-*.gz SHA256SUMS
if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release upload "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --clobber draftcat-*.gz SHA256SUMS
else
gh release create "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --generate-notes --title "$GITHUB_REF_NAME" draftcat-*.gz SHA256SUMS
fi

image:
if: startsWith(github.ref, 'refs/tags/v')
needs: verify
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -91,3 +122,27 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

npm:
if: startsWith(github.ref, 'refs/tags/v')
needs: release
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
registry-url: https://registry.npmjs.org
- run: npm ci --ignore-scripts
- run: npm test
- name: Smoke-test the published native asset
run: |
node npm/install.js
test "$(node npm/draftcat.js --version)" = "draftcat ${GITHUB_REF_NAME#v}"
- name: Publish npm package
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --access public --provenance
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Changelog

## 0.8.0

- Add durable, pipeline-scoped `Idempotency-Key` webhook retries. Matching bodies return the original admission; changed bodies are rejected.
- Claim signed webhook replay identities atomically, including concurrent requests.
- Upgrade older SQLite stores transactionally before indexing new columns.
- Refuse consume requests when the tool rule, operator channel, allowed reviewers, or approval timeout changed after the decision.
- Reject oversized and unreadable request bodies, unknown tool envelope fields, duplicate JSON keys, and trailing JSON.
- Preserve exact tool argument numbers for approval hashes and compare numeric policy bounds without rounding.
- Persist completed and failed pipeline runs with their run IDs; join approvals by exact identity while preserving legacy history.
- Add `draftcat receipts verify <file.jsonl|-> [--json]` to verify exported signed receipt fields without SQLite.
- Open audit and inspection commands in read-only mode, without creating or migrating databases.

Release packaging includes `--version`, synchronized npm metadata, and a tag workflow that tests before building native assets, publishing the container, and publishing npm after a native installer smoke test.

### Upgrade notes

Start the engine once to migrate an older state store before using read-only audit commands. Existing v1/v2 receipt signatures remain unchanged. Unconsumed permits from v0.7.0 require a new approval because the effective policy binding now includes the operator authorization configuration. Tool request envelopes are stricter and retain numeric token spelling; send the same spelling when retrying an action ID.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ Sometimes a customer, auditor, or partner needs evidence that a human approved a

This is an **experimental cryptographic preview**, not a production compliance claim. It uses an embedded BN254/Groth16 circuit and a development single-party setup; the circuit has not received an independent audit. Use it to evaluate the disclosure model, then replace the setup through a ceremony before relying on it in production. See [zero-knowledge approval proofs](docs/zk-approval-proofs.md) for the trust model, exact statement, and limitations.

> **New in v0.8.0:** webhook retries can carry a durable `Idempotency-Key`, signed replay identities are claimed atomically, and tool permits recheck their policy before execution. Requests reject oversized or ambiguous data and retain exact numbers. Older state stores upgrade safely; completed and failed runs carry exact approval identities. Audit commands read without modifying the database, and `draftcat receipts verify` checks exported JSONL offline. See the [upgrade and reliability guide](docs/reliability.md).
>
> **New in v0.7.0:** execution decisions now carry their proof. Every tool-gate route is authenticated, each request has a stable action identity and exact policy binding, and an allowed decision becomes an atomic consume-once permit before the side effect runs. Webhook acceptance is durable before HTTP 202 and can be polled after handoff. Versioned receipts bind action, payload, policy, and expiry, with `draftcat receipts list|show|export` for verification-ready JSONL. Ordered `model_policy` rules can deny or send matching model input/output to a human, while `/healthz` and `/readyz` give orchestrators a safe listener contract.
>
> **In v0.6.0:** the gate holds under load. The [tool-call gate](docs/tool-gate.md) answers asynchronously (`mode: async`, `wait:`) so a harness with a short HTTP timeout never loses a decision, and a tool call waiting on a human is durable across a restart. Rules constrain arguments (`args:` - glob, regex, `one_of`, `min`/`max`) and never widen on a mismatch. A repeat guard stops an agent that loops on one call from paging you, the operator hears about denials the gate made on its own, `/pending` and `draftcat pending` list every open gate, `/status` shows spend against caps, cost caps enforce the provider's real charge, rate limits back off instead of failing the run - and one Telegram update pump fixes taps that were silently lost while two gates were open at once.
Expand Down Expand Up @@ -138,6 +140,12 @@ draftcat --help

The installer downloads the matching Linux, macOS, or Windows binary and verifies it against the checksums attached to the GitHub release. No Go toolchain is required.

With a Go toolchain, install the CLI from the module:

```bash
go install github.com/renezander030/draftcat@latest
```

Or build from source:

```bash
Expand Down
13 changes: 12 additions & 1 deletion docs/action-receipts.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ decision fields with HMAC-SHA256:
- quorum result
- policy and policy digest
- binding digest
- permit expiry and lifecycle
- permit expiry
- nonce

See [`internal/approval/receipt.go`](../internal/approval/receipt.go). If any
Expand Down Expand Up @@ -107,6 +107,17 @@ Set `DRAFTCAT_APPROVAL_SECRET` while reading to receive `verification: ok` or
`tampered`. Signed rows without the key report `unverified`; unsigned rows
report `unsigned` explicitly.

Verify exported records without opening SQLite:

```bash
draftcat receipts verify receipts.jsonl --json
cat receipts.jsonl | draftcat receipts verify -
```

This requires `DRAFTCAT_APPROVAL_SECRET`, checks each signed v1/v2 field set,
and returns nonzero for unsigned or tampered rows. It does not certify export
completeness or unsigned `lifecycle` metadata. See the [reliability guide](reliability.md).

## Design rule

Do not let the model decide whether the approval boundary was satisfied. The
Expand Down
86 changes: 86 additions & 0 deletions docs/reliability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Reliable requests and audit inspection

## Retry a webhook without repeating the action

Choose one opaque key for one intended pipeline execution and keep it for every retry:

```bash
curl -X POST https://draftcat.example/hooks/invoice-due-diligence \
-H "Authorization: Bearer $DRAFTCAT_WEBHOOK_SECRET" \
-H "Idempotency-Key: invoice-4821" \
-d '{"invoice":"4821"}'
```

The key is scoped to the pipeline and must be 1–128 URL-safe characters (letters,
digits, underscore, hyphen, dot, or colon). SQLite stores its hash and the exact
body hash. Matching retries return HTTP 202 with the original `admission_id`,
current status, and poll path even after completion or restart. A changed body
returns 409. Missing keys retain the previous behavior. Retain the key while the
admission exists; keys do not automatically expire. A retry never resumes an
interrupted admission: inspect it and explicitly choose a fresh key for a new run.

Bearer authentication is checked on every retry. When signatures are enabled,
every retry also needs an authentic signature within the clock-skew window.
A matching, already-admitted retry may reuse the original signature while it is
still timely; it only reads the existing admission. Starting new work claims the
signature atomically. Reusing that signature for another admission is refused.

## Send complete, unambiguous tool requests

Oversized HTTP request bodies return 413 instead of being truncated; unreadable
bodies return 400. `webhook.max_body_bytes` applies to webhook and tool-gate POSTs.
The tool gate also limits direct requests to 1 MiB and consume bodies to 4 KiB.
Unknown tool envelope fields, duplicate keys (including nested arguments), and
trailing JSON, and nesting above 128 levels return 400 before creating or consuming a permit. Webhook payloads
remain arbitrary bytes; the strict JSON envelope rules apply to the tool gate.

Tool argument numbers retain their JSON spelling, including integers above 2^53.
Distinct large integers therefore cannot share a rounded approval hash. Use the
same numeric spelling on an action ID retry: `1`, `1.0`, and `1e0` bind different
representations. Numeric min/max constraints compare exact decimal values and
reject non-finite strings such as `NaN`. YAML numeric bounds keep their existing
float64 configuration type; comparisons retain the supplied argument's exact digits.

## Reapprove after a policy change

Consumption compares the saved policy digest with the current effective tool
policy, including operator-channel settings, permitted reviewers, and the
approval window. A mismatch returns 409 without an execution permit. Request a
new action ID and obtain a fresh approval. This check also applies to permits
loaded after restart. Upgrading from v0.7.0 changes the policy digest, so obtain
fresh approval for any unconsumed permits.

## Upgrade and inspect state

Engine startup migrates older SQLite stores in one transaction. Indexes on new
columns are created after those columns exist, and previous receipt signatures
are retained. Completed and failed pipeline runs are recorded with the run ID
used by their approvals. `draftcat runs --json` includes `run_id` for new runs;
identity-less historical records use the old timestamp join, restricted to
identity-less approvals.

`runs`, `pending`, `receipts list|show|export`, and `audit-verify` open existing
state in read-only mode. A missing path fails without creating a new database.
Start the engine once to migrate an older store before inspecting it. Inspection
can read a live WAL-backed database; it does not run schema migrations.

## Verify exported receipt fields offline

```bash
draftcat receipts export --out receipts.jsonl
draftcat receipts verify receipts.jsonl --json
cat receipts.jsonl | draftcat receipts verify -
```

Set `DRAFTCAT_APPROVAL_SECRET` to the same signing key used by the instance.
The verifier opens no state database and recomputes each v1/v2 signature instead
of trusting the export's `verification` field. Exit 0 means all listed receipts
have valid signatures; unsigned or tampered rows return 1; a missing signing key
or invalid command returns 2. Malformed, oversized, empty, or unsupported-version
input fails. The JSON report includes line number, receipt ID, and verdict.

HMAC verification requires the signing secret. It verifies the fields covered by
the corresponding receipt version; it does not attest that a JSONL file is
complete, prove actual delivery, or authenticate unsigned display metadata such
as `lifecycle`. Keep the secret within the trusted operator boundary. Use
`zk-receipt` for the separate experimental privacy-preserving proof flow.
46 changes: 24 additions & 22 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ package config
import (
"encoding/json"
"fmt"
"math"
"math/big"
"path"
"regexp"
"sort"
Expand Down Expand Up @@ -573,7 +575,7 @@ type ToolRule struct {
}

// ArgConstraint is one condition on one argument. Every non-empty field must
// hold. Values are compared in their string form (numbers without exponent,
// hold. Values are compared in their string form (JSON number spelling is retained,
// booleans as true/false, anything structured as compact JSON) except min/max,
// which need a number.
type ArgConstraint struct {
Expand Down Expand Up @@ -631,14 +633,14 @@ func (c ArgConstraint) Check(v interface{}, present bool) (bool, string) {
}
}
if c.Max != nil || c.Min != nil {
f, ok := argNumber(v)
f, ok := exactArgNumber(v)
if !ok {
return false, fmt.Sprintf("%q is not a number", clip(s))
}
if c.Max != nil && f > *c.Max {
if c.Max != nil && (math.IsNaN(*c.Max) || math.IsInf(*c.Max, 0) || f.Cmp(exactBound(*c.Max)) > 0) {
return false, fmt.Sprintf("%s exceeds max %s", s, strconv.FormatFloat(*c.Max, 'f', -1, 64))
}
if c.Min != nil && f < *c.Min {
if c.Min != nil && (math.IsNaN(*c.Min) || math.IsInf(*c.Min, 0) || f.Cmp(exactBound(*c.Min)) < 0) {
return false, fmt.Sprintf("%s is below min %s", s, strconv.FormatFloat(*c.Min, 'f', -1, 64))
}
}
Expand Down Expand Up @@ -673,25 +675,25 @@ func ArgString(v interface{}) string {
}
}

func argNumber(v interface{}) (float64, bool) {
switch x := v.(type) {
case float64:
return x, true
case float32:
return float64(x), true
case int:
return float64(x), true
case int64:
return float64(x), true
case json.Number:
f, err := x.Float64()
return f, err == nil
case string:
f, err := strconv.ParseFloat(strings.TrimSpace(x), 64)
return f, err == nil
default:
return 0, false
func exactBound(v float64) *big.Rat {
r, _ := new(big.Rat).SetString(strconv.FormatFloat(v, 'g', -1, 64))
return r
}

func exactArgNumber(v interface{}) (*big.Rat, bool) {
s := strings.TrimSpace(ArgString(v))
if i := strings.IndexAny(s, "eE"); i >= 0 {
exponent, err := strconv.Atoi(s[i+1:])
if err != nil || exponent < -4096 || exponent > 4096 {
return nil, false
}
}
// Bound exponent work and reject NaN/Inf while retaining exact decimal digits.
f, err := strconv.ParseFloat(s, 64)
if err != nil || math.IsNaN(f) || math.IsInf(f, 0) {
return nil, false
}
return new(big.Rat).SetString(s)
}

func clip(s string) string {
Expand Down
25 changes: 25 additions & 0 deletions internal/config/precise_numbers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package config

import (
"encoding/json"
"testing"
)

func TestExactNumericConstraintBoundary(t *testing.T) {
max := float64(9007199254740992)
c := ArgConstraint{Max: &max}
for _, v := range []interface{}{json.Number("9007199254740993"), "9007199254740993", "NaN", "Inf", "-Inf", "1e-1000000000"} {
if ok, _ := c.Check(v, true); ok {
t.Errorf("unsafe boundary %v accepted", v)
}
}
if ok, why := c.Check(json.Number("9007199254740992"), true); !ok {
t.Fatalf("exact bound rejected: %s", why)
}
min := 1.1
max = 1.1
c = ArgConstraint{Min: &min, Max: &max}
if ok, why := c.Check(json.Number("1.1"), true); !ok {
t.Fatalf("decimal bound rejected: %s", why)
}
}
Loading
Loading