<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.3.2">Jekyll</generator><link href="https://bradleybeddoes.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://bradleybeddoes.com/" rel="alternate" type="text/html" /><updated>2026-09-16T02:30:41+00:00</updated><id>https://bradleybeddoes.com/feed.xml</id><title type="html">Bradley Beddoes</title><subtitle>Practical notes on software, cloud infrastructure, security, and AI-assisted development from a CTO with two decades in the trenches.</subtitle><entry><title type="html">Pasting images straight into a devcontainer</title><link href="https://bradleybeddoes.com/writing/pasting-images-straight-into-a-devcontainer" rel="alternate" type="text/html" title="Pasting images straight into a devcontainer" /><published>2026-09-16T02:00:00+00:00</published><updated>2026-09-16T02:00:00+00:00</updated><id>https://bradleybeddoes.com/writing/pasting-images-straight-into-a-devcontainer</id><content type="html" xml:base="https://bradleybeddoes.com/writing/pasting-images-straight-into-a-devcontainer"><![CDATA[<p>I’ve written previously about <a href="https://github.com/bradleybeddoes/devcontainer-bridge" target="_blank" rel="noopener noreferrer">devcontainer-bridge</a>, the small Rust tool I built to bring automatic port forwarding and host browser opening to terminal-based devcontainer workflows.</p>

<p>Since then, another piece of friction has kept bothering me: screenshots.</p>

<p>My development environment runs inside a devcontainer, usually with tmux and an AI coding agent. The agent can work with images, but the clipboard belongs to my Mac. Getting a screenshot across that boundary meant saving it somewhere, copying it into the container, finding the path, and then passing that path to the agent.</p>

<p>None of those steps are difficult. Together, they interrupt the flow often enough to be annoying.</p>

<p>So <code>dbr</code> can now do this:</p>

<pre><code class="language-sh">dbr paste
</code></pre>

<p>Which produces something like:</p>

<pre><code class="language-text">/home/vscode/.cache/dbr/paste/image-a1b2c3/clipboard.png
</code></pre>

<p>That’s it. The image on the host clipboard is now a private file inside the devcontainer, ready for any tool that accepts a local image path.</p>

<h2 id="how-it-works">How it works</h2>

<p><code>dbr paste</code> connects to the existing host daemon and requests the current clipboard image. The host reads the clipboard, validates the result, and transfers the original PNG or JPEG bytes into the container.</p>

<p>Each image is written to a unique directory beneath <code>~/.cache/dbr/paste</code>. On Unix, the directory is created with <code>0700</code> permissions and the image with <code>0600</code> permissions. Partial or failed transfers do not leave image files behind.</p>

<p>On macOS, <code>dbr</code> can also convert TIFF clipboard data to PNG. This matters because copying pixels from applications such as Preview does not always put a PNG representation on the pasteboard.</p>

<p>Copying an image file in Finder is different again. Finder provides both a file reference and rendered image data. My first implementation happily selected the rendered data and transferred Finder’s generic PNG file icon rather than the contents of the file.</p>

<p>Technically an image. Not quite the image I wanted.</p>

<p>The current implementation detects a single PNG or JPEG file copied in Finder, reads the file itself, and validates its byte signature before transferring it. Unsupported files, multiple selections, unreadable files and oversized images fail explicitly rather than silently falling back to the icon.</p>

<h2 id="using-it-from-tmux">Using it from tmux</h2>

<p>The command can also insert the resulting path directly into a tmux pane:</p>

<pre><code class="language-tmux">bind-key V run-shell -b 'dbr paste --tmux-target "#{pane_id}" &gt;/dev/null'
</code></pre>

<p>With that in my container’s tmux configuration, I can copy a screenshot on the Mac, press my tmux prefix followed by <strong>Shift-V</strong>, and the quoted image path appears at the cursor.</p>

<p>It deliberately does not press Enter. The receiving application decides how to use the path.</p>

<p>This has been particularly useful when working with coding agents. I can capture a broken interface, paste the image path into the conversation, and continue without leaving the terminal.</p>

<h2 id="enabling-clipboard-access">Enabling clipboard access</h2>

<p>Clipboard sharing is opt-in. On the host, add this to <code>~/.config/dbr/config.toml</code>:</p>

<pre><code class="language-toml">[clipboard]
enabled = true
</code></pre>

<p>Then restart the daemon:</p>

<pre><code class="language-sh">dbr restart
</code></pre>

<p>Install or upgrade the host binary with:</p>

<pre><code class="language-sh">curl -fsSL \
  https://github.com/bradleybeddoes/devcontainer-bridge/releases/latest/download/install.sh \
  | bash
</code></pre>

<p>The host and container must share the existing <code>dbr</code> authentication token. Clipboard transfers are limited to 20 MiB and only one transfer is allowed at a time.</p>

<p>There is no continuous clipboard synchronisation and no support for arbitrary clipboard contents. The client must explicitly request an image each time.</p>

<p>There is also no per-request confirmation on the host. Any client with the host token can request a supported clipboard image while the feature is enabled. Transfers use the bridge’s existing unencrypted TCP connection, so its ports should remain within the trusted host and container network.</p>

<p>Successful images remain in the container until removed. This is intentional—the receiving tool may need the file after <code>dbr paste</code> exits—but it means the cache should be cleaned up when those images are no longer required.</p>

<h2 id="a-small-feature-with-an-outsized-effect">A small feature with an outsized effect</h2>

<p>Clipboard image transfer is not a large feature, but it removes another boundary between the host and the development environment.</p>

<p>Ports appear on the host automatically. Browser links open in the right place. Unix sockets can cross the container boundary. Now screenshots and copied image files can move in the other direction with one command.</p>

<p>The best developer tooling often works like this. It does not introduce a new workflow. It removes the awkward parts from the workflow you already have.</p>

<p>The paste functionality is available in the latest <a href="https://github.com/bradleybeddoes/devcontainer-bridge/releases/latest" target="_blank" rel="noopener noreferrer">devcontainer-bridge release</a>. The full setup, tmux integration and security details are in the <a href="https://github.com/bradleybeddoes/devcontainer-bridge/blob/main/docs/clipboard.md" target="_blank" rel="noopener noreferrer">clipboard guide</a>.</p>

<p>If you’re using devcontainers from the terminal, I’d genuinely appreciate the feedback. Issues and contributions are welcome on <a href="https://github.com/bradleybeddoes/devcontainer-bridge" target="_blank" rel="noopener noreferrer">GitHub</a>.</p>]]></content><author><name></name></author><category term="rust" /><summary type="html"><![CDATA[Clipboard image transfer is not a large feature, but it removes another boundary between the host and the development environment.]]></summary></entry><entry><title type="html">Building WordBattle with an AI team</title><link href="https://bradleybeddoes.com/writing/building-wordbattle-with-an-ai-team" rel="alternate" type="text/html" title="Building WordBattle with an AI team" /><published>2026-04-06T23:00:00+00:00</published><updated>2026-04-06T23:00:00+00:00</updated><id>https://bradleybeddoes.com/writing/building-wordbattle-with-an-ai-team</id><content type="html" xml:base="https://bradleybeddoes.com/writing/building-wordbattle-with-an-ai-team"><![CDATA[<div class="not-prose rounded-lg border border-rule border-l-2 border-l-[color:var(--accent)] px-5 py-4">
  <p class="text-sm text-muted">
    <strong class="font-semibold text-[color:var(--accent)]">Want to play?</strong> <a href="https://wordbattle.fun/" target="_blank" rel="noopener noreferrer" class="link">wordbattle.fun</a> is free and no login is required. Come back here when you want to know how it was built.
  </p>
</div>

<p>About a year ago, I ran an experiment. I wanted to see if AI models could build a six-letter Wordle clone from scratch while I stayed out of the code entirely. The rules were simple: use web technologies I didn’t know (JavaScript/TypeScript), accept every code change the models suggested without questioning it, and guide the project purely through text and screenshots. GPT-4 and Claude 3.5 Sonnet in Cursor did the work.</p>

<p>It was a fun failure. The models made reasonable progress on the frontend and got data into SQLite, but they couldn’t handle the communication between frontend and backend. When they got stuck, they’d propose the same two or three fixes over 10 or 12 attempts. I’d switch between models to break the loop. Eventually the game board stopped loading, the browser spewed errors, and the backend wasn’t much better. We didn’t get anywhere near authentication, user accounts, teams, or any of the other features I’d hoped to include.</p>

<p>But I didn’t have to retire to be an onion farmer 🧅. Not in early 2025, anyway.</p>

<p>Fast forward to early 2026. The tooling had changed substantially. I picked the project back up with <a href="https://docs.anthropic.com/en/docs/claude-code" target="_blank" rel="noopener noreferrer">Claude Code</a> and the same starting constraint: technologies I didn’t know, so the code had to come from the agent while I guided the product. Six weeks later, <a href="https://wordbattle.fun" target="_blank" rel="noopener noreferrer">WordBattle</a> is a production multiplayer word game with over 1,300 commits. Maybe now it is time to become an onion farmer 🧅?</p>

