Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
199c88b
#23 implemented boiler plate for merkle tree
SaifRehman May 16, 2026
578be2d
#23 implemented boiler plate for merkle tree
SaifRehman May 16, 2026
aeaf736
#24 sql persistance layer
SaifRehman May 16, 2026
07fb1e8
#26 Implemented SignEntry / VerifyEntry / EntryHash in internal/ledge…
SaifRehman May 16, 2026
cfdc49e
#27 ledger pipeline implm
SaifRehman May 16, 2026
7371ff2
#28 implemented gossip-receive pipeline
SaifRehman May 16, 2026
f9c8f14
#29 cli implemented wit =h new features
SaifRehman May 16, 2026
77a5cef
test: red end-to-end trust propagation (relay archive + boss catch-up…
SaifRehman May 17, 2026
b4d4904
fix(reputation): self-seed uses base58 peer-id so boss's own ratings …
SaifRehman May 17, 2026
48143a4
feat(api): expose visibility fields on /api/workers and /v1/models (i…
SaifRehman May 17, 2026
b0549dc
feat(api): /v1/peers/{id}/log with pagination + rater_status; shared …
SaifRehman May 17, 2026
0fb2800
feat(api): GET /v1/peers/{id} single-peer summary endpoint
SaifRehman May 17, 2026
e480267
feat(api): GET /v1/peers/{id}/comments/{cid} hydrates the comment body
SaifRehman May 17, 2026
08f3907
fix(api): correct pagination cap and uncapped-scan semantics in peer_…
SaifRehman May 18, 2026
6d2f9c7
refactor(api): peer_view self-contained; clearer /log pagination fiel…
SaifRehman May 18, 2026
35fece3
feat(reputation): hourly aggregate +/-0.1 ratings on dispatch outcome…
SaifRehman May 18, 2026
6fe5fc0
feat(trust): TUI feedback writes signed Comment + optional Rating to …
SaifRehman May 18, 2026
370d7bc
feat(api): /api/execute body accepts optional feedback + numeric rating
SaifRehman May 18, 2026
0401594
refactor(worker): remove ephemeral FeedbackProtocol stream and plaint…
SaifRehman May 18, 2026
447413c
fix(trust): reject NaN/Inf rating values in TUI feedback prompt
SaifRehman May 18, 2026
0f38334
feat(relay): open archive ledger; auto-archives gossipped entries; au…
SaifRehman May 18, 2026
f6d8919
feat(trust): boss auto catch-up from relay archive on startup; verifi…
SaifRehman May 18, 2026
b68e496
feat(store): DistinctSubjects query for offline-peer visibility
SaifRehman May 18, 2026
0ca9c73
feat(trust): ListKnownPeers includes offline peers from inbox + own log
SaifRehman May 18, 2026
7630418
feat(api): /api/workers?include_offline=true surfaces offline peers
SaifRehman May 18, 2026
4b5f3c8
feat(tui): post-selection menu (Execute / View ratings & feedback / B…
SaifRehman May 18, 2026
c04b448
feat(tui): peer-view screen with rating + comment history + [unverifi…
SaifRehman May 18, 2026
55c95d1
feat(tui): radar renders ONLINE + OFFLINE sections; ENTER works on an…
SaifRehman May 18, 2026
456cfd9
fix(reputation): per-rater EigenTrust normalization (Sybil + spam res…
SaifRehman May 18, 2026
1369b5d
feat(trust): two-check dispatch gate (equivocator + reputation floor)…
SaifRehman May 18, 2026
fe90fe7
chore: delete internal/boss/attest.go + internal/trustedagents/ (repl…
SaifRehman May 18, 2026
29b8180
chore: delete internal/rekor (zero importers; Sigstore is not a fit f…
SaifRehman May 18, 2026
0c85645
chore: remove FeedbackProtocol constant; finalize E2E test helpers; T…
SaifRehman May 18, 2026
2c2c4ea
feat(sdk): client.peers namespace (list/get/log/comment_body) mirrori…
SaifRehman May 18, 2026
dea3de0
docs(v1.3.1): trust layer is reputation-driven; remove allow-list lan…
SaifRehman May 18, 2026
239a183
fix(trust): --reputation-floor=0 no longer silently becomes allow-all
SaifRehman May 18, 2026
a352aba
chore: delete witness server (zero production callers in v1.3.1)
SaifRehman May 18, 2026
c7874c0
chore: delete witness gather plumbing (inert in v1.3.1)
SaifRehman May 18, 2026
069be4c
chore: delete witness client + pb (inert in v1.3.1; no remaining impo…
SaifRehman May 18, 2026
1118f0e
v1.3 implementation
SaifRehman May 18, 2026
0b415bf
Potential fix for pull request finding 'Unused import'
SaifRehman May 18, 2026
94ddabd
Potential fix for pull request finding 'Unused import'
SaifRehman May 18, 2026
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
88 changes: 88 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Changelog

## [1.3.0] — Verifiable Agent Mesh — unreleased

The v1.3 release adds a tamper-evident reputation ledger plus runtime verification so dishonest agents can be caught and ejected without a blockchain.

### Added

**Trust substrate (Phase 1 — ledger spine)**

- New `internal/ledger/` package with a per-peer signed Merkle log over SQLite (modernc.org/sqlite, pure-Go, no CGO).
- RFC 6962 Merkle tree primitive with leaf/node domain separation, inclusion proofs, and consistency proofs.
- Append-only enforcement at BOTH the Go API layer and the database trigger layer.
- Ed25519 signing over `SHA-256(canonical_bytes)` for cross-language verifier compatibility.
- Gossip publish/subscribe on `agentfm-feedback-v1` GossipSub topic.
- Inbox with chain-extension validation, orphan queuing + BFS promotion, replay dedup, signature verification, and post-sig range validation (rejects NaN/Inf/out-of-range scores).
- `agentfm reputation show <peer_id>` CLI subcommand for casual auditing.
- New CLI flags: `--witness`, `--witness-threshold`, `--witness-set`, `--capability`, `--attestation-mode`, `--reject-unknown-images`, `--trusted-agents`.

**Witnesses (Phase 2)**

- New `internal/witness/` package implementing the `/agentfm/witness/1.0.0` co-sign protocol.
- Witnesses store the last LogHead per peer and refuse to co-sign non-extending heads.
- M-of-N gather configuration in the ledger; per-witness consistency proofs prevent fork attacks at non-overlapping tree sizes.
- `EquivocationAlert` envelope gossiped on `agentfm-equivocation-v1`; offenders pinned to `-1.0` permanently after end-to-end alert validation (offender sig + conflict check).
- New `/agentfm/ledger-fetch/1.0.0` stream protocol for pulling entries from another peer's log.
- `Ledger.Prove(entryHash)` returns full RFC 6962 inclusion proofs; standalone `VerifyInclusionProof` for offline validation.

**Agent verification (Phase 3 — L1 only in v1.3)**

- Telemetry envelope extended with `agent_image_digest`, `agent_image_ref`, `agent_capability`.
- Curated `manifests/trusted-agents.json` registry of recognised agent images.
- L1 commitment check at dispatch with three modes (`off` / `warn` / `strict`); mismatch writes a `-0.5` rating to the ledger; strict mode refuses dispatch with `403 agent_attestation_failed`.
- Equivocator gate: dispatch always refuses peers marked equivocator (regardless of attestation mode).

**API + SDK + UI (Phase 4)**

- New HTTP endpoints on the Boss gateway:
- `GET /v1/peers/{id}/reputation`
- `GET /v1/peers/{id}/log`
- `GET /v1/peers/{id}/proof?entry={hex}`
- `POST /v1/peers/{id}/comments` (signed submission; v1.3 self-only)
- Content-addressed comment body storage at `~/.agentfm/comments/` with 10 KiB cap, fetchable on demand via `/agentfm/comment-fetch/1.0.0`.
- Python SDK adds `client.reputation.get/log/proof/comment` namespace.
- Web UI viewer at `/ui/peer/{peer_id}` (single static HTML page, no framework, auto-refreshes every 10s).

**Reputation engine + release polish (Phase 5)**

- EigenTrust-lite scoring in `internal/reputation/`: rater vote weight = rater's own current score, 30-day half-life age decay, configurable mixing factor, ejection at `-0.5` with `0.2` hysteresis.
- Curated `manifests/genesis-seeds.json` (bundled into the binary as a fallback).
- Optional Sigstore Rekor anchoring (`--rekor-anchor`) via the new `internal/rekor/` package — Stub anchor ships in v1.3; real Rekor v2 REST client lands in v1.3.1.
- New `docs/trust.md` (~2000 words) covering the threat model, defenses, trust assumptions, manual verification, and non-goals.

### Changed

- `internal/types/types.go::WorkerProfile` adds `IsWitness`, `AgentImageDigest`, `AgentImageRef`, `AgentCapability` (all `omitempty` for backward compatibility).
- `internal/boss.New(node)` retained as before; new `NewWithOptions(node, Options{...})` constructor for callers that want to wire the ledger, attestation registry, and comment store.
- `pb.InclusionProof.Entry` now carries the full `SignedEntry` wrapper instead of an inner-only `oneof{Rating|Comment}` — future SignedEntry-level fields survive proof round-trip without silent verification failures.

### Security

- Strict equivocation-alert validation prevents a rogue witness from forging brands against innocent peers (audit fix).
- Witness extension check requires a valid RFC 6962 consistency proof; closes the fork-at-non-overlapping-size hole (audit fix).
- Post-signature payload validation in the inbox rejects NaN/Inf scores and other out-of-range values (audit fix).
- Length-prefixed protocol framing has hard size caps (1 MiB witness frames, 10 KiB comment bodies, 1000 entry fetch batches, 20 GB image cache LRU).

### Not in this release (deferred)

- Salt-challenge over container image layers (P3-4) — deferred to v1.3.1.
- Golden-prompt probe coordinator (P3-5) — deferred to v1.4.
- Multi-strategy probe grader (P3-6, including LLM-judge / code-execute) — deferred to v1.4.
- External-submitter signed comments (P4-3 delegation) — deferred to v1.4.
- TEE attestation tier (L4) — deferred to v1.4 as an opt-in premium tier.
- Privacy / encrypted ratings — deferred to v1.4.
- Cross-mesh reputation portability — v1.5 problem.
- Real Sigstore Rekor v2 REST client (Stub anchor only in v1.3) — v1.3.1.

### Hard project constraints (intentional non-goals)

- No blockchain, tokens, staking, or on-chain governance.
- No zkML or cryptographic proof of inference correctness.
- No sybil IMMUNITY (the design only provides sybil RESISTANCE via EigenTrust + curated seeds).

---

## [1.2.0] — 2026-XX-XX

Previous releases — see git history.
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,11 @@ A peer-to-peer compute grid that turns idle hardware into a decentralized AI sup

**Three roles:** a *Worker* runs your agent in a Podman sandbox; a *Boss* orchestrates and dispatches tasks (TUI or HTTP gateway); a *Relay* helps peers discover each other and punch through NAT. All you need to start is a laptop with Podman.

**Two things make it interesting:**
**Three things make it interesting:**

1. **OpenAI-compatible** — point any OpenAI SDK at your local mesh and it just works.
2. **Hardware-aware** — workers broadcast live CPU / GPU / queue state; the matcher picks the least-loaded peer for every request.
3. **Reputation-driven trust mesh (v1.3.1)** — every rating is a signed receipt on a tamper-evident Merkle log; bosses earn worker reputation through hourly aggregate outcomes; equivocators are caught by witnesses and floored at `-1.0` permanently; bad actors auto-reject below `-0.5` honesty. No allow-lists, no central authority, no blockchain. See [Trust & Verification](docs/trust.md).

---

Expand Down Expand Up @@ -81,6 +82,35 @@ That's it. Files the agent drops into `/tmp/output` get zipped and shipped back

---

## Join the Public Mesh in 30 seconds (v1.3.1)

The public mesh has **no allow-list**. Push your agent image anywhere, point a worker at the public lighthouse, and you're in. Reputation accumulates from honest behaviour over time.

```bash
# 1. Build + push your image to any registry.
podman build -t ghcr.io/yourorg/myagent:v1 ./my-agent
podman push ghcr.io/yourorg/myagent:v1

# 2. Run a worker. It joins the mesh immediately —
# no PR, no maintainer review, no allow-list.
agentfm -mode worker \
-agentdir ./my-agent \
-image ghcr.io/yourorg/myagent:v1 \
-agent "My Agent" \
-capability "research-assistant" \
-model "llama3.2"

# 3. Watch your reputation accumulate via the boss-side TUI:
# arrow to your worker → ENTER → "View ratings & feedback"
agentfm -mode boss
```

v1.3.1 uses reputation-driven trust by default. Operators can tighten the dispatch gate via `--reputation-floor=-0.3` (stricter than the default `-0.5`) or effectively disable it via `--reputation-floor=-1.0`. No allow-list file, no maintainer review required. See [Trust & Verification](docs/trust.md) for the full model.

Want full network isolation? That's the [private-swarm](docs/private-swarms.md) path — same binary, `--swarmkey` plus your own `--genesis-seeds` files.

---

## Python SDK

```bash
Expand Down
Binary file not shown.
6 changes: 3 additions & 3 deletions agentfm-go/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
APP_NAME := agentfm
RELAY_NAME := relay
RELAY_INSTALL_NAME := agentfm-relay
VERSION := 1.2.0
VERSION := 1.3.0-rc.1
RELEASE_TAG := AgentFM
MAIN_FILE := ./cmd/agentfm
RELAY_MAIN_FILE := ./cmd/relay
Expand Down Expand Up @@ -88,13 +88,13 @@ endif
build-agentfm:
@echo "🔨 Building native $(APP_NAME) binary for $(OS_NAME) ($(HOST_ARCH))..."
@go mod tidy
@go build -ldflags "-s -w -X main.Version=$(VERSION)" -o $(APP_NAME) $(MAIN_FILE)
@go build -ldflags "-s -w -X agentfm/internal/version.AppVersion=$(VERSION)" -o $(APP_NAME) $(MAIN_FILE)
@echo "✅ $(APP_NAME) build complete! Run ./$(APP_NAME) --help"

build-relay:
@echo "🔨 Building native $(RELAY_NAME) binary for $(OS_NAME) ($(HOST_ARCH))..."
@go mod tidy
@go build -ldflags "-s -w -X main.Version=$(VERSION)" -o $(RELAY_NAME) $(RELAY_MAIN_FILE)
@go build -ldflags "-s -w -X agentfm/internal/version.AppVersion=$(VERSION)" -o $(RELAY_NAME) $(RELAY_MAIN_FILE)
@echo "✅ $(RELAY_NAME) build complete! Run ./$(RELAY_NAME) --help"

build: build-agentfm build-relay
Expand Down
230 changes: 230 additions & 0 deletions agentfm-go/cmd/agentfm/bossbootstrap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
package main

import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"time"

"agentfm/internal/boss"
"agentfm/internal/ledger"
"agentfm/internal/ledger/comments"
"agentfm/internal/ledger/store"
"agentfm/internal/network"
"agentfm/internal/obs"
"agentfm/internal/reputation"

netcore "github.com/libp2p/go-libp2p/core/network"
)

// bossOptionsFromFlags assembles the v1.3 boss.Options bundle that
// wires the ledger, comments store, reputation engine, and floor policy.
// Used by runBossMode + runAPIMode so the v1.3 HTTP / ledger surfaces are
// LIVE in the running binary (rather than returning 503 ledger_unavailable).
//
// The function is best-effort: every component is optional. A
// failure to open the ledger (e.g. permission denied on the SQLite
// path) is logged and the boss boots without ledger-backed
// endpoints — the rest of the gateway still works.
//
// The returned cleanup func MUST be called at shutdown.
func bossOptionsFromFlags(
ctx context.Context,
mode string,
node *network.MeshNode,
reputationFloor float64,
genesisSeedsPath string,
) (boss.Options, func()) {
opts := boss.Options{
// Always pass a pointer so an explicit --reputation-floor=0 stays 0
// and does not collide with the "unconfigured" sentinel.
ReputationFloor: &reputationFloor,
}
cleanups := []func(){}
cleanup := func() {
// Run cleanups in reverse order (LIFO).
for i := len(cleanups) - 1; i >= 0; i-- {
cleanups[i]()
}
}

// --- ledger -------------------------------------------------------
keyPath := fmt.Sprintf(".agentfm_%s_identity.key", mode)
priv, err := network.LoadOrGenerateIdentity(keyPath)
if err != nil {
slog.Warn("boss bootstrap: identity load failed; ledger disabled",
slog.Any(obs.FieldErr, err))
return opts, cleanup
}

dbPath := defaultBossLedgerPath(mode)
if err := os.MkdirAll(filepath.Dir(dbPath), 0o700); err != nil {
slog.Warn("boss bootstrap: cannot create ledger dir; ledger disabled",
slog.String("path", dbPath),
slog.Any(obs.FieldErr, err))
return opts, cleanup
}

l, err := ledger.NewWithOptions(dbPath, priv, node.PubSub, ledger.Options{
Host: node.Host,
})
if err != nil {
slog.Warn("boss bootstrap: ledger open failed; v1.3 endpoints will 503",
slog.String("path", dbPath),
slog.Any(obs.FieldErr, err))
return opts, cleanup
}
opts.Ledger = l
cleanups = append(cleanups, func() { _ = l.Close() })
slog.Info("boss bootstrap: ledger opened",
slog.String("path", dbPath))

// P5-1: on restart, pull any entries the boss missed while offline.
// Non-fatal: if catch-up fails the boss continues normally.
go func() {
const waitBudget = 30 * time.Second
deadline := time.Now().Add(waitBudget)
for time.Now().Before(deadline) {
if node.RelayPeerID != "" &&
node.Host.Network().Connectedness(node.RelayPeerID) == netcore.Connected {
if err := ledger.CatchUp(context.Background(), l, node.Host, node.RelayPeerID); err != nil {
slog.Warn("boss bootstrap: catch-up failed",
slog.Any(obs.FieldErr, err))
} else {
slog.Info("boss bootstrap: catch-up complete")
}
return
}
time.Sleep(1 * time.Second)
}
slog.Warn("boss bootstrap: relay never came online within 30s; no catch-up")
}()

// Hourly aggregate outcome rater (P2 / Task 2.1).
// RunTicker must be started after the boss is constructed; the
// bootstrap caller is responsible for: go opts.CompletionRater.RunTicker(ctx).
// We store the ticker in opts so the caller has a reference.
opts.CompletionRater = boss.NewCompletionRatingWriter(l, node.Host)
// Start the ticker in the background; cancel via the top-level ctx.
go opts.CompletionRater.RunTicker(ctx)

// Open a SECOND store handle on the same DB file for the
// reputation engine's read-only walks. SQLite under WAL mode
// supports concurrent open handles cleanly; the engine never
// writes, the ledger always does. Avoids reaching into the
// ledger impl for its private store reference.
readStore, err := store.Open(dbPath)
if err != nil {
slog.Warn("boss bootstrap: secondary store open failed; reputation engine disabled",
slog.Any(obs.FieldErr, err))
} else {
cleanups = append(cleanups, func() { _ = readStore.Close() })
opts.ReadStore = readStore // wired through so HTTP handler can recompute on demand
}

// --- comments store + submission handler --------------------------
cstore, err := comments.Open(defaultCommentsRoot())
if err != nil {
slog.Warn("boss bootstrap: comments store open failed; P4-3 disabled",
slog.Any(obs.FieldErr, err))
} else {
cserver := comments.NewServer(node.Host, cstore)
cserver.Start()
cleanups = append(cleanups, cserver.Stop)

// Wire commentsStore so GET /v1/peers/{id}/comments/{cid}
// can hydrate comment bodies (sub-task 1.5 / Phase 1).
opts.CommentsStore = cstore

// The boss's POST /v1/peers/{id}/comments handler needs a
// reference to the boss, but the boss hasn't been
// constructed yet (we're producing its Options). Use a
// late-binding closure — the bootstrap caller calls
// AttachBoss(b) right after boss.NewWithOptions returns.
handler := boss.NewCommentSubmissionHandler(cstore, node.Host)
opts.CommentSubmissionHandler = func(w http.ResponseWriter, r *http.Request) {
handler.HandleHTTP(currentBossRef.Load(), w, r)
}
}

// --- reputation engine + recompute ticker -------------------------
if readStore != nil {
seeds, err := reputation.LoadSeedsFile(genesisSeedsPath)
if err != nil {
slog.Warn("boss bootstrap: genesis seeds load failed; using bundled defaults",
slog.Any(obs.FieldErr, err))
seeds, _ = reputation.LoadDefaultSeeds()
}
// Self-seed: this boss's OWN peer id gets score 1.0 in its
// OWN reputation engine. EigenTrust's mathematical premise
// is "a rater's voting weight equals their own current
// reputation"; without self-seeding, a fresh boss's
// machine-issued attestation ratings have zero voting
// weight and don't move scores. From the boss's local
// perspective, trusting your own attestation gate fully is
// the natural fixed point — and other peers in the mesh
// don't automatically inherit this trust (they have to
// accumulate evidence about this boss through OTHER seeds
// independently).
seeds = append(seeds, reputation.Seed{
PeerID: node.Host.ID().String(),
Score: 1.0,
})
engine := reputation.New(seeds, reputation.Config{})
opts.ReputationEngine = engine

// Initial recompute so the first request has fresh data.
if _, err := engine.Recompute(ctx, readStore); err != nil {
slog.Debug("boss bootstrap: initial reputation recompute failed",
slog.Any(obs.FieldErr, err))
}

// Background recompute — every 60s per P5-1.
tickCtx, tickCancel := context.WithCancel(context.Background())
go runReputationTicker(tickCtx, engine, readStore)
cleanups = append(cleanups, tickCancel)
}

return opts, cleanup
}

// runReputationTicker runs the engine's 60s recompute loop. Exits
// on ctx cancel; tolerates Recompute errors (logs at debug, retries
// next tick).
func runReputationTicker(ctx context.Context, eng *reputation.Engine, s *store.Store) {
t := time.NewTicker(60 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if _, err := eng.Recompute(ctx, s); err != nil {
slog.Debug("reputation: recompute tick failed",
slog.Any(obs.FieldErr, err))
}
}
}
}

// defaultBossLedgerPath returns ~/.agentfm/<mode>_ledger.db.
// Falls back to working dir if HOME isn't set (CI sandboxes).
func defaultBossLedgerPath(mode string) string {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return fmt.Sprintf(".agentfm_%s_ledger.db", mode)
}
return filepath.Join(home, ".agentfm", fmt.Sprintf("%s_ledger.db", mode))
}

// defaultCommentsRoot returns ~/.agentfm/comments (or local fallback).
func defaultCommentsRoot() string {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return ".agentfm_comments"
}
return filepath.Join(home, ".agentfm", "comments")
}
Loading
Loading