Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
ed9af10
feat(identity): dev-mode self-signed identity for the rogue agent
aciobanu-godaddy Oct 26, 2026
f67312e
feat(callee): ingress verifier with live TL, seller forwarding, recei…
aciobanu-godaddy Oct 26, 2026
bc6984b
feat(commerce): AP2 commerce leg — mandate, DPoP, R1-ordered rewrite
aciobanu-godaddy Oct 26, 2026
8415e18
feat(demo): two-agent egress configs, policies, and gateway registration
aciobanu-godaddy Oct 26, 2026
7fe5823
feat(capability): pluggable transform chain + discovery + pre-return …
aciobanu-godaddy Oct 26, 2026
5bed124
fix(commerce,middleware): mandate byte-forwarding, G1 htu binding, cl…
aciobanu-godaddy Oct 26, 2026
57dd64a
feat(demo): live .env-driven two-agent demo + narrated SAFE-reviewer …
aciobanu-godaddy Oct 26, 2026
348d249
fix(lint): align gosec G704 suppressions with CI
aciobanu-godaddy Oct 27, 2026
f2b741d
refactor(app): extract reusable server assembly into app package
aciobanu-godaddy Oct 27, 2026
21ab0fd
refactor(capability): generic Artifacts map; decouple core from commerce
aciobanu-godaddy Oct 27, 2026
bd74518
refactor(tutorial): move commerce into a separate dev-only module
aciobanu-godaddy Oct 27, 2026
56d2264
refactor(tutorial): add demo binary; remove commerce config from core
aciobanu-godaddy Oct 27, 2026
858258e
build(tutorial): demo-build target, CI for the tutorial module, docs
aciobanu-godaddy Oct 27, 2026
d6ac6fd
refactor(tutorial): address PR review — enum guard, shared FileConfig…
aciobanu-godaddy Oct 27, 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
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,34 @@ jobs:
version: v2.10.1
args: --verbose --concurrency=4 --allow-parallel-runners

tutorial:
name: Tutorial module (demo)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e #v7.0.0
with:
go-version: "1.26.9"
# The tutorial module (dev-only demo) consumes the core via a workspace.
# go.work is gitignored; generate it here (not committed) so the module
# resolves the in-repo core at its v0.0.0 placeholder version.
- name: Generate workspace
run: |
rm -f go.work go.work.sum
go work init . ./tutorial
go work edit -replace github.com/agentnameservice/ans-openshell@v0.0.0=./
- name: Build (tutorial)
run: go build ./tutorial/...
- name: Build demo binary
run: go build -o bin/ans-demo-middleware ./tutorial/cmd/ans-demo-middleware
- name: Test (tutorial)
run: go test -race ./tutorial/...
- uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a #v9.3.0
with:
version: v2.10.1
args: --verbose --concurrency=4 --allow-parallel-runners
working-directory: tutorial

deploy-artifacts:
name: Container images & Helm chart
runs-on: ubuntu-latest
Expand Down
12 changes: 12 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,18 @@ Thumbs.db
# Secrets and certificates
.env
.env.*
!.env.example
*.pem
*.key
*.csr

# Identity material for the Tier 2 demo (Agent A bundle, commerce key) — never committed
/.secrets/

# Generated demo run reports (run-demo.sh --report)
report.md
run-report.md
*-run-report.md

# Design docs, plans, and runbooks are local-only (user instruction: ignore docs folder)
/docs/
17 changes: 16 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,29 @@ LINT_GOTOOLCHAIN ?= local
BIN := bin
CMDS := ans-openshell-middleware ans-openshell-interceptor ans-ocsf-hydrator ans-reference-callee

.PHONY: all build proto test cover lint vet demo clean
.PHONY: all build demo-build workspace proto test cover lint vet demo clean

all: build

build:
@mkdir -p $(BIN)
@for c in $(CMDS); do echo "building $$c"; $(GO) build -o $(BIN)/$$c ./cmd/$$c; done

# The tutorial (dev-only demo) module lives in ./tutorial and consumes the core
# via a Go workspace. go.work is gitignored; `workspace` (re)generates it with a
# version-pinned replace so the module resolves the in-repo core. It is harmless
# to run repeatedly.
workspace:
rm -f go.work go.work.sum
$(GO) work init . ./tutorial
$(GO) work edit -replace github.com/agentnameservice/ans-openshell@v0.0.0=./

# demo-build produces the dev-only demo binary (production middleware + the
# commerce tutorial transform). run-demo.sh runs this binary, not the prod one.
demo-build: workspace
@mkdir -p $(BIN)
$(GO) build -o $(BIN)/ans-demo-middleware ./tutorial/cmd/ans-demo-middleware

