From b9c64c2694b28adcbaa2585a7947cc50e785b6c8 Mon Sep 17 00:00:00 2001 From: Mathias Date: Thu, 28 May 2026 20:54:04 +0200 Subject: [PATCH] feat: add agent boundaries, network policy, agent stub, dockerfile, CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AGENT_BOUNDARIES.md: egress allow-list, FS scope, approved/forbidden ops - agent-policy.yaml: k8s NetworkPolicy scoping egress to LiteLLM/brain-mcp/ gitea-mcp/OTLP + default-deny baseline - internal/agent/agent.go: thin ADK runner wrapper (Config + Run) - Dockerfile: distroless multi-stage build, entrypoint cmd/__PROJECT_NAME__ - .gitea/workflows/cd.yml: check → buildah build/push → GitOps deploy Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitea/workflows/cd.yml | 114 ++++++++++++++++++++++++++++++++++++++++ AGENT_BOUNDARIES.md | 76 +++++++++++++++++++++++++++ Dockerfile | 12 +++++ agent-policy.yaml | 101 +++++++++++++++++++++++++++++++++++ internal/agent/agent.go | 74 ++++++++++++++++++++++++++ 5 files changed, 377 insertions(+) create mode 100644 .gitea/workflows/cd.yml create mode 100644 AGENT_BOUNDARIES.md create mode 100644 Dockerfile create mode 100644 agent-policy.yaml create mode 100644 internal/agent/agent.go diff --git a/.gitea/workflows/cd.yml b/.gitea/workflows/cd.yml new file mode 100644 index 0000000..a9bff21 --- /dev/null +++ b/.gitea/workflows/cd.yml @@ -0,0 +1,114 @@ +name: CD + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + branches: [main] + +env: + IMAGE: __PROJECT_NAME__ + +jobs: + check: + name: Lint / Test / Vet + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: false + + - name: Install toolchain + run: | + go version + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh \ + | sh -s -- -b "$(go env GOPATH)/bin" v2.11.4 + + - name: Run checks + run: task check + + build: + name: Build & Import + needs: check + runs-on: self-hosted + if: github.event_name != 'pull_request' + outputs: + image-tag: ${{ steps.meta.outputs.sha-tag }} + steps: + - uses: actions/checkout@v4 + + - name: Derive image tags + id: meta + run: | + SHA=$(git rev-parse --short HEAD) + echo "sha-tag=${SHA}" >> "$GITHUB_OUTPUT" + + - name: Build and push to local registry + run: | + REGISTRY="localhost:5000" + REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}" + buildah build \ + --label "org.opencontainers.image.revision=${{ github.sha }}" \ + -t ${REF} \ + -t ${REGISTRY}/${{ env.IMAGE }}:latest \ + . + buildah push --tls-verify=false ${REF} + buildah push --tls-verify=false ${REGISTRY}/${{ env.IMAGE }}:latest + echo "✓ Image pushed to ${REF}" + + deploy: + name: Deploy via GitOps + needs: build + runs-on: self-hosted + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + steps: + - name: Update image tag in infra repo + env: + IMAGE_TAG: ${{ needs.build.outputs.image-tag }} + DEPLOY_KEY: ${{ secrets.INFRA_DEPLOY_KEY }} + run: | + set -euo pipefail + mkdir -p ~/.ssh + echo "$DEPLOY_KEY" > ~/.ssh/id_infra + chmod 600 ~/.ssh/id_infra + ssh-keyscan -p 30022 10.0.1.20 >> ~/.ssh/known_hosts 2>/dev/null + export GIT_SSH_COMMAND="ssh -i ~/.ssh/id_infra -o IdentitiesOnly=yes" + rm -rf /tmp/infra + git clone -b main ssh://git@10.0.1.20:30022/mathias/infra.git /tmp/infra + cd /tmp/infra + DEPLOYMENT="k3s/apps/__PROJECT_NAME__/deployment.yaml" + sed -i "s|image: localhost:5000/__PROJECT_NAME__:.*|image: localhost:5000/__PROJECT_NAME__:${IMAGE_TAG}|" "$DEPLOYMENT" + grep -q "localhost:5000/__PROJECT_NAME__:${IMAGE_TAG}" "$DEPLOYMENT" \ + || { echo "✗ image tag patch failed"; exit 1; } + if git diff --quiet "$DEPLOYMENT"; then + echo "ℹ image tag unchanged — skipping push" + else + git -c user.name="__PROJECT_NAME__ CI" \ + -c user.email="ci@__PROJECT_NAME__.local" \ + commit -m "chore(deploy): __PROJECT_NAME__ → ${IMAGE_TAG}" "$DEPLOYMENT" + git push origin main + echo "✓ pushed to infra repo" + fi + shred -u ~/.ssh/id_infra + + - name: Trigger Flux reconcile + run: | + kubectl -n flux-system annotate gitrepository flux-system \ + reconcile.fluxcd.io/requestedAt="$(date +%s)" --overwrite + kubectl -n flux-system annotate kustomization apps \ + reconcile.fluxcd.io/requestedAt="$(date +%s)" --overwrite + + - name: Verify rollout + run: | + kubectl rollout status deployment/__PROJECT_NAME__ \ + --namespace __PROJECT_NAME__ \ + --timeout=120s \ + || { + kubectl get pods -n __PROJECT_NAME__ -o wide + kubectl get events -n __PROJECT_NAME__ --sort-by='.lastTimestamp' | tail -20 + exit 1 + } diff --git a/AGENT_BOUNDARIES.md b/AGENT_BOUNDARIES.md new file mode 100644 index 0000000..d640fef --- /dev/null +++ b/AGENT_BOUNDARIES.md @@ -0,0 +1,76 @@ +# Agent boundaries — __PROJECT_NAME__ + +Operational scope for this agent. Read before extending tools, adding endpoints, +or wiring new data sources. Defaults are conservative; widen explicitly. + +## Network scope + +Egress is allow-listed. The agent MAY reach: + +| Endpoint | Purpose | Protocol | Default port | +|--------------------------------|----------------------------------|----------|--------------| +| `llm-api.d-ma.be` (LiteLLM) | Model inference | HTTPS | 443 | +| `brain-mcp.d-ma.be` | Knowledge base (BM25 + synth) | HTTPS | 443 | +| `gitea-mcp.d-ma.be` | Repo/issue/PR ops | HTTPS | 443 | +| `jaeger.d-ma.be` | OTLP trace export | HTTP | 4318 | +| In-cluster DNS (`kube-dns`) | Service discovery | UDP | 53 | + +Egress MUST be blocked to: + +- Public Slack / Discord / Telegram / email +- Third-party LLM APIs (OpenAI, Anthropic, Google) unless explicitly added below +- Public package registries from runtime (proxy through build only) +- Customer/client domains not listed in the engagement scope + +Add new endpoints by editing `agent-policy.yaml` AND this table in the same commit. + +## File scope + +Read scope: + +- Mounted ConfigMaps and Secrets under `/etc/__PROJECT_NAME__/` +- Working dir under `/var/lib/__PROJECT_NAME__/` (ephemeral) +- `/tmp` for streaming artifacts (size-capped via emptyDir) + +Write scope: + +- `/var/lib/__PROJECT_NAME__/` only +- `/tmp` only +- Never `/`, `/etc`, `/var/log`, host paths, or sibling pod volumes + +Persistent state MUST live in PostgreSQL (`postgres.d-ma.be`) or object storage — +not the pod filesystem. + +## Approved operations + +The agent MAY: + +- Issue LLM inference requests via the LiteLLM adapter +- Query the brain MCP for prior art / postmortems +- Read repos, issues, and PRs via gitea-mcp +- Emit OTLP spans for `invoke_agent`, `generate_content`, and any custom tool spans +- Write to its own k8s logs (stdout/stderr; structured `slog`) + +The agent MUST NOT, without explicit opt-in in code review: + +- Write to gitea (open PR, push branch, comment) — requires `--write` flag and audit log +- Execute shell commands in non-sandboxed contexts +- Mutate k8s resources (no kubectl/Helm/Flux operations from runtime) +- Forward LLM outputs to external systems before redaction +- Hold conversation logs longer than the session TTL (24h default) + +## Failure posture + +- Outbound DNS failure → fail closed, retry with jitter, surface to caller +- LLM 5xx → bounded retry (3 attempts), then return error to caller +- Brain MCP unavailable → continue without prior-art lookup; log degraded mode +- OTLP unavailable → drop spans silently; never block request path + +## Review triggers + +Bump this doc + `agent-policy.yaml` when: + +- A new external endpoint is added +- A new tool with side effects is wired in +- The agent gains write access to anything (repo, DB table, filesystem path) +- A client engagement narrows or widens the data scope diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c845f14 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.26-alpine AS build +WORKDIR /src +RUN apk add --no-cache git +COPY go.mod ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/app ./cmd/__PROJECT_NAME__ + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/app /app +USER nonroot:nonroot +ENTRYPOINT ["/app"] diff --git a/agent-policy.yaml b/agent-policy.yaml new file mode 100644 index 0000000..8f71478 --- /dev/null +++ b/agent-policy.yaml @@ -0,0 +1,101 @@ +# NetworkPolicy for __PROJECT_NAME__. +# +# Pairs with AGENT_BOUNDARIES.md. Egress is allow-listed: LiteLLM, brain-mcp, +# gitea-mcp, OTLP collector, in-cluster DNS. Everything else is denied. +# +# Apply in the __PROJECT_NAME__ namespace. Substitute __PROJECT_NAME__ at +# render time (envsubst, kustomize replacement, or sed in CI). +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: __PROJECT_NAME__-egress + namespace: __PROJECT_NAME__ + labels: + app.kubernetes.io/name: __PROJECT_NAME__ + app.kubernetes.io/component: agent +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: __PROJECT_NAME__ + policyTypes: + - Egress + egress: + # In-cluster DNS — required for resolving any of the endpoints below. + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + + # LiteLLM — model inference proxy. + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: litellm + podSelector: + matchLabels: + app.kubernetes.io/name: litellm + ports: + - protocol: TCP + port: 4000 + + # Brain MCP — knowledge base query/write. + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: ingestion + podSelector: + matchLabels: + app.kubernetes.io/name: brain-mcp + ports: + - protocol: TCP + port: 8080 + + # Gitea MCP — repo/issue/PR access. + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: gitea + podSelector: + matchLabels: + app.kubernetes.io/name: gitea-mcp + ports: + - protocol: TCP + port: 8080 + + # OTLP — trace export to Jaeger collector. + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: observability + podSelector: + matchLabels: + app.kubernetes.io/name: jaeger + ports: + - protocol: TCP + port: 4318 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: __PROJECT_NAME__-default-deny + namespace: __PROJECT_NAME__ + labels: + app.kubernetes.io/name: __PROJECT_NAME__ +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: __PROJECT_NAME__ + policyTypes: + - Ingress + - Egress + # Empty rules = deny-all. The allow-list above is additive on Egress. + # Ingress stays denied unless a sibling policy opens specific ports. diff --git a/internal/agent/agent.go b/internal/agent/agent.go new file mode 100644 index 0000000..132dac9 --- /dev/null +++ b/internal/agent/agent.go @@ -0,0 +1,74 @@ +// Package agent wraps the ADK runner with the project's defaults. +// +// Keep this thin: construction, defaults, and the Run loop. Tool wiring, +// callbacks, and any business logic belong in sibling packages. +package agent + +import ( + "context" + "fmt" + "iter" + + "google.golang.org/adk/agent" + "google.golang.org/adk/agent/llmagent" + "google.golang.org/adk/model" + "google.golang.org/adk/runner" + "google.golang.org/adk/session" + "google.golang.org/genai" +) + +// Config holds the minimum needed to spin up an agent + runner. +type Config struct { + Name string + Description string + Instruction string + Model model.LLM +} + +// Agent is the runnable unit. Construct with New, drive with Run. +type Agent struct { + name string + runner *runner.Runner +} + +// New builds an llmagent and an in-memory-session runner around it. +func New(cfg Config) (*Agent, error) { + if cfg.Name == "" { + return nil, fmt.Errorf("agent: Name required") + } + if cfg.Model == nil { + return nil, fmt.Errorf("agent: Model required") + } + + ag, err := llmagent.New(llmagent.Config{ + Name: cfg.Name, + Description: cfg.Description, + Model: cfg.Model, + Instruction: cfg.Instruction, + }) + if err != nil { + return nil, fmt.Errorf("agent: build llmagent: %w", err) + } + + r, err := runner.New(runner.Config{ + AppName: cfg.Name, + Agent: ag, + SessionService: session.InMemoryService(), + AutoCreateSession: true, + }) + if err != nil { + return nil, fmt.Errorf("agent: build runner: %w", err) + } + + return &Agent{name: cfg.Name, runner: r}, nil +} + +// Run dispatches a single user turn and returns the event iterator. +// The caller is responsible for draining it and handling errors. +func (a *Agent) Run(ctx context.Context, userID, sessionID, text string) iter.Seq2[*session.Event, error] { + msg := genai.NewContentFromText(text, "user") + return a.runner.Run(ctx, userID, sessionID, msg, agent.RunConfig{}) +} + +// Name returns the registered agent name (also used as ADK AppName). +func (a *Agent) Name() string { return a.name }