<h2 id="what-is-wordbattle">What is WordBattle?</h2>

<p>A daily 6-letter word guessing game, similar in spirit to Wordle. Every player globally gets the same word each day, resetting at UTC midnight. You get six guesses. Each guess tells you which letters are correct, present but misplaced, or absent. The daily word is never sent to the client. Guesses are submitted to the server, which returns only the per-letter evaluation. You can’t cheat by opening DevTools.</p>

<p>Where it diverges from Wordle is in the multiplayer and AI dimensions. Players form teams. Leaderboards track individual and team performance. There are some fun touches: you can edit individual letters in your guess without deleting the entire word, and teams can hook directly into Slack or Microsoft Teams messaging to keep the competition flowing.</p>

<p>AI agents get to play too. They can create accounts, join teams, and compete on the same leaderboards as human players. My Claude Opus 4.6 agent wakes itself up on a daily schedule to play, then complains about how it lost because its rules are harder (agents face tougher constraints). I recently relented and allowed it to start using its local memory. I suspect it won’t be long until it optimises itself and starts winning.</p>

<p>I’m already in fierce competitions at home and at work.</p>

<h2 id="what-changed-between-attempt-one-and-attempt-two">What changed between attempt one and attempt two</h2>

<p>The first attempt failed because the models had no persistence, no conventions, and no ability to understand the project as a whole. Each prompt was effectively starting fresh. When the frontend and backend needed to talk to each other correctly, the models couldn’t hold enough context to get the integration right. They’d fix one side and break the other, endlessly.</p>

<p>Three things made the second attempt work:</p>

<ol>
  <li><strong>Claude Code as a persistent collaborator.</strong> Not a prompt-and-forget code generator, but a development tool that maintains context within a session, reads the entire codebase, runs tests, and iterates on its own output.</li>
  <li><strong>A project guide (<code>CLAUDE.md</code>).</strong> Architecture, conventions, patterns, and common mistakes to avoid that Claude reads at the start of every session. More on this below.</li>
  <li><strong>The models got better.</strong> The capability jump in late 2025 was significant. The models that failed at frontend-backend integration a year earlier could now hold the full context of a project, reason about how systems connect, and produce code that worked across boundaries. That gap closed without me doing anything.</li>
  <li><strong>I got better.</strong> Twelve months of constant experimentation with agentic development taught me what works and what doesn’t. I learned how to describe work precisely, when to plan vs when to let the agent explore, how to structure a project guide, when to steer and when to trust the output.</li>
</ol>

<h2 id="choosing-the-stack">Choosing the stack</h2>

<p>I didn’t pick the stack. Claude did.</p>

<p>Before writing any code, I set up Claude as a product manager agent. Through a back-and-forth interview process, I described the game, the audience, the features I wanted, and the constraints (solo maintainer, low cost, simple deployments). The agent worked through the discussion, asked clarifying questions, and formulated a plan that included technology recommendations with trade-offs. I selected the option that seemed the most well-reasoned.</p>

<p><strong>SvelteKit 2 with Svelte 5</strong> was its recommendation for the application framework. Svelte 5’s runes (<code>$state()</code>, <code>$derived()</code>, <code>$effect()</code>) replaced the older store-based reactivity model. The result is more explicit, less magical, and easier to reason about. SvelteKit’s Node adapter meant we could deploy a single binary-like artifact rather than orchestrating multiple services.</p>

<p><strong>SQLite via Drizzle ORM</strong> for persistence. For a game with daily resets and modest concurrent writes, SQLite in WAL mode with a 5-second busy timeout is more than adequate. Drizzle gave us type-safe queries and auto-generated migrations. No database server to manage. The entire state of the application is a single file I can back up with a cron job.</p>

<p><strong>Hetzner Cloud</strong> for hosting. A single cloud instance, no Kubernetes, no containers in production. Caddy as a reverse proxy, Cloudflare Tunnel for ingress (no public ports), and systemd template units for blue/green deployments. Minimal operational overhead. Negligible cost.</p>

<p>Deliberately boring infrastructure. The interesting engineering belongs in the game itself, not the deployment pipeline.</p>

<h2 id="the-development-environment">The development environment</h2>

<p>All development happens inside a devcontainer, managed from the terminal via tmux and Claude Code. If you read my <a href="/writing/making-devcontainers-even-better-in-the-terminal">previous post on devcontainer-bridge</a>, this is where it comes full circle. <code>dbr</code> is a declared devcontainer feature in the project, handling port forwarding and browser opening from the container to my host machine.</p>

<p>The devcontainer itself is more interesting than most. It runs a default-deny network egress firewall using iptables and ipset. On every container start, a firewall script loads a pre-computed IP allowlist and blocks all outbound traffic that isn’t on it. After applying the rules, it verifies the firewall works by confirming it can reach GitHub’s API and cannot reach example.com. IPv6 is blocked entirely to prevent bypass.</p>

<p>This is what makes it safe to run Claude Code with permissions that would otherwise be reckless. Inside the container, I let Claude run autonomously, accepting file edits and command execution without prompting me for approval. The firewall means the agent can’t reach anything I haven’t explicitly allowed, regardless of what it tries to do. The security boundary is the container isolation and the network, not a permission dialog.</p>

<p>The container installs Claude CLI automatically during setup, provisions GitHub SSH keys, and pulls in my personal dotfiles on start. A typical session is <a href="https://ghostty.org" target="_blank" rel="noopener noreferrer">Ghostty</a> fullscreen on my Mac with <a href="https://github.com/tmux/tmux" target="_blank" rel="noopener noreferrer">tmux</a>, Claude Code in the main pane and daemon terminals in side panes. That’s it. No IDE. The performance win is real: CPU and memory that would go to an IDE goes to Claude’s agent teams instead. When I need to read code, which was rare, there’s my full <a href="https://neovim.io" target="_blank" rel="noopener noreferrer">Neovim</a> setup inside the container.</p>

<h2 id="the-project-guide">The project guide</h2>

<p><code>CLAUDE.md</code> is a project guide that Claude reads at the start of every session. It covers the project layout, naming conventions, database patterns, authentication flow, accessibility standards, testing expectations, deployment procedures, and a list of common mistakes to avoid.</p>

<p>It didn’t arrive fully formed. It grew alongside the project. I’d write sections based on decisions we’d made, Claude would suggest additions when it noticed patterns emerging or spotted gaps that were causing inconsistency. Some entries came from mistakes we made early on that I didn’t want repeated. By the end, it was 280 lines co-authored by both of us.</p>

<p>This was probably the most important artefact in the project. Without it, Claude’s output drifts. With it, the code stays coherent across sessions.</p>

<p>A few examples of what’s in there:</p>

<ul>
  <li><strong>Svelte 5 only.</strong> Claude’s training data includes Svelte 4 patterns. Without an explicit instruction, it defaults to <code>$:</code> reactive declarations and Svelte stores. The project guide says “Use runes. No stores. No Svelte 4 patterns.” Problem solved.</li>
  <li><strong>Database conventions.</strong> Integer primary keys, ISO 8601 timestamps, snake_case columns mapped to camelCase in TypeScript. Migrations auto-generated by Drizzle. Claude follows this consistently because it’s written down.</li>
  <li><strong>Agent API parity.</strong> The REST API and MCP server must expose identical functionality. If you add a feature to one, you add it to the other. The OpenAPI spec and A2A discovery card must be updated simultaneously. The kind of rule that prevents drift and is easy to forget without a document enforcing it.</li>
</ul>

<h2 id="plan-then-build">Plan, then build</h2>

<p>The work split into two distinct modes.</p>

<p><strong>Planning sessions</strong> came first. These were long, collaborative conversations with Claude in planning mode, sometimes running for a few hours. We’d work through what needed to get done: feature scope, edge cases, data model changes, how a new capability should interact with existing systems, security considerations. By the end of a planning session, we’d have a clear picture of the work broken into discrete tasks.</p>

<p><strong>Implementation</strong> happened next, using Claude Code’s agent teams. The plan would get handed to a team of agents that worked through the tasks, writing code, tests, database migrations, and documentation, often across multiple files simultaneously. Every commit in the repository is co-authored.</p>

<p>We didn’t just build technology this way. The website copy, the UI design, the security model, WCAG compliance, the deployment design, the operational automation. All of it was planned and iterated using agents. I could describe what I wanted the landing page to say, how the onboarding flow should feel, what the error messages should communicate.</p>

<p>Features that would take me a full day to build solo (authentication, team management, leaderboard ranking algorithms, webhook integrations) came together in hours. Not because the agents wrote perfect code on the first try, but because the planning was thorough and the feedback cycle was tight. Plan, build, review, fix, commit. Repeat.</p>

<h2 id="where-i-had-to-steer">Where I had to steer</h2>

<p><strong>Architecture.</strong> Claude can implement a feature, but it doesn’t know which feature to implement next. It doesn’t know that the leaderboard should hide detailed results until after midnight to prevent information leakage between players. These design decisions require understanding the game, the players, and the incentive structures.</p>