# Generated code must match CI byte for byte. protoc-gen-go formats its output with the
# go/format of the toolchain that built it, so the plugins are built with the CI toolchain.
PROTO_GOTOOLCHAIN ?= go1.26.9
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,26 @@ Run the tests:
make cover
```

## Repository layout

The repository is two Go modules:

- **Core** (`github.com/agentnameservice/ans-openshell`, repo root) — the
generic middleware and its binaries (`make build`). The production
`ans-openshell-middleware` is pure middleware: it stamps a
transparency-log-vouched ANS identity on a sandbox's outbound traffic and
contains **no demo or commerce code**.
- **Tutorial** (`github.com/agentnameservice/ans-openshell/tutorial`,
`./tutorial`) — a dev-only module that consumes the core's `app.Run`
extension point and registers a worked-example capability transform
(AP2 commerce: mandate + DPoP). It builds its own binary,
`ans-demo-middleware` (`make demo-build`), used only by the live demo under
`deploy/live-demo/`. It is never shipped.

`make demo-build` generates a gitignored `go.work` so the tutorial module
resolves the in-repo core; the production build (`make build`) needs no
workspace.

## Running the services

Every service takes one YAML file. Flags: `--config` (required), `--listen` to override the
Expand Down
221 changes: 221 additions & 0 deletions app/app.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
// Package app assembles and serves the OpenShell supervisor middleware. It is
// the reusable core entrypoint: the production command calls Run with no
// customization, while a caller that wants to register additional capability
// transforms (for example the demo's commerce tutorial) passes options that
// receive the built *middleware.Service before it starts serving.
package app

import (
"context"
"errors"
"log/slog"
"slices"
"time"

"google.golang.org/grpc"

"github.com/agentnameservice/ans-openshell/gen/openshell/middlewarev1"
"github.com/agentnameservice/ans-openshell/internal/config"
"github.com/agentnameservice/ans-openshell/internal/extauth"
"github.com/agentnameservice/ans-openshell/internal/grpcserver"
"github.com/agentnameservice/ans-openshell/internal/identity"
"github.com/agentnameservice/ans-openshell/internal/middleware"
"github.com/agentnameservice/ans-openshell/internal/vetting"
)

const (
// DefaultListen is the gRPC listen address when the config leaves it empty.
DefaultListen = ":50051"
// DefaultHealthListen serves plaintext HTTP /healthz and /readyz for probes.
DefaultHealthListen = ":8081"
healthCheckInterval = 5 * time.Second
drainDelay = 3 * time.Second
)

// Config is the generic middleware configuration. It is the YAML file shape the
// production binary loads; callers that layer extra capabilities keep their own
// (for example commerce) config separate and wire it through an Option.
type Config struct {
Listen string `yaml:"listen"`
// HealthListen serves plaintext HTTP /healthz and /readyz for probes.
// Empty disables it; the default is :8081.
HealthListen string `yaml:"health_listen"`
TLS config.TLS `yaml:"tls"`
Auth config.Auth `yaml:"auth"`
TransparencyLog config.TransparencyLog `yaml:"transparency_log"`
Identities map[string]identity.Config `yaml:"identities"`
SandboxIdentities map[string]string `yaml:"sandbox_identities"`
// Vetting enables destination vetting when present; policies may only set
// destination_policy if it is.
Vetting *config.Vetting `yaml:"vetting"`
// MaxPayloadBytes and RequestTimeout are advertised in the manifest binding.
MaxPayloadBytes uint64 `yaml:"max_payload_bytes"`
RequestTimeout time.Duration `yaml:"request_timeout"`
}

// options collects the optional customizations a caller may apply.
type options struct {
version string
customize func(context.Context, *middleware.Service, *slog.Logger) error
}

// Option customizes a Run invocation.
type Option func(*options)

// WithVersion sets the implementation version advertised in the manifest and logs.
func WithVersion(v string) Option {
return func(o *options) {
if v != "" {
o.version = v
}
}
}

// WithCustomize registers a hook that runs after the *middleware.Service is
// built and before the server starts, letting the caller register capability
// transforms and a discoverer (e.g. svc.AddTransform / svc.WithDiscoverer). The
// hook may start background work; returning an error aborts startup.
func WithCustomize(fn func(context.Context, *middleware.Service, *slog.Logger) error) Option {
return func(o *options) { o.customize = fn }
}

// Run assembles the middleware from cfg and serves it until ctx is cancelled.
// It wires identity, caller authentication, optional destination vetting, the
// gRPC server and readiness, then applies any options (which may register
// capability transforms) before serving.
func Run(ctx context.Context, cfg Config, logger *slog.Logger, opts ...Option) error {
o := options{version: "dev"}
for _, apply := range opts {
apply(&o)
}
return serve(ctx, cfg, o, logger)
}

func serve(ctx context.Context, cfg Config, o options, logger *slog.Logger) error {
if cfg.Listen == "" {
cfg.Listen = DefaultListen
}
// An empty HealthListen disables the health endpoints; callers that want the
// default apply it before calling (see the production main). We do not
// default it here so tests and embedders can opt out.
if len(cfg.Identities) == 0 {
return errors.New("identities: at least one identity is required")
}

auth, err := extauth.NewVerifier(cfg.Auth, logger)
if err != nil {
return err
}
go auth.Run(ctx)
if !cfg.TLS.Enabled() && !cfg.Auth.AllowInsecureNoAuth {
logger.WarnContext(ctx, "listening without TLS while extension authentication is on; gateway bearer tokens travel in the clear")
}

registry, err := identity.NewRegistry(ctx, cfg.Identities, cfg.TransparencyLog, logger)
if err != nil {
return err
}
defer registry.Close()
authz, err := middleware.NewSandboxIdentities(cfg.SandboxIdentities, registry)
if err != nil {
return err
}

var vet vetting.Verifier
if cfg.Vetting != nil {
vetSvc, vetErr := vetting.New(*cfg.Vetting, vetting.Deps{}, logger)
if vetErr != nil {
return vetErr
}
defer vetSvc.Close()
vet = vetSvc
}

srv, err := grpcserver.New(ctx, grpcserver.Options{
Listen: cfg.Listen,
HealthListen: cfg.HealthListen,
TLS: cfg.TLS,
Unary: []grpc.UnaryServerInterceptor{auth.UnaryInterceptor()},
Stream: []grpc.StreamServerInterceptor{auth.StreamInterceptor()},
DrainDelay: drainDelay,
}, logger)
if err != nil {
return err
}
svc := middleware.NewService(registry, authz, vet, serviceConfig(cfg, o.version), logger)

// Apply caller customizations (e.g. register capability transforms).
if o.customize != nil {
if err := o.customize(ctx, svc, logger); err != nil {
return err
}
}

middlewarev1.RegisterSupervisorMiddlewareServer(srv.Registrar(), svc)
middlewarev1.RegisterHttpResponsePreReturnServer(srv.Registrar(), middleware.NewPreReturnService(logger))
logger.InfoContext(ctx, "http response pre-return stage enabled")
go srv.WatchReadiness(ctx, healthCheckInterval, readinessProbe(ctx, registry, auth, logger))
logger.InfoContext(ctx, "ans-openshell-middleware starting",
"version", o.version, "identities", registry.Names(), "vetting", vet != nil)
return srv.Serve(ctx)
}

// serviceConfig maps the generic configuration onto the middleware service.
func serviceConfig(cfg Config, version string) middleware.Config {
c := middleware.Config{
Audience: cfg.Auth.Audience,
MaxPayloadBytes: cfg.MaxPayloadBytes,
RequestTimeout: cfg.RequestTimeout,
ImplementationVersion: version,
RequireAuthenticatedCaller: !cfg.Auth.AllowInsecureNoAuth,
}
if cfg.Vetting != nil {
c.VettingBudget = cfg.Vetting.RequestBudget
}
return c
}

// keyHolder reports whether a gateway verification key is held;
// extauth.Verifier satisfies it.
type keyHolder interface{ Ready() bool }

// identityHealth reports the configured identities and which of them lack
// usable artifacts; identity.Registry satisfies it.
type identityHealth interface {
Names() []string
Unhealthy() []string
}

// readinessProbe ties readiness to what the replica can actually do: it must
// hold a gateway verification key and at least one identity with usable
// artifacts. A single identity whose artifacts lapse is denied per request and
// logged here, but does not take the other identities out of service. Every
// change of state is logged, the first evaluation included.
func readinessProbe(ctx context.Context, registry identityHealth, auth keyHolder, logger *slog.Logger) func() bool {
var last *healthState
return func() bool {
next := healthState{authReady: auth.Ready(), unhealthy: registry.Unhealthy(), total: len(registry.Names())}
if last == nil || next.ready() != last.ready() || !slices.Equal(next.unhealthy, last.unhealthy) {
logger.LogAttrs(ctx, next.level(), "readiness evaluated",
slog.Bool("ready", next.ready()), slog.Bool("gateway_keys", next.authReady),
slog.Any("unhealthy_identities", next.unhealthy))
}
last = &next
return next.ready()
}
}

type healthState struct {
authReady bool
unhealthy []string
total int
}

func (h healthState) ready() bool { return h.authReady && len(h.unhealthy) < h.total }

func (h healthState) level() slog.Level {
if h.ready() && len(h.unhealthy) == 0 {
return slog.LevelInfo
}
return slog.LevelWarn
}
Loading
Loading