diff --git a/public/blog/broker-pattern.webp b/public/blog/broker-pattern.webp new file mode 100644 index 0000000..111d894 Binary files /dev/null and b/public/blog/broker-pattern.webp differ diff --git a/public/diagrams/broker-pattern.svg b/public/diagrams/broker-pattern.svg new file mode 100644 index 0000000..6507f7a --- /dev/null +++ b/public/diagrams/broker-pattern.svg @@ -0,0 +1,60 @@ + + + + + The broker pattern: ask, never hold + the agent can only ask — every credential stays behind the brokers, every write waits on a human + + + + untrusted executor + + + trusted tier — holds every credential + + + + agent + sandboxed · assume compromised + holds a capability, not a key + + + + credential broker + verifies the sandbox identity + mints a scoped, one-time, + expiring capability + + + + tool broker + checks signature · scope · nonce + runs the tool with a credential + it holds — never the agent + + + + human approval + every write is staged, not run + one-time token · fail-closed + timeout = denied + + + + + + + + + + + + asks + + capability + + stages write + + + The agent can only ask. Every credential, every write-authority, every scope decision lives at the broker — a compromised agent's worst case is a denied request. + diff --git a/src/components/DossierThumb.astro b/src/components/DossierThumb.astro index 8f01210..f4722d5 100644 --- a/src/components/DossierThumb.astro +++ b/src/components/DossierThumb.astro @@ -37,6 +37,58 @@ const { kind = "generic" } = Astro.props; + ) : kind === "gpu" ? ( + <> + {/* GPU die with passthrough lanes feeding two edge nodes */} + + + + + + + GPU + + + + + + + + ) : kind === "globe" ? ( + <> + {/* globe of regions — a modernised multi-region estate */} + + + + + + + + + + + + + + + ) : kind === "network-fleet" ? ( + <> + {/* fleet of switches, single-pane managed */} + + NCM + + + + + + + + + + + + + ) : ( <> {/* pipeline — commits flowing through gates to a fleet */} diff --git a/src/components/ProjectCard.astro b/src/components/ProjectCard.astro index 39357ae..aab58b1 100644 --- a/src/components/ProjectCard.astro +++ b/src/components/ProjectCard.astro @@ -79,12 +79,26 @@ const dossierNo = String(index).padStart(3, "0"); } .dossier__body { + position: relative; padding: 24px; display: flex; flex-direction: column; gap: 12px; flex: 1; } + /* a faint on-theme grid fills the idle space above the chips/meta (the + margin-top:auto gap on shorter "argument" text) instead of a flat void */ + .dossier__body::before { + content: ""; + position: absolute; + inset: 68px 24px 64px; + background-image: + linear-gradient(var(--grid-line) 1px, transparent 1px), + linear-gradient(90deg, var(--grid-line) 1px, transparent 1px); + background-size: 22px 22px; + mask-image: linear-gradient(180deg, black, transparent 85%); + pointer-events: none; + } .dossier__title { margin: 0; font-family: var(--font-display); diff --git a/src/content/blog/broker-pattern.md b/src/content/blog/broker-pattern.md new file mode 100644 index 0000000..85cbaa9 --- /dev/null +++ b/src/content/blog/broker-pattern.md @@ -0,0 +1,74 @@ +--- +title: "An agent should never hold the key it's using" +date: 2026-07-03 +summary: "You want an AI agent that can actually do things — call APIs, touch real data. You also don't fully trust it. The resolution isn't a better sandbox; it's making sure the agent never possesses a credential at all. A broker holds the keys, mints short-lived capabilities, and gates every write behind a human. Here's the pattern." +tags: ["security", "ai-agents", "architecture", "zero-trust", "homelab"] +draft: false +hero: "/blog/broker-pattern.webp" +heroAlt: "An untrusted agent reaches through a sealed gate to ask; on the far side, a guardian holds a ring of keys the agent can never touch." +--- + + +The previous post put untrusted code in a hardware-isolated VM, and ended on a caveat: isolation contains an +*escape*, but it does nothing about an agent **misusing a tool it was legitimately given**. If you hand an AI +agent a database credential so it can be useful, a single bad decision — a prompt injection, a confused chain +of reasoning — spends that credential. The sandbox did its job perfectly and you still got robbed, through the +front door you built. + +So the real question isn't "how do I isolate the agent?" It's "how does the agent get work done *without ever +holding a key*?" + +## The agent holds a capability, not a credential + +The pattern is to put every credential, every tool, and every model endpoint **behind a broker**, and give the +agent only a *capability to ask*. The agent never sees a token. It calls the broker; the broker holds the real +credential, decides whether the request is allowed, and — if it is — performs the action itself and returns the +result. The key never leaves the broker. + +That one inversion changes the threat model completely. A fully compromised agent can now do exactly one thing: +**ask**. And asking is answered by something it can't reach, can't impersonate, and can't bypass. + + +![The two-tier broker flow: capability in, action out, credential never exposed](/diagrams/broker-pattern.svg) + +## Two clusters, two brokers + +The trust boundary is physical, not just logical. The **untrusted executor** (the Kata sandboxes) lives on one +cluster; the **trusted tier** (the brokers, the model gateway, the real credentials) lives on a *separate* +cluster. A total compromise of the executor still can't reach the brokers' secrets except across a policed +network link — there's no shared kernel, no shared API server, nothing to pivot through. + +On the trusted side there are two brokers, deliberately split: + +- A **credential broker** validates the sandbox's identity (a short-lived, signed token unique to the task) and + mints a **capability** — a cryptographically signed, scoped, single-use, expiring grant. Not a credential. A + *permission to ask for one specific thing*. +- A **tool broker** takes that capability, verifies the signature, the scope, and the one-time nonce, and only + then runs the requested tool — using a credential *it* holds. The result comes back; the credential doesn't. + +## A human gates every write + +Reads are one thing. For anything that *changes the world* — creating, deleting, sending — the broker doesn't +just decide on policy. It **stages** the action and pings a human: an Approve/Deny prompt on my phone, carrying +a one-time token bound to the exact task, method, and arguments. Tap approve and it executes; tap deny, or +ignore it, and it doesn't. The gate is **fail-closed**: a timeout is a denial, a replayed token is rejected, an +unknown method is rejected. The default, always, is *no*. + +The load-bearing principle underneath all of it: **enforcement that has to survive a compromised agent lives at +the broker, never at the agent or the orchestrator.** Approval, scope, budgets, write-authority — none of it +lives anywhere the agent's reasoning can touch. The agent can be wrong, jailbroken, or outright hostile, and the +worst-case is still just *a request that gets refused*. + +## Useful and safe at the same time + +It's tempting to think you have to choose: give the agent real power and accept real risk, or lock it down so +hard it can't do anything. The broker pattern is how you get both. The agent is *useful* — it can call real +tools against real systems. It's *safe* — it never holds a key, every write waits on a human, and the moment +something goes wrong, the blast radius is a denied request, not a spent credential. + +Run agents like you'd run any other untrusted input: assume it's compromised, and make sure that assumption is +*boring*. + +*Live as the trusted tier of a two-cluster AI-agent platform: a credential broker and a tool broker holding the +keys, a phone-based human-approval gate on every write, and an agent that — by construction — never sees a +secret.* diff --git a/src/content/blog/init-gating-gpu-readiness.md b/src/content/blog/init-gating-gpu-readiness.md index 5641819..700887c 100644 --- a/src/content/blog/init-gating-gpu-readiness.md +++ b/src/content/blog/init-gating-gpu-readiness.md @@ -8,18 +8,20 @@ heroAlt: "A GPU glows behind a sealed checkpoint gate while a waiting pod-orb is --- -The most common way a GPU workload fails at the edge isn't the model, the driver, or the -network. It's timing. Kubernetes is eager — it will happily schedule your inference pod the -moment a node is `Ready`, which is often *before* the NVIDIA device plugin has advertised -`nvidia.com/gpu`. The pod starts, can't see a GPU, crash-loops, and now your rollout is -poisoned across the fleet. +The most common way a GPU workload fails at the edge isn't the model, the driver, or the network. +It's timing. Kubernetes is eager — it will happily schedule your inference pod the moment a node +reports `Ready`, which is often *before* the NVIDIA device plugin has advertised `nvidia.com/gpu`. +The pod starts, can't see a GPU, crash-loops — and now your rollout is poisoned across the fleet, +on boxes nobody is standing next to. -The fix is to make readiness explicit. Don't trust node-`Ready`; gate on the GPU. +Node-`Ready` answers the wrong question. It says the kubelet is up. It says nothing about whether +the one piece of hardware your workload exists to use is actually there yet. So stop trusting it: +make GPU readiness explicit, and gate on that. -## Gate the schedule, not just the start +## Gate the schedule, then gate the start -A resource request is the first line — a pod that *requests* a GPU won't schedule until the -plugin advertises capacity: +The first gate is free — a resource request. A pod that *requests* a GPU won't schedule until the +device plugin advertises capacity: ```yaml resources: @@ -27,9 +29,10 @@ resources: nvidia.com/gpu: 1 ``` -But on a single-GPU edge node that's recovering from a reboot, you still want a hard check -before the workload does anything expensive. An init container that blocks until the device -is real keeps the main container honest: +That handles the common case. But on a single-GPU edge node recovering from a power cut, there's a +window where the plugin has advertised the device and the driver is still finding its feet — and +you don't want an expensive model load to be the thing that discovers it. So the second gate is an +init container that blocks until the device is demonstrably real, and fails loudly if it never is: ```bash #!/usr/bin/env bash @@ -45,11 +48,21 @@ echo "GPU never became ready" >&2 exit 1 ``` +Two gates, two failure modes closed: the scheduler can't place the pod before capacity exists, and +the workload can't start before the hardware answers. Note the bound — an init gate that waits +forever isn't a gate, it's a hang. Two and a half minutes, then fail loud and let the platform +retry. Fail-closed, never fail-quiet. + ## Why this is the win -Once readiness is gated, the whole class of "pod started before the GPU" failures disappears -— and it disappears *the same way on every node*. That consistency is the real prize at the -edge, where no one is standing next to the box to nurse a bad rollout. +Once readiness is gated, the entire class of "pod started before the GPU" failures disappears — +and it disappears *the same way on every node*. That consistency is the real prize at the edge. +A fix that requires a human to notice, shell in, and nurse a bad rollout doesn't scale past the +first dozen sites; a gate that makes every node converge identically after every reboot does. -The principle generalises: at the edge, **design the dependency, don't hope for it**. The GPU -is just the first dependency worth gating; egress paths and model artifacts are next. +## The principle + +At the edge, **design the dependency — don't hope for it**. Anything your workload cannot run +without deserves an explicit, bounded, fail-loud gate between it and the scheduler's optimism. +The GPU is just the first dependency worth naming; egress paths and model artifacts are next, +and they want the same treatment. diff --git a/src/content/blog/shipping-this-site.md b/src/content/blog/shipping-this-site.md index 5cea651..ef9c750 100644 --- a/src/content/blog/shipping-this-site.md +++ b/src/content/blog/shipping-this-site.md @@ -8,25 +8,28 @@ heroAlt: "A glowing data container travels a luminous rail from a small server r --- -This site is a static Astro build, but how it gets to you is the interesting part. It's -served from my homelab Kubernetes cluster over a Cloudflare Tunnel, deployed the same way I'd -ship anything else: as an immutable image, pinned by digest, reconciled by GitOps. +This site is a static Astro build, and that's the least interesting thing about it. What matters +is how it reaches you: served from my homelab Kubernetes cluster over a Cloudflare Tunnel, shipped +the way I'd ship anything I actually cared about — an immutable image, pinned by digest, reconciled +by GitOps. No special case for "it's just a website." ## The pipeline -1. The site is built and baked into a hardened `nginx-unprivileged` image. -2. The image is pushed to a **self-hosted public Gitea registry** — deliberately separate - from the private instance that holds my infrastructure code. -3. The image digest is pinned in a private `home-ops` repo. -4. **ArgoCD** reconciles that repo onto the cluster. -5. A **Cloudflare Tunnel** exposes exactly one service — this site — outbound-only. +The build is baked into a hardened `nginx-unprivileged` image and pushed to a **self-hosted public +Gitea registry** — deliberately a separate instance from the private one holding my infrastructure +code, so the public artifact and the private estate never share a trust boundary. The image digest +is then pinned in a private `home-ops` repo, **ArgoCD** reconciles that repo onto the cluster, and +a **Cloudflare Tunnel** exposes exactly one service — this site — outbound-only. -No open ports. No server runtime. No registry credential on the cluster, because the public -package is anonymous-pull and the image holds nothing secret. +Follow the chain and notice what's missing. No open ports: the tunnel dials out. No server runtime: +the output is static files behind nginx. No registry credential on the cluster: the public package +is anonymous-pull, and the image holds nothing secret to protect. Every link in the pipeline is +either immutable, declarative, or absent. ## Security as acceptance criteria -The interesting constraint was treating security as a checklist to *pass*, not a vibe: +The discipline that made it work was treating security as a checklist to *pass*, not a vibe to +gesture at. The site didn't ship until every box was ticked: ```text [x] Static output — no server runtime to attack @@ -36,8 +39,14 @@ The interesting constraint was treating security as a checklist to *pass*, not a [x] Outbound-only tunnel, single hostname, no catch-all ``` -## Why bother +A checklist sounds bureaucratic until you notice what it changes: each item is a claim you can +verify, and a failing item blocks the ship. "Pretty secure" isn't a state you can test for. +`grep` finding zero secrets in the bundle is. -Because the site *is* the argument. A platform engineer's portfolio should demonstrate the -discipline it's advertising — and "it's a static page" is no excuse to skip the rigour. The -deployment story is part of the work. +## The principle + +The site *is* the argument. A platform engineer's portfolio should demonstrate the discipline it +advertises, and "it's a static page" is no excuse to skip the rigour — it's the cheapest possible +place to practise it. If the pipeline behind a brochure site is immutable, verified, and +zero-trust, that's not overkill. That's the standard, rehearsed where the stakes are low so it +holds where they aren't. diff --git a/src/content/projects/global-infra-modernisation.md b/src/content/projects/global-infra-modernisation.md index afc5c1f..c14e837 100644 --- a/src/content/projects/global-infra-modernisation.md +++ b/src/content/projects/global-infra-modernisation.md @@ -7,6 +7,7 @@ period: "2019 – 2025" stack: ["VMware / vSphere", "Azure (Blob, AVS)", "Microsoft 365", "SD-WAN", "Aruba ClearPass", "Palo Alto / FortiGate", "Veeam"] featured: false order: 40 +diagram: "globe" --- ## Problem diff --git a/src/content/projects/gpu-as-code.md b/src/content/projects/gpu-as-code.md index 0bfa446..52b9fd3 100644 --- a/src/content/projects/gpu-as-code.md +++ b/src/content/projects/gpu-as-code.md @@ -7,6 +7,7 @@ period: "2025 – Present" stack: ["GPU passthrough", "ESXi", "DCGM Exporter", "Prometheus", "Bash", "Watchdogs"] featured: false order: 30 +diagram: "gpu" --- ## Problem diff --git a/src/content/projects/network-automation-fleet.md b/src/content/projects/network-automation-fleet.md index 3b3c8bc..e61cc1f 100644 --- a/src/content/projects/network-automation-fleet.md +++ b/src/content/projects/network-automation-fleet.md @@ -7,6 +7,7 @@ period: "2019 – 2022" stack: ["Unimus (NCM)", "NetBox (IPAM / SoT)", "Config backup & DR", "Bulk config push", "Credential vaulting + rotation", "Multi-vendor switching"] featured: false order: 45 +diagram: "network-fleet" links: - label: "Unimus" href: "https://unimus.net" diff --git a/src/data/series.ts b/src/data/series.ts index 5e76abb..3c5982b 100644 --- a/src/data/series.ts +++ b/src/data/series.ts @@ -2,6 +2,6 @@ // The security series threads the zero-trust arc across posts as they publish. export const series: Record = { "secret-zero": { name: "SECURITY SERIES", number: 1 }, + "broker-pattern": { name: "SECURITY SERIES", number: 2 }, "workload-least-privilege": { name: "SECURITY SERIES", number: 3 }, - // broker-pattern → 02 when it publishes (currently a draft in the workshop) };