<p><strong>Visual design.</strong> Claude can implement a design, but the decisions about what looks right, what feels good to interact with, what creates the right mood for a daily word game, those came from me. I created a design audit document and worked through it iteratively with Claude to refine the interface.</p>

<p><strong>Security judgement.</strong> The security audit was where Claude got it the most wrong. I set up a security review agent to assess the application, and its initial recommendations were corporate boilerplate. It demanded MFA for a free word game. It flagged the absence of OAuth2 integration as a critical risk. Meanwhile, it completely missed rate limiting on public endpoints, which is one of the first things you need on anything exposed to the open internet.</p>

<p>It wasn’t paying attention to what kind of application it was looking at. It took significant refinement, drawing on my own security background, to get the audit agent producing recommendations that were proportionate to the actual threat model. The layered rate limiting, the bot detection, the anti-enumeration measures, those all came from steering the agent towards what actually matters for a public web game rather than what a compliance checklist says.</p>

<p><strong>Knowing when to stop.</strong> Claude will happily add features forever. Error handling for scenarios that can’t happen. Configuration for things that don’t need to be configurable. Abstractions for code that’s used exactly once. Recognising when the code is done, not just “passing tests” done, requires judgement I’m not ready to delegate.</p>

<p>You could look at this and say it’s not that different from any experienced product person working with a development team. And you’d be right about the skills involved. The difference is that the work doesn’t stop when I do. Agent teams run while I’m asleep. I’d finish a planning session in the evening, hand off the tasks, and wake up to a product that had moved forward overnight. That cadence changes what a single person can ship.</p>

<h2 id="the-triple-protocol-agent-api">The triple-protocol agent API</h2>

<p>One of the more interesting technical decisions was exposing three integration surfaces for AI agents:</p>

