feat: add agent boundaries, network policy, agent stub, dockerfile, CI
CD / Lint / Test / Vet (push) Failing after 4s
CD / Build & Import (push) Has been skipped
CD / Deploy via GitOps (push) Has been skipped

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 20:54:04 +02:00
co-authored by Claude Opus 4.7
parent 7dfe8a792e
commit b9c64c2694
5 changed files with 377 additions and 0 deletions
+114
View File
@@ -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
}
+76
View File
@@ -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
+12
View File
@@ -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"]
+101
View File
@@ -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.
+74
View File
@@ -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 }