<p><strong>REST API</strong> (<code>/agent/v1/*</code>): Standard CRUD endpoints with Bearer token authentication. This is what you’d use if you’re building a custom integration or a bot that plays WordBattle.</p>

<p><strong>MCP Server</strong> (<code>POST /mcp</code>): A JSON-RPC 2.0 endpoint with 20+ tools that mirror the REST API. No SDK dependency, just pure JSON-RPC. This is how Claude Code and other MCP-aware agents interact with the game. Each tool has Anthropic-standard annotations for read-only, destructive, and idempotent hints.</p>

<p><strong>A2A Discovery</strong> (<code>/.well-known/agent-card.json</code>): An agent-to-agent discovery card that lets third-party agents automatically find and understand WordBattle’s capabilities.</p>

<p>Early in the project, I designed a service layer based on functional programming concepts that would sit beneath any transport. The game logic, team management, authentication, all of it lives in that layer. The four surfaces (web UI, REST, MCP, A2A) are thin wrappers that call into it. Getting this right early meant adding a new protocol was a thin wrapper, not a rewrite.</p>

<p>Agents face a 5-second server-enforced cooldown between guesses to prevent brute-force solving. They also require email verification before login, just like human players. The leaderboard treats everyone equally. An agent that solves the puzzle in two guesses ranks above a human who took four.</p>

<h2 id="anti-cheat-and-security">Anti-cheat and security</h2>

<p>This was a priority from the start.</p>

<p>The daily word is generated server-side using a seeded PRNG. The seed combines a secret value with the day number (days since epoch, March 27, 2026). The word list is a curated subset of the ENABLE dictionary, 6-letter words only, shuffled deterministically using Fisher-Yates. Linear probing skips any word that’s been used before, giving roughly nine years of unique daily words before the sequence would repeat.</p>

<p>Guesses are evaluated server-side using a two-pass algorithm: first pass marks exact position matches and decrements available letter counts, second pass marks present-but-misplaced letters from the remaining pool. This correctly handles duplicate letters, which is where most naive Wordle implementations get it wrong.</p>

<p>Bot protection for human registration uses layered passive detection: a honeypot field positioned off-screen with CSS (not <code>display: none</code>, which bots have learned to detect), time-based detection with HMAC-signed timestamps that reject submissions completed in under two seconds, and a blocklist of approximately 121,000 known disposable email domains.</p>

<p>Rate limiting operates at three layers: Cloudflare WAF at the edge, SvelteKit middleware with per-endpoint escalating lockouts, and Caddy body size limits. Defence in depth, as it should be.</p>

<h2 id="deployment-deliberately-simple">Deployment: Deliberately simple</h2>

<p>The deployment pipeline uses blue/green zero-downtime swaps:</p>

<ol>
  <li>Determine which slot (blue or green) is currently active</li>
  <li>Pull latest code into the standby slot</li>
  <li>Run database migrations</li>
  <li>Health check the standby instance</li>
  <li>Hot-swap Caddy’s upstream to the standby slot</li>
  <li>Stop the old slot</li>
</ol>

<p>This is orchestrated by a shell script that GitHub Actions triggers on merge to main. The script, the smoke tests, and the backup automation all pass <code>shellcheck</code> with no warnings, another convention enforced by the <code>CLAUDE.md</code>.</p>

<p>Monitoring uses Sentry with PII scrubbing, configured to never identify individual users. Claude has explicit access to Sentry error data and uses it to diagnose production issues directly. Agent ops, effectively. I’m still figuring out how this works and looks longer term.</p>

<p>Daily SQLite backups go to Hetzner Object Storage via a cron-driven script. Cloud snapshots provide an additional recovery layer. The whole application can be rebuilt from scratch in under an hour.</p>

<h2 id="whats-next">What’s next</h2>

<p>The agentic team model works, but it’s early. Memory is the biggest gap right now. Claude’s project guide gives it project knowledge, but session-to-session memory is still primitive. I want agents that remember the last three planning sessions, know which approaches we tried and rejected, and can brief a new agent team without me repeating context. That’s coming, but it’s not here yet.</p>

<p>I’m also thinking about how this scales beyond a solo builder. At <a href="https://macuject.com" target="_blank" rel="noopener noreferrer">Macuject</a>, the MedTech startup where I’m CTO, I lead people. Each of those people will eventually have their own agentic teams. The interesting question isn’t “how does one person work with AI?” It’s how human teams share context, conventions, and institutional knowledge across their respective agent teams without it becoming a mess. The project guide is a starting point, but it won’t be the whole answer.</p>

<p>And of course, newer models keep arriving. Every few months the capabilities shift and the patterns I’ve established need revisiting. The workflow that produced WordBattle will probably look different six months from now. That’s fine. The point was never to find the final process. It was to find a process that ships products now and builds my knowledge so that I can adapt as the state of the art changes rapidly.</p>

<p>So I don’t need to become an onion farmer 🧅 just yet, but 2027 is going to come fast.</p>

<p>WordBattle is live at <a href="https://wordbattle.fun" target="_blank" rel="noopener noreferrer">wordbattle.fun</a>. If you’d like to play, or connect an AI agent to compete, I’d welcome your company. <a href="https://wordbattle.fun/teams/join/ceea665db384cb0620c5afb6cadc5e63" target="_blank" rel="noopener noreferrer">Join the readers team</a> and let’s see who’s best!</p>]]></content><author><name></name></author><category term="ai" /><summary type="html"><![CDATA[A year ago I tried to build a word game with AI and it was a fun failure. This time, with Claude Code as a persistent co-developer, the result is a production multiplayer game where AI agents compete alongside humans.]]></summary></entry><entry><title type="html">Making devcontainers even better in the terminal</title><link href="https://bradleybeddoes.com/writing/making-devcontainers-even-better-in-the-terminal" rel="alternate" type="text/html" title="Making devcontainers even better in the terminal" /><published>2026-02-16T04:00:00+00:00</published><updated>2026-02-16T04:00:00+00:00</updated><id>https://bradleybeddoes.com/writing/making-devcontainers-even-better-in-the-terminal</id><content type="html" xml:base="https://bradleybeddoes.com/writing/making-devcontainers-even-better-in-the-terminal"><![CDATA[<p>I’ve been using <a href="https://containers.dev/" target="_blank" rel="noopener noreferrer">devcontainers</a> for development work and I’m a fan. Consistent environments, isolated dependencies, everything defined in a <code>devcontainer.json</code> that the whole team shares. The <a href="https://github.com/devcontainers/cli" target="_blank" rel="noopener noreferrer">devcontainer CLI</a> is the foundation of this, and if like me you prefer working in a terminal rather than VS Code, it’s been a solid companion.</p>

<p>Except for two things that are really annoying:</p>

<p><strong>1. Port forwarding:</strong> When a process inside a devcontainer listens on a port (say, a dev server on <code>:3000</code>), VS Code automatically makes it accessible on your host machine. The devcontainer CLI does not.</p>

<p><strong>2. Browser opening:</strong> When a container process tries to open a URL (think OAuth callbacks, documentation links), VS Code opens it in your host browser. The devcontainer CLI cannot.</p>

<p>These two gaps break real workflows. OAuth flows that bind a random port, open a browser, and expect a callback on <code>localhost</code> fail completely. Dev servers are unreachable without manual port forwarding in <code>devcontainer.json</code>.</p>

<p>I built <a href="https://github.com/bradleybeddoes/devcontainer-bridge" target="_blank" rel="noopener noreferrer">devcontainer-bridge</a> (<code>dbr</code>) to fix both.</p>

<h2 id="how-dbr-works">How dbr works</h2>

<p><code>dbr</code> uses a reverse connection model (similar to <code>ssh -R</code>) where all TCP connections flow container to host. The container daemon initiates everything outward, and the host daemon multiplexes over those connections.</p>

<pre><code>┌───────────────────── Host Machine ──────────────────────────────────┐
│                                                                     │
│  dbr host-daemon                                                    │
│  ├─ Control: :19285 (JSON-lines protocol)                           │
│  ├─ Data:    :19286 (reverse data connections)                      │
│  ├─ Binds loopback:PORT for each forwarded port                     │
│  └─ Opens URLs in host browser (open/xdg-open)                      │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘
          ▲ All connections initiated container → host
          │ via host.docker.internal
┌─────────┴────────── Devcontainer ───────────────────────────────────┐
│                                                                     │
│  dbr container-daemon                                               │
│  ├─ Polls /proc/net/tcp every 1s for new listeners                  │
│  ├─ Sends Forward/Unforward to host via control channel             │
│  └─ Opens reverse data connections for proxied traffic              │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘
</code></pre>

<p>The two daemons communicate over a JSON-lines protocol on a persistent control channel. A separate data channel carries the actual proxied TCP traffic.</p>

<p>The container daemon polls <code>/proc/net/tcp</code> every second to detect new listening ports (the same approach VS Code uses internally). When it finds one, it tells the host daemon to bind a loopback listener on the matching port. When traffic arrives on the host side, it’s proxied through to the container via a reverse data connection. One host daemon serves all your running devcontainers, handling port conflicts automatically by assigning alternative host ports when needed.</p>

<p>For browser opening, setting <code>BROWSER=dbr-open</code> in your container shell profile is all it takes. When a tool calls <code>$BROWSER</code>, the URL gets sent to the host daemon, which opens it with <code>open</code> (macOS) or <code>xdg-open</code> (Linux). URLs are even rewritten automatically if a container port has been remapped to a different host port, which keeps OAuth callbacks working even when ports shift.</p>

<h2 id="getting-started">Getting started</h2>

<p>Setup is straightforward.</p>

<ol>
  <li>
    <p>Install the host binary, or <a href="https://github.com/bradleybeddoes/devcontainer-bridge#building-from-source" target="_blank" rel="noopener noreferrer">build from source</a> if you prefer.</p>

    <pre><code class="language-bash"> curl -fsSL \
   https://github.com/bradleybeddoes/devcontainer-bridge/releases/latest/download/install.sh \
   | bash
</code></pre>
  </li>
  <li>
    <p>Start the host daemon</p>

    <pre><code class="language-bash"> dbr host-daemon
</code></pre>

    <p>This runs in the foreground. If you’d prefer something you can drop into a shell alias or dotfile, <code>dbr ensure</code> checks whether the host daemon is already running and starts it if not.</p>
  </li>
  <li>
    <p>Add the devcontainer feature to your project</p>

    <pre><code class="language-json"> {
   "features": {
     "ghcr.io/bradleybeddoes/devcontainer-bridge/dbr:0": {}
   }
 }
</code></pre>

    <p>This installs <code>dbr</code> inside the container and starts the container daemon automatically on boot via the feature’s entrypoint. No manual setup inside the container.</p>
  </li>
  <li>
    <p>Configure browser integration</p>

    <p>Add this to your <code>~/.zshrc</code> or <code>~/.bashrc</code> inside the container (via your personal dotfiles):</p>

    <pre><code class="language-bash"> export BROWSER=dbr-open
</code></pre>

    <p>Most tools respect the <code>BROWSER</code> environment variable: Node.js <code>open</code>, Python <code>webbrowser</code>, Rust’s <code>open</code> crate, and others.</p>
  </li>
  <li>
    <p>Verify</p>

    <pre><code class="language-bash"> $ dbr status
   
 Container       Port   Host Port  Process    Since
 myapp_dev       8080   8080       node       2m ago
 myapp_dev       39821  39821      mcp-auth   5s ago
</code></pre>

    <p>That’s it. Ports are forwarded, browsers open on the host.</p>
  </li>
</ol>

<h2 id="safe-for-mixed-teams">Safe for mixed teams</h2>

<p>If you’re working on a team, you might be wondering whether adding this to a shared config is safe. One of the design constraints was that <code>dbr</code> needed to be safe to add to shared <code>devcontainer.json</code> files in teams where some people use VS Code and others use the CLI.</p>

<p><strong>VS Code users are not impacted.</strong> The <code>dbr</code> binary is installed but the system is effectively inert without the host daemon running. It doesn’t set global environment variables or interfere with VS Code’s own port forwarding. Think of it like having <code>nvim</code> installed but unused.</p>

<p>The container daemon does run in the background (started by the feature’s entrypoint), but it only initiates outbound TCP connections. It binds no ports inside the container. If no host daemon is running, it reconnects silently with exponential backoff and negligible resource cost.</p>

<p>Terminal developers activate <code>dbr</code> through personal dotfiles and shell configuration, not shared project files. VS Code developers won’t notice it’s there.</p>

<h2 id="security-considerations">Security considerations</h2>

<p>Given my <a href="/writing/overview-of-security-and-compliance-in-medtech">background in security and compliance</a>, this was something I thought about from the start.</p>

<p><code>dbr</code> uses a two-tier binding model. Forwarded port listeners always bind to loopback only (<code>127.0.0.1</code> / <code>[::1]</code>), never <code>0.0.0.0</code>. Your forwarded dev server is only reachable from your machine, not the network. The control and data ports bind to <code>0.0.0.0</code> only when Docker is detected (necessary for Docker Desktop on macOS, where containers reach the host via a gateway IP) and fall back to loopback otherwise. Override with <code>--bind-addr</code> or <code>--no-docker-detect</code> if you need explicit control.</p>

<p>The host daemon accepts a fixed set of protocol messages. A container cannot instruct the host to run arbitrary commands. The only host-side actions are binding loopback ports and opening validated HTTP/HTTPS URLs. URLs are passed directly as process arguments (not through a shell), so there’s no command injection surface.</p>

<p>Resource limits are enforced throughout: 64 KB message cap, max 64 concurrent containers, max 128 forwards per container, rate-limited URL opens. The entire project uses zero <code>unsafe</code> Rust code.</p>

<p>The full threat model is documented in <a href="https://github.com/bradleybeddoes/devcontainer-bridge/blob/main/docs/security.md" target="_blank" rel="noopener noreferrer">security.md</a>.</p>

<h2 id="building-it-with-claude-code-agent-teams">Building it with Claude Code agent teams</h2>

<p>This section is a bit of a departure from the tool itself, but it’s relevant to my ongoing exploration of AI-assisted development.</p>

<p>I built <code>dbr</code> as an exploratory collaboration with <a href="https://docs.anthropic.com/en/docs/claude-code" target="_blank" rel="noopener noreferrer">Claude Code</a> agent teams. I did the planning and design work: defining the problem, choosing the technology (Rust, tokio for async, serde for the protocol), sketching the architecture, and writing the specification. Then I handed the implementation to a team of 17 AI agents.</p>

<p>The agents created the codebase, tests, documentation, and end-to-end validation scripts. They did exceptionally well. But the application didn’t function straight out of the box, which I expected given the scope. I then spent a few more hours pair programming with Claude Code to debug, refine, and get everything working.</p>

<p>A few honest observations from the experience:</p>

<p><strong>What worked well:</strong> The agents produced well-structured Rust code that followed the architecture I’d specified. The protocol implementation, the <code>/proc/net/tcp</code> parser, the async connection handling, the CLI, the documentation — all were high-quality starting points. Having 17 agents work in parallel meant the initial codebase came together remarkably quickly.</p>

<p><strong>What needed human attention:</strong> Integration between systems was where things fell apart. The way components connected had subtle bugs. The kind of issues you only find by actually running end to end. We’ve since created a full e2e script that invokes multiple containers so this won’t happen again — perhaps a necessary part of the plan going forward for any networked tool.</p>

<p><strong>My takeaway:</strong> AI agent teams are a genuinely useful tool for bootstrapping projects, especially when you invest in clear specification and architecture upfront. The better your input, the better their output. But “ship it and walk away” isn’t where we are yet. That may well change as the technology continues to improve and we learn more about optimising inputs. I intend to keep experimenting both personally and across teams I lead.</p>

<h2 id="early-days">Early days</h2>

<p>This project is new and in active development. It’s been tested on macOS only so far; Linux host support is implemented but unverified. Bugs are expected.</p>

<p>If you’re a terminal-first devcontainer user and want to give it a try, I’d genuinely appreciate the feedback. Issues and contributions are welcome at <a href="https://github.com/bradleybeddoes/devcontainer-bridge" target="_blank" rel="noopener noreferrer">github.com/bradleybeddoes/devcontainer-bridge</a>.</p>]]></content><author><name></name></author><category term="rust" /><summary type="html"><![CDATA[The devcontainer CLI doesn't forward ports or open browsers on the host. I built a Rust tool called dbr to fix both, with help from Claude Code agent teams.]]></summary></entry><entry><title type="html">Speed vs. Safety: Building developer experience in a MedTech startup</title><link href="https://bradleybeddoes.com/writing/building-developer-experience-in-medtech" rel="alternate" type="text/html" title="Speed vs. Safety: Building developer experience in a MedTech startup" /><published>2025-12-05T10:35:21+00:00</published><updated>2025-12-05T10:35:21+00:00</updated><id>https://bradleybeddoes.com/writing/building-developer-experience-in-medtech</id><content type="html" xml:base="https://bradleybeddoes.com/writing/building-developer-experience-in-medtech"><![CDATA[<p>When I first started thinking about how to build a great developer experience while maintaining rigorous compliance standards, I admit to seeing them as competing priorities: speed vs. safety, or, to put it another way, developer autonomy vs. audit bureaucracy.</p>

<p><strong>I was wrong</strong>. What I’ve learned is that great developer experience and strong compliance can genuinely reinforce one another.</p>

<p>As CTO I lead technology and security at <a href="https://macuject.com">Macuject</a>, a MedTech startup developing cutting-edge solutions to end preventable blindness. MedTech comes with numerous compliance requirements, and for us, part of that means ensuring that annual SOC 2 and HIPAA audits are successful.</p>

<p>The key is treating compliance not as a set of hurdles to clear, but as a design challenge, and it works. At Macuject, we consistently achieve annual attestations for SOC2 and HIPAA while our developers ship features quickly and enjoy their work. The secret? Making the compliant path the easy path.</p>

<h2 id="developers-tolerate-friction-they-understand-explain-the-why">Developers tolerate friction they understand: Explain the ‘why’</h2>

<p>Developers tolerate friction they understand. They resent friction that seems arbitrary.</p>

<p>Macuject invests significant time explaining why our compliance requirements exist. In MedTech, we can point to real breaches, real consequences, real harm to patients. When developers understand that the controls they’re working within exist to protect vulnerable people, the whole thing feels different. It’s not bureaucracy; it’s responsibility.</p>

<p>Explaining the why needs to be done well and <strong>often</strong>. A session during onboarding is essential; continuing to talk about the benefits and reasons on an ongoing basis is invaluable for keeping the understanding front of mind.</p>

<h2 id="gates-as-confirmations-build-quality-in-first">Gates as confirmations: Build quality in first</h2>

<p>In compliance-heavy environments, there’s a need to add gates that review, scan, and approve work. Gates provide a point of reference when auditors ask how the company ensures security, quality, and reduces risk during product development, providing a detailed history of who did what and when.</p>

<p>However, gates that slow developers down create pressure to circumvent them. Slow feedback loops mean developers have moved on to other work by the time they learn about issues, making fixes more expensive, time-consuming, and frustrating. Remove these issues for developers by ensuring checkers, linters, analysers, and tests run locally in the IDE and before remote git branch pushes, not just in CI. A local verification process that catches an issue immediately beats a pipeline failure every time. The developer is still in context, still thinking about that code, and can fix the problem as part of their normal flow. Of course, local checks can technically be bypassed, but in practice, developers want fast feedback; they use the local tools because they make their lives easier.</p>

<p>We still have gates; these can’t be bypassed. CI runs checkers, linters, analysers, and tests. We require human review of all changes. We need formal, documented User Acceptance Testing (UAT) sign-off by clinical experts for all functional changes. Finally, there is leadership approval for release. However, by the time the code reaches those gates, most issues have already been caught and fixed. The gates become confirmations, not blockers, and work continues to flow.</p>

<p><strong>Tip:</strong> Eliminate low-value decision-making. Find an automatic code style validator, such as <a href="https://github.com/rubocop/rubocop">Rubocop</a>, adopt it, and mandate it for all work. There is no greater waste of expensive engineering time than arguing about syntax during a code review.</p>

<h2 id="the-duality-impatience-to-automate-patience-for-rigour">The duality: Impatience to automate, patience for rigour</h2>

<p>In a previous post, I explored the leadership necessity of <a href="https://bradleybeddoes.com/writing/lead-with-impatience">leading with impatience and the patience to see it through</a>. Our approach to compliance is the practical application of this duality.</p>

<p><strong>Compliance is the patience.</strong> It is the understanding that quality, security, and safety are non-negotiable and take time. It protects patients, doctors, our business, and ultimately our ability to keep doing the work we love.</p>

<p><strong>Developer experience is the impatience.</strong> It is the refusal to accept that “rigour” must equal “slowness,” and the drive to automate friction out of existence.</p>

<p>When you combine these, you get our strategy: Use the <em>impatience</em> of engineering to automate the <em>patience</em> of compliance.</p>

<h2 id="compliance-as-code-automate-the-audit-trail-relentlessly">Compliance as code: Automate the audit trail relentlessly</h2>

<p>If developers have to remember to do something for compliance, they’ll resent it. The resentment isn’t about the compliance requirement itself; it’s about the cognitive load and the interruption to flow.</p>

<p>The solution: Automate relentlessly.</p>

<p>All of our developers adhere to a common approach for two key aspects of their work:</p>

<p><strong>Branch naming:</strong> You likely use something similar <code>&lt;JIRA-KEY&gt;/descriptive-name-here</code>, e.g. `WA-1023/add-photos-to-user-profiles`.</p>

<p><strong>PR templates:</strong> We use a standard PR template across all repositories with headings and checklists related to our business, SOC2, and HIPAA. A PR template like ours takes a slight adjustment for newcomers and adds a couple of minutes to PR creation time. Still, once you get into the flow of it, there aren’t any objections; it helps developers think through the PR and ensure it meets all needs.</p>

<p>With these two primitives in place, we can now automate many tasks. Let me give you some concrete examples of what this looks like from a web application perspective; similar processes exist for our data and platform developers as well.</p>

<h3 id="pull-requests">Pull requests</h3>

<p>We leverage GitHub automations and Jira API connections here when a new PR is opened by a developer.</p>

<ol>
  <li>The PR is automatically linked to Jira based on branch naming, no big deal; this is standard practice.</li>
  <li>PR names are automatically pre-pended with the Jira key. “Adds user profile photos” is automatically modified to “WA-1023: Adds user profile photos”.</li>
  <li>A comment gets added to the PR that provides a convenient link back to the Jira issue.</li>
  <li>Our extensive template is pre-populated, and developers fill in several details, including:
    <ul>
      <li>The level of risk the change poses to the business, e.g., changes that affect authentication or PHI handling, is automatically flagged as high risk and receives extra scrutiny during review.</li>
      <li>Changes needing extra scrutiny during UAT.</li>
      <li>Asks from other teams to make the change successful, e.g., ensuring a firewall change is already in place.</li>
    </ul>
  </li>
  <li>The Jira ticket is automatically moved from “In Development” to “In Review” to align with our Kanban process.</li>
  <li>Checkers, Linters, Analysers and Tests are then invoked, in order from quickest to complete to longest. We always want to exit as quickly as possible when CI detects a problem.</li>
  <li>Once passed, human review is requested, either automatically or dynamically, depending on the type of change.</li>
  <li>Once approved and merged:
    <ul>
      <li>The entire PR thread gets copied to Jira and added as a comment on the original ticket, including any uploaded media such as images. Copying PR descriptive content ensures that those involved in UAT, who may not have access to GitHub, can read the entire background if helpful for their work.</li>
      <li>Unless otherwise tagged, the PR gets assigned to the next release defined in Jira.</li>
      <li>The Jira ticket is automatically moved from “In Review” to “Ready for UAT.” These statuses maintain the necessary separation of duties: developers write and review code. At the same time, clinical experts perform UAT to verify that features function correctly within medical workflows and meet regulatory requirements. Developers cannot sign off on their own UAT. Only after clinical sign-off and final release approval is a ticket marked as “Done.”</li>
    </ul>
  </li>
</ol>

<h3 id="releases">Releases</h3>

<p>When we’re ready to release the web application, a lengthy process ensues that relies on semantic versioning (semver) tagging and git release branches.</p>

<p>Previously, it took half a day to assemble all the pieces we needed to demonstrate compliance manually. Once proven stable and repeatable across multiple releases, we introduced automation here as well.</p>

<p>It now takes 30 minutes to complete the same process for our release owner using a release script and a combination of GitHub and Jira automations.</p>

<ol>
  <li>Every issue assigned to the release has its PR content parsed by the release script:
    <ul>
      <li>Identifying how many changes fall into the low, medium, and high categories.</li>
      <li>Collating all details for changes that need to be considered by the UAT team as additional information for their work in validating the release.</li>
      <li>Likewise, for other teams, collate all the changes required by this release so our release owner can communicate them for scheduling purposes.</li>
    </ul>
  </li>
  <li>An overall release category is calculated based on the identified low, medium, and high risk categories from the associated PRs.</li>
  <li>Using a Jira release template, the release script documents the release, including all the details above.</li>
  <li>The release owner creates customer-facing notes that include product images, videos, and other helpful content to explain the new features and changes. Once fully documented in Confluence, the new page gets amalgamated into the release documentation.</li>
  <li>One or more release candidates are published to our UAT environment, allowing UAT and medical teams to assess, request modifications and finally approve the release.</li>
  <li>Finally, the leadership team must formally approve the release. We can then proceed with the release process:
    <ul>
      <li>Final binaries are built and published to our environments.</li>
      <li>The release gets posted to Slack as a celebration. Some channels receive lower-level technical details, others a higher-level summary, and the customer-facing change notes. Our CEO, COO and other leadership get these as well, which happily automates a small part of my job.</li>
      <li>I’m not covering how releases appear in development, UAT, and production environments in both Australia and the United States. This post is already too long; needless to say, this is also automated, with each environment reporting its deployment status back into Jira.</li>
    </ul>
  </li>
</ol>

<p>The pattern here is consistent: identify the compliance-driven tasks that create friction, then automate them so thoroughly that developers hardly notice they’re happening. The controls exist, the audit trail is available, but the cognitive burden disappears.</p>

<h2 id="infrastructure-as-code-eliminate-drift-enable-compliance">Infrastructure as code: Eliminate drift, enable compliance</h2>

<p>Environmental issues are a classic source of frustration. Code works in development but fails in production. Configuration drift across environments creates mysterious bugs. Managing multiple regions globally while expanding semi-regularly is the stuff of nightmares. When the CFO comes knocking about cloud costs, you end up in a cold sweat.</p>

<p>In a compliance context, this is even more problematic; you need to demonstrate that your production environment has the controls you’ve documented, is secure, considers PII and PHI fully, enforces PoLP, is backed up, can be restored from a clean slate in a complete disaster scenario and can report on every single change made.</p>

<p>Our solution has been to invest heavily in Infrastructure as Code (IaC). We’ve built an extensive AWS CDK project in TypeScript that defines all of our infrastructure. This single codebase sustains Development, UAT, and our Production environments in both Australia and the US. Adding another region becomes an easily quantifiable piece of work, measured in days.</p>

<p>The choice of TypeScript for our CDK project has been particularly valuable. Compile-time type checking catches misconfigurations before deployment (well, usually, it is AWS, ask me about war stories). You find out about problems when you run the build, not when CloudFormation fails halfway through a deployment.</p>

<p>AWS itself provides best practices for the CDK and compliance validation with tooling called <a href="https://github.com/cdklabs/cdk-nag">CDK Nag</a>. CDK Nag allows CI to fail builds unless developers have done the right things or documented a valid exception state, something which our PR reviewers must validate and agree to.</p>

<p>A change to the infrastructure follows the same path as the web application discussed earlier; the only difference is that, instead of a binary, the final output is AWS infrastructure being created, updated, or removed.</p>

<h2 id="beyond-developer-workflow-the-rest-of-the-compliance-picture">Beyond developer workflow: The rest of the compliance picture</h2>

<p>This post focuses on developer workflow automation and on how we’ve made compliance controls feel natural rather than burdensome in day-to-day development. But this is just one piece of a comprehensive compliance program. What I haven’t covered here, but Macuject does rigorously, includes:</p>

<ul>
  <li><strong>Security monitoring and incident response</strong>: Centralised logging, real-time alerting for security events, defined escalation procedures, and regular tabletop exercises.</li>
  <li><strong>Vendor management</strong>: Due diligence reviews, BAA management, annual vendor security assessments.</li>
  <li><strong>Data governance</strong>: Data retention policies, PHI data flow mapping, data deletion procedures.</li>
  <li><strong>Security training</strong>: Regular security awareness and HIPAA training, random phishing simulations.</li>
  <li><strong>Penetration testing and vulnerability management</strong>: Annual pentests, continuous vulnerability scanning, and a defined SLA for patching critical issues.</li>
  <li><strong>Physical and administrative safeguards</strong>: Device management, clean desk policies, and background checks.</li>
</ul>

<p>Make no mistake, obtaining annual attestations for SOC2, HIPAA, and others is no easy task and requires the involvement of the entire business to be successful.</p>

<h2 id="final-thoughts-start-with-the-friction">Final thoughts: Start with the friction!</h2>

<p>If you’re working in a similar environment and feeling the tension between compliance and developer productivity, start by asking your developers what slows them down most. I’d bet that you can solve at least some of these without compromising compliance posture. Often, the solution will strengthen both.</p>

<p>Let’s move the conversation from “How do we pass the audit?” to “How do we design our developer experience to make compliance a feature, not a hassle?”</p>]]></content><author><name></name></author><category term="leadership" /><summary type="html"><![CDATA[Great developer experience and strong compliance aren't opposites. The key is making the compliant path the easy path.]]></summary></entry><entry><title type="html">Lead with the impatience to change the world and the patience to see it through</title><link href="https://bradleybeddoes.com/writing/lead-with-impatience" rel="alternate" type="text/html" title="Lead with the impatience to change the world and the patience to see it through" /><published>2023-09-10T07:24:32+00:00</published><updated>2023-09-10T07:24:32+00:00</updated><id>https://bradleybeddoes.com/writing/lead-with-impatience</id><content type="html" xml:base="https://bradleybeddoes.com/writing/lead-with-impatience"><![CDATA[<p>For the past 23 years, I’ve been fortunate enough to be involved in a fusion of software development, security, cloud and leadership, which has shipped many innovative solutions. Perhaps none more so than in my current role as CTO with <a href="https://macuject.com">Macuject</a>, where our vision is to end preventable blindness, and our AI and CDSS platform highlights retinal disease whilst keeping doctors firmly in the decision-making loop.</p>

<p>After all this time, I still have a lot to discover. However, I have picked up a few things along the way, and in this post, I wanted to share one of those with you, “Lead with the impatience to change the world and the patience to see it through”.</p>

<h2 id="be-impatient-with-actions">Be impatient with actions</h2>

<ol>
  <li>
    <p><strong>Drive innovation</strong>: Software companies thrive on innovation. Be relentless in your pursuit of technological advancement. Support your team to explore new frontiers, experiment with cutting-edge technologies, and challenge the status quo. Be impatient when it comes to taking action on your innovative ideas.</p>
  </li>
  <li>
    <p><strong>Rapid prototyping</strong>: Swiftly move from concept to prototyping. Develop a culture that encourages rapid prototyping, where ideas are tested, refined, and validated as quickly as possible.</p>
  </li>
  <li>
    <p><strong>Agile development</strong>: Embrace agile methodologies in your software development process. Shorter development cycles and more frequent releases enable you to iterate and adapt swiftly to user feedback and changing market conditions. I strongly favour Kanban (a post for another day) in this area.</p>
  </li>
  <li>
    <p><strong>Empower your team</strong>: Impatience with actions doesn’t mean micromanaging. Instead, empower your team to make decisions and take ownership of their work. Encourage autonomy and trust your people to execute with speed and precision.</p>
  </li>
</ol>

<h2 id="be-patient-with-results">Be patient with results</h2>

<ol>
  <li>
    <p><strong>Quality assurance</strong>: Rigorous testing, bug fixes, UAT, security scanning, and other quality assurance processes take time. Be patient when it comes to ensuring your products meet the highest standards of reliability and performance.</p>
  </li>
  <li>
    <p><strong>Compliance</strong>: Ensuring <a href="/writing/overview-of-security-and-compliance-in-medtech">compliance with regulatory standards</a>, relevant to your software companies’ industry of operation is critical and includes standards such as SOC2, ISO27001, GDPR and HIPAA. Be patient as your team diligently works to meet these compliance requirements, knowing that it’s vital for the trust and security of your users’ data. Automate compliance wherever possible, you’ll have to revisit it annually.</p>
  </li>
  <li>
    <p><strong>Long-term impact</strong>: Technological innovations can have a profound and lasting impact on user experiences. Understand that the results of your efforts may take time to be visible. Be patient and committed to the long-term vision of delivering exceptional software.</p>
  </li>
  <li>
    <p><strong>Market penetration</strong>: Breaking into a market can be challenging for software companies. It often involves fierce competition, market fluctuations, and building user trust. Be patient as you work towards establishing a solid market presence.</p>
  </li>
</ol>

<h2 id="conclusion">Conclusion</h2>

<p>Leadership within software companies extends beyond technical prowess. We need the audacity to envision change, the patience to shepherd it into reality, and the compassion to understand the diverse needs of our teams and customers.</p>

<p>Lead with the impatience to change the world and the patience to see it through!</p>]]></content><author><name></name></author><category term="leadership" /><summary type="html"><![CDATA[After 23 years shipping software, I've learned that the best leaders are simultaneously impatient and patient. Here's how I think about balancing the two.]]></summary></entry><entry><title type="html">Solving an obscure AWS S3 access denied error in a cross account scenario</title><link href="https://bradleybeddoes.com/writing/solving-an-obscure-aws-s3-access-denied-error" rel="alternate" type="text/html" title="Solving an obscure AWS S3 access denied error in a cross account scenario" /><published>2023-05-13T01:43:07+00:00</published><updated>2023-05-13T01:43:07+00:00</updated><id>https://bradleybeddoes.com/writing/solving-an-obscure-aws-s3-access-denied-error</id><content type="html" xml:base="https://bradleybeddoes.com/writing/solving-an-obscure-aws-s3-access-denied-error"><![CDATA[<div class="p-2 bg-slate-800 items-center text-slate-100 lg:rounded-md flex lg:inline-flex leading-6 mt-6 mb-0" role="alert">
  <span class="flex rounded-md bg-pink-600 uppercase px-2 py-1 text-xs font-bold mr-3 ml-3">Note!</span>
  <span class="font-semibold ml-3 mr-3 text-left flex-auto">As of April 2023 with <a href="https://aws.amazon.com/about-aws/whats-new/2022/12/amazon-s3-automatically-enable-block-public-access-disable-access-control-lists-buckets-april-2023/" target="_blank" rel="noopener noreferrer">AWS bucket default changes</a> the details in this post will be less relevant. However for buckets created prior to April 2023 you may still experience this.</span>
</div>

<p>At <a href="https://macuject.com">Macuject</a>, we’re building in the <a href="https://aws.amazon.com/">AWS cloud</a>, and one of the critical drivers of our architecture is the <a href="https://aws.amazon.com/architecture/well-architected/">AWS Well-Architected Framework</a>. The framework comprises a set of six pillars that describe key concepts, design principles, and architectural best practices for designing and running workloads in the cloud; these include:</p>

<ul>
  <li>Operational Excellence</li>
  <li>Security</li>
  <li>Reliability</li>
  <li>Performance Efficiency</li>
  <li>Cost Optimization</li>
  <li>Sustainability</li>
</ul>

<p>Within the <a href="https://docs.aws.amazon.com/wellarchitected/latest/security-pillar/welcome.html">security pillar</a>, you’ll find the foundational concept of <a href="https://docs.aws.amazon.com/wellarchitected/latest/security-pillar/aws-account-management-and-separation.html">SEC01-BP01, “account separation”</a>, which is the basis of the problem described here. If this is new to you, AWS has <a href="https://docs.aws.amazon.com/whitepapers/latest/organizing-your-aws-environment/organizing-your-aws-environment.html">a whitepaper</a> that might interest you; you’ll also want to invest time in <a href="https://aws.amazon.com/organizations/">AWS Organizations</a>, <a href="https://aws.amazon.com/controltower/">AWS Control Tower</a>, and <a href="https://aws.amazon.com/iam/identity-center/">AWS Identity Center</a> (IDC). For this post, we’ll define three AWS accounts:</p>

<ul>
  <li><code>AccountA</code> has:
    <ul>
      <li>Users defined in IAM</li>
      <li>AWS Organizations</li>
      <li>AWS Identity Center, along with policies and roles that make everyone’s access secure and adhere to the Principle of Least Privilege (PoLP)</li>
    </ul>
  </li>
  <li><code>AccountB</code> has an S3 bucket called <code>our-shared-widgets</code></li>
  <li><code>AccountC</code> has various pieces of compute used for development</li>
</ul>

<h2 id="all-is-well">All is well</h2>

<p>For some time, our users have been able to access the <code>our-shared-widgets</code> bucket in <code>AccountB</code> while logged in using IDC policies associated with <code>AccountC</code> without issue. Some of these policies grant some of our users <code>GetObject</code>, some <code>PutObject</code>, and others with both. Policies providing this access can be tricky to set up, so this <a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/example-walkthroughs-managing-access-example2.html">walk-through</a> is an excellent place to start if you want more information.</p>

<h2 id="then-it-wasnt">Then it wasn’t</h2>

<p>I recently needed to provide a new user access to <code>our-shared-widgets</code> while logged in using IDC policies associated with <code>AccountB</code>. If you missed it, the critical difference is the IDC policies and their account association, <code>AccountC</code> vs. <code>AccountB</code>.
The authentication process was successful; IDC functioned as expected, but on attempting to get an S3 object, a <code>403 Access Denied</code> error occurred.</p>

<h2 id="investigation">Investigation</h2>

<p>S3 is simple until it isn’t. As a starting point, I reviewed the following aspects of our IAM policies and S3 bucket policies to rule out any obvious issues:</p>

<ul>
  <li>Access is denied if the IAM policy grants the necessary permissions but the bucket policy does not.</li>
  <li>Access is denied if the bucket policy grants the necessary permissions, but the IAM policy does not.</li>
  <li>If both the IAM and bucket policies grant the necessary permissions, access is allowed unless there is an explicit <code>Deny</code> statement in either policy. Explicit <code>Deny</code> statements always take precedence over <code>Allow</code> statements.</li>
</ul>

<p>None of the above was the culprit, our new user still couldn’t get objects, and our pre-existing users kept operating happily.</p>

<p>Next I logged in with full administrative rights in <code>AccountB</code> and pushed a test file to <code>our-shared-widgets</code>; our new user could get that object. Now I am intrigued, cross-context puts are at fault.</p>

<p>My next avenue of investigation was objects somehow being associated with the wrong server-side key when encrypting files which meant the IDC policies associated with <code>AccountB</code> would not be able to decrypt them, hence the 403. On review, this was not the case; the key used to encrypt existing objects was the KMS CMK for <code>our-shared-widgets</code>, as expected:</p>

<pre><code class="language-json">❯ aws s3api head-object --bucket our-shared-widgets --key file.ext

{
    "AcceptRanges": "bytes",
    "LastModified": "2023-04-17T01:14:05+00:00",
    "ContentLength": 15,
    "ETag": "\"52d22dba61ed2f00b1fcd41\"",
    "VersionId": "NPWsAIu_oO04xAeL4rE_me0Z.bEScteI",
    "ContentType": "text/plain",
    "ServerSideEncryption": "aws:kms",
    "Metadata": {},
    "SSEKMSKeyId": "arn:aws:kms:...:...:key/677379e1...",
    "BucketKeyEnabled": true
}
</code></pre>

<p>Now, I’m racking my brain and making little progress on diagnosis. Some fresh air and an espresso later, I decide to consider object ownership:</p>

<pre><code class="language-json">❯ aws s3api get-object-acl --bucket our-shared-widgets --key file.ext

{
    "Owner": {
        "DisplayName": "AccountC",
        "ID": "63a4d1..."
    },
    "Grants": [
        {
            "Grantee": {
                "DisplayName": "AccountC",
                "ID": "63a4d1...",
                "Type": "CanonicalUser"
            },
            "Permission": "FULL_CONTROL"
        }
    ]
}
</code></pre>

<p><strong><em>RESULT!</em></strong></p>

<h2 id="s3-acls">S3 ACLs</h2>

<p>AWS S3 Access Control Lists (ACLs) are a legacy access control mechanism that allows you to manage the permissions of individual objects within an S3 bucket. With ACLs, you can define which AWS accounts, or groups are granted access to an object and their specific permissions, such as read, write, or delete access. They’re complex, and as such, I’ve always used IAM policies and bucket policies.</p>

<p>That said, by default, when an object is uploaded to a bucket, it is owned by the AWS account that uploaded it; this means that the object’s permissions are determined by the ACL settings specified by the uploading account. In a cross-account scenario like ours, this can lead to a situation where the bucket owner does not have full access to the objects uploaded by other AWS accounts, even though the objects are stored in their bucket.</p>

<p>I’d forgotten this key aspect when investigating this fault, so it is worth repeating; by default, when an object is uploaded to an S3 bucket, it is owned by the AWS account that uploaded it.</p>

<p>Root cause identified. As we saw, the objects in the bucket are owned by <code>AccountC</code>, so regardless of appropriate IAM and S3 Bucket policies, any user associated via IDC policies with <code>AccountB</code> would be denied access by ACLs.</p>

<h2 id="solution">Solution</h2>

<p>Amazon S3 introduced the bucket owner-enforced feature in late 2021. By enabling the <code>s3.ObjectOwnership.BUCKET_OWNER_ENFORCED</code> setting on the bucket, the owner can ensure complete control over all objects, regardless of which account uploaded them. It is worth noting this setting entirely disables ACLs, good riddance!</p>

<p>You can make this change via the console; however, I recommend doing this via the CDK (you created the bucket via CDK, right?):</p>

<pre><code class="language-typescript">const bucket = new s3.Bucket(this, 'Bucket', {
  bucketName: 'our-shared-widgets',
  ...
  objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_ENFORCED,
});
</code></pre>

<p>Changes to object ownership for existing buckets defined in a stack are applied by CloudFormation; there is no need to define a custom lambda function to update the bucket.
With these changes in place, all is well again; our new user can get all objects, and our pre-existing users can still get and put objects. Going forward ownership of objects will always be, <code>AccountB</code> regardless of how they’re uploaded. IAM and S3 bucket policies will function as expected without ACL interference. 🎉</p>

<h2 id="conclusion">Conclusion</h2>

<p>This default is archaic, much like allowing public access to buckets. No doubt I’m not the first person to have suffered this, but likely not the last.</p>

<p>AWS, to their credit, have been working on these issues for a while now and plan to change the defaults per <a href="https://aws.amazon.com/about-aws/whats-new/2022/12/amazon-s3-automatically-enable-block-public-access-disable-access-control-lists-buckets-april-2023/">https://aws.amazon.com/about-aws/whats-new/2022/12/amazon-s3-automatically-enable-block-public-access-disable-access-control-lists-buckets-april-2023/</a>. This change is probably rolling out around the same time I publish this blog post. That said, the changes won’t be retroactive, so if you have existing buckets, you might trip over this and still need to take action.</p>

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/atom-one-dark.min.css" integrity="sha384-oaMLBGEzBOJx3UHwac0cVndtX5fxGQIfnAeFZ35RTgqPcYlbprH9o9PUV/F8Le07" crossorigin="anonymous" />

<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js" integrity="sha384-4l+9bhb7rakZ18megzl0/DWczL8ojbDl1jIEzBVffeMho9A6xB/lkqt1K0PC8Jin" crossorigin="anonymous"></script>

<script>hljs.highlightAll();</script>]]></content><author><name></name></author><category term="aws" /><summary type="html"><![CDATA[A debugging war story. Cross-account S3 access was failing with a cryptic access denied error. The culprit? An archaic ACL default that's easy to miss.]]></summary></entry><entry><title type="html">Overview of security and compliance in MedTech</title><link href="https://bradleybeddoes.com/writing/overview-of-security-and-compliance-in-medtech" rel="alternate" type="text/html" title="Overview of security and compliance in MedTech" /><published>2023-04-12T00:41:00+00:00</published><updated>2023-04-12T00:41:00+00:00</updated><id>https://bradleybeddoes.com/writing/overview-of-security-and-compliance-in-medtech</id><content type="html" xml:base="https://bradleybeddoes.com/writing/overview-of-security-and-compliance-in-medtech"><![CDATA[<p>This is a topic close to my heart right now, as I’m leading <a href="https://macuject.com" target="_blank" rel="noopener noreferrer">Macuject’s</a> security and compliance initiatives. I plan to delve deeper into each of these subjects in future posts but wanted to start with an overview.</p>

<h2 id="why-is-security-and-compliance-important-for-medtech-startups">Why is security and compliance important for MedTech startups?</h2>

<p>Security breaches in the healthcare sector are all too common, with incidents ranging from malicious attacks to accidental data leaks by internal staff or misplaced devices like laptops.</p>

<p>Personal Health Information (PHI) is a hot commodity on the <a href="https://en.wikipedia.org/wiki/Dark_web">dark web</a>, even more so than credit card details or typical Personally Identifiable Information (PII). This heightened value means cybercriminals target medical databases, either to sell the PHI or to exploit it for their own nefarious purposes. A staggering 15 million+ health records have fallen victim to data breaches, as reported by the <a href="https://ocrportal.hhs.gov/ocr/breach/breach_report.jsf">health and human services breach report</a>.</p>

<h2 id="key-security-and-compliance-standards-and-laws-for-medtech-startups">Key security and compliance standards and laws for MedTech startups</h2>

<p>For many MedTech startups, the US is a key market, which makes both SOC2 compliance and HIPAA adherence a must. I’ll expand on those below.</p>

<p>Outside the US, there are other standards and laws that are important to consider including:</p>

<ul>
  <li><a href="https://www.iso.org/isoiec-27001-information-security.html" target="_blank" rel="noopener noreferrer">ISO 27001</a></li>
  <li><a href="https://gdpr.eu/" target="_blank" rel="noopener noreferrer">GDPR</a></li>
  <li><a href="https://www.oaic.gov.au/privacy-law/privacy-act/" target="_blank" rel="noopener noreferrer">Australian Privacy Act</a></li>
  <li><a href="https://www.priv.gc.ca/en/privacy-topics/privacy-laws-in-canada/the-personal-information-protection-and-electronic-documents-act-pipeda/" target="_blank" rel="noopener noreferrer">PIPEDA</a></li>
</ul>

<h3 id="implementation">Implementation</h3>

<p>Achieving compliance and adherence to these standards and laws is a long process. They require significant financial and people investment. You also need to be prepared to make changes to your product and processes to meet the requirements of each standard.</p>

<p>I strongly recommend having someone on your team with prior experience in implementing these standards and laws. It is a huge advantage which means you’ll be able to hit the ground running and tweak implementation to your companies operating environment whilst still being compliant. Alternatively look for a consultant who can help you get started who has worked with your scale of company previously.</p>

<p>Most importantly ensuring commitment to security and compliance is something you need to instill in your company culture, from the top down. Security is everyone’s responsibility. Your people will need to see the changes as a benefit to them, not a burden. In that regard ensure what you’re putting in place is practical and not overly complex. Embrace automation where ever possible.</p>

<p>Also be aware that achieving compliance and adherence to these standards and laws is not a one-off investment. You need to continue to demonstrate your commitment to security and compliance, company-wide, every single day. An annual audit process with a third party partner is a key part of this as is ongoing budget and people allocation.</p>

<h3 id="soc-2">SOC 2</h3>

<p>Service Organization Control (SOC) 2 is a security auditing process developed by the <a href="https://www.aicpa.org/" target="_blank" rel="noopener noreferrer">American Institute of Certified Public Accountants (AICPA)</a>. SOC 2 is specifically designed for service providers that store, process, or transmit customer data in the cloud. It assesses the effectiveness of a service organization’s controls in managing customer data, focusing on five key principles:</p>

<ol>
  <li>Security</li>
  <li>Availability</li>
  <li>Confidentiality</li>
  <li>Processing Integrity</li>
  <li>Privacy</li>
</ol>

<p>SOC 2 compliance demonstrates a commitment to the security, confidentiality, and availability of sensitive health information. The compliance process includes:</p>

<ul>
  <li>Establishing a comprehensive set of policies and procedures.</li>
  <li>Implementing and maintaining proper security controls.</li>
  <li>Undergoing an independent audit by a certified third party partner, generally a CPA firm.</li>
</ul>

<p>The audit results in a SOC 2 report, which provides an in-depth review of the service organization’s controls and their effectiveness. A Type 1 report assesses the design of the controls, while a Type 2 report evaluates their operating effectiveness over a specified period. You’ll do both types of report, achieving Type 1 compliance first, then Type 2.</p>

<h3 id="hipaa">HIPAA</h3>

<p>The Health Insurance Portability and Accountability Act (HIPAA) is a US law that sets the standard for protecting sensitive patient data. Any company that handles protected health information (PHI) must ensure their systems and processes comply with HIPAA. The <a href="https://www.hhs.gov/hipaa/for-professionals/privacy/index.html" target="_blank" rel="noopener noreferrer">HIPAA Privacy Rule</a> and the <a href="https://www.hhs.gov/hipaa/for-professionals/security/index.html" target="_blank" rel="noopener noreferrer">HIPAA Security Rule</a> are the primary components of HIPAA, focusing on the safeguarding of PHI.</p>

<p>Key aspects of HIPAA for MedTech startups include:</p>

<ul>
  <li>Implementing technical, administrative, and physical safeguards.
    <ul>
      <li>Technical safeguards: Access controls, audit controls, integrity controls, and transmission security.</li>
      <li>Administrative safeguards: Risk assessments, workforce training, and contingency planning.</li>
      <li>Physical safeguards: Facility access controls, workstation security, and device security.</li>
    </ul>
  </li>
  <li>Regularly reviewing and updating security measures.</li>
  <li>Providing training on HIPAA compliance to all employees.</li>
</ul>

<p>Upon meeting HIPAA requirements, organizations can obtain a HIPAA attestation. This is a formal declaration, confirming that the organization has implemented the necessary safeguards to protect PHI as required by the HIPAA Privacy and Security Rules. This may be done internally but is more commonly done by a third party partner.</p>

<h3 id="third-party-partner">Third party partner</h3>

<p>I’ve found it particularly useful to use the same third party partner for both SOC 2 compliance and HIPAA attestation. This ensures you’re getting a consistent approach to security and compliance assessment and that you’re not duplicating effort in areas where there is overlap in the controls you need to implement.</p>

<h2 id="conclusion">Conclusion</h2>

<p>If you’re a MedTech startup then security and compliance is a must. It’s not something you can afford to ignore.</p>

<p>Good luck on your journey!</p>]]></content><author><name></name></author><category term="security" /><summary type="html"><![CDATA[Security breaches in healthcare are all too common and PHI is a hot commodity on the dark web. Here's an overview of SOC 2, HIPAA and what I've learned leading compliance in a MedTech startup.]]></summary></entry></feed>