<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
	<channel>
		<title>Security on tsvinc</title>
		<link>https://tsvinc.dev/tags/security/</link>
		<description>Recent content in Security on tsvinc</description>
		<generator>Hugo</generator>
		<language>en-us</language>
		
		
		
		
			<lastBuildDate>Mon, 02 Nov 2026 00:00:00 +0000</lastBuildDate>
		
			<atom:link href="https://tsvinc.dev/tags/security/index.xml" rel="self" type="application/rss+xml" />
			<item>
				<title>Lock OpenCode 2.x to your self-hosted model</title>
				<link>https://tsvinc.dev/guides/lock-opencode-to-local-model/</link>
				<pubDate>Mon, 02 Nov 2026 00:00:00 +0000</pubDate>
				<guid>https://tsvinc.dev/guides/lock-opencode-to-local-model/</guid>
				<description>&lt;p&gt;Two config lines make OpenCode 2.x send prompts only to your self-hosted OpenAI-compatible model, and a self-contained script proves which model a headless run picks.&lt;/p&gt;&#xA;&#xA;&lt;dl class=&#34;guide-scope&#34;&gt;&lt;div class=&#34;guide-scope-row&#34;&gt;&lt;dt&gt;Tested on&lt;/dt&gt;&lt;dd&gt;OpenCode V2 (npm &lt;code&gt;@opencode/cli&lt;/code&gt;) 2.2.5, headless &lt;code&gt;opencode run&lt;/code&gt;, throwaway &lt;code&gt;HOME&lt;/code&gt;, first in a container with no network, then with the PoC below behind a dead proxy (2026-11-02). The no-model case was also run on 2.2.0.&lt;/dd&gt;&lt;/div&gt;&lt;div class=&#34;guide-scope-row is-gap&#34;&gt;&lt;dt&gt;Not verified&lt;/dt&gt;&lt;dd&gt;the interactive TUI, a fresh install&#39;s first-run screens, 2.2.1 to 2.2.4, a config with both lines at once, &lt;code&gt;&amp;quot;model&amp;quot;&lt;/code&gt; alone with the local server down, a full personal config.&lt;/dd&gt;&lt;/div&gt;&lt;div class=&#34;guide-scope-row&#34;&gt;&lt;dt&gt;Needs&lt;/dt&gt;&lt;dd&gt;an &lt;code&gt;opencode&lt;/code&gt; 2.x binary, a local OpenAI-compatible model server already configured as a provider (one way: /guides/serve-local-models-to-opencode-llama-router/), &lt;code&gt;python3&lt;/code&gt; for the PoC. No opencode.ai account and no API key.&lt;/dd&gt;&lt;/div&gt;&lt;div class=&#34;guide-scope-row&#34;&gt;&lt;dt&gt;Time&lt;/dt&gt;&lt;dd&gt;about 10 min, my estimate, not timed (the PoC caps each of its three runs at 60 s).&lt;/dd&gt;&lt;/div&gt;&lt;div class=&#34;guide-scope-row&#34;&gt;&lt;dt&gt;Risk&lt;/dt&gt;&lt;dd&gt;low. You edit one config file after copying it; the PoC points &lt;code&gt;HOME&lt;/code&gt; and the four &lt;code&gt;XDG_*&lt;/code&gt; directories it sets (config, data, state, cache) at a &lt;code&gt;mktemp -d&lt;/code&gt; directory and starts a server on 127.0.0.1:18765 for the length of the run.&lt;/dd&gt;&lt;/div&gt;&lt;/dl&gt;&#xA;&lt;h2 id=&#34;before-you-start&#34;&gt;Before you start&lt;a class=&#34;heading-anchor&#34; href=&#34;#before-you-start&#34; aria-label=&#34;Link to section: Before you start&#34;&gt;#&lt;/a&gt;&lt;/h2&gt;&lt;ul&gt;&#xA;&lt;li&gt;&#xA;&lt;p&gt;Placeholders used below:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;code&gt;&amp;lt;CONFIG&amp;gt;&lt;/code&gt;: your global OpenCode config file. The PoC writes its own as &lt;code&gt;$XDG_CONFIG_HOME/opencode/opencode.json&lt;/code&gt; inside a throwaway &lt;code&gt;HOME&lt;/code&gt;, so on a default setup this is &lt;code&gt;~/.config/opencode/opencode.json&lt;/code&gt;.&lt;/li&gt;&#xA;&lt;li&gt;&lt;code&gt;&amp;lt;PROVIDER_ID&amp;gt;&lt;/code&gt;: the key of your local provider under &lt;code&gt;&amp;quot;provider&amp;quot;&lt;/code&gt; in &lt;code&gt;&amp;lt;CONFIG&amp;gt;&lt;/code&gt;. The PoC uses &lt;code&gt;local&lt;/code&gt;.&lt;/li&gt;&#xA;&lt;li&gt;&lt;code&gt;&amp;lt;MODEL_ID&amp;gt;&lt;/code&gt;: the key of your model under that provider&#39;s &lt;code&gt;&amp;quot;models&amp;quot;&lt;/code&gt;. The PoC uses &lt;code&gt;qwen-local&lt;/code&gt;.&lt;/li&gt;&#xA;&lt;li&gt;&lt;code&gt;&amp;lt;OPENCODE_BIN&amp;gt;&lt;/code&gt;: path to the &lt;code&gt;opencode&lt;/code&gt; 2.x binary; the PoC defaults to &lt;code&gt;opencode&lt;/code&gt; on &lt;code&gt;PATH&lt;/code&gt;.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;/li&gt;&#xA;&lt;li&gt;&#xA;&lt;p&gt;Check the version with &lt;code&gt;&amp;lt;OPENCODE_BIN&amp;gt; --version&lt;/code&gt;. Mine printed &lt;code&gt;opencode v2.2.5&lt;/code&gt; (it is also the PoC&#39;s first output line).&lt;/p&gt;&#xA;&lt;/li&gt;&#xA;&lt;li&gt;&#xA;&lt;p&gt;Back up the config before step 3:&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;cp &amp;lt;CONFIG&amp;gt; &amp;lt;CONFIG&amp;gt;.bak&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/li&gt;&#xA;&lt;/ul&gt;</description>
			</item>
			<item>
				<title>My agent guardrails passed every test. Then I used them.</title>
				<link>https://tsvinc.dev/posts/guardrails-first-real-evening/</link>
				<pubDate>Mon, 02 Nov 2026 00:00:00 +0000</pubDate>
				<guid>https://tsvinc.dev/posts/guardrails-first-real-evening/</guid>
				<description>&lt;p&gt;&lt;em&gt;Assumes you&#39;ve set up a terminal coding agent&#39;s permission rules, or wondered what a model sees when they say no.&lt;/em&gt;&lt;/p&gt;&#xA;&lt;p&gt;For weeks, the only user my test suites knew was a fake model. The suites cover my configuration templates for six terminal coding agents (OpenCode in two major versions, V1 and V2, plus pi, omp, Hermes Agent and Claude Code): each one runs local models and refuses destructive commands even with nobody approving. Every suite drove them with &lt;a href=&#34;https://tsvinc.dev/posts/model-that-only-says-run/&#34;&gt;a scripted fake model&lt;/a&gt; in a container with no network. It was an ideal user: it ran exactly the command the test told it to, it never improvised, and it never once wanted a screenshot of anything.&lt;/p&gt;</description>
			</item>
			<item>
				<title>OpenCode didn&#39;t want an account. It wanted a model name.</title>
				<link>https://tsvinc.dev/posts/opencode-account-myth/</link>
				<pubDate>Mon, 02 Nov 2026 00:00:00 +0000</pubDate>
				<guid>https://tsvinc.dev/posts/opencode-account-myth/</guid>
				<description>&lt;p&gt;&lt;em&gt;Assumes you&#39;ve pointed a terminal coding agent at a model you serve yourself.&lt;/em&gt;&lt;/p&gt;&#xA;&lt;p&gt;The warning reached me second-hand, the way the most confident warnings travel. Don&#39;t install a fresh OpenCode 2.x if you run your own models, it said: the new version won&#39;t talk to a self-hosted model until you activate an account on opencode.ai and pay for a subscription, and it asks for an API key on your very first prompt. A second message came along with it, an AI assistant&#39;s explanation, and that one had a mechanism: fresh installs now default to OpenCode&#39;s own hosted model, the switch that turns the hosted provider off is fairly new and may not work, and in that case there&#39;s a third-party plugin, installed globally with &lt;code&gt;npx&lt;/code&gt;.&lt;/p&gt;</description>
			</item>
			<item>
				<title>The leak scan found me in my own guard&#39;s tests</title>
				<link>https://tsvinc.dev/posts/leak-scan-found-me-in-guard-tests/</link>
				<pubDate>Mon, 02 Nov 2026 00:00:00 +0000</pubDate>
				<guid>https://tsvinc.dev/posts/leak-scan-found-me-in-guard-tests/</guid>
				<description>&lt;p&gt;Publishing a private repository as a single orphan commit sounds like the safe kind of open source. No history goes out, so nothing buried in the history can leak: you choose the files, you commit them once, you push. It&#39;s the plan that looks best on a whiteboard, and that is exactly why I didn&#39;t let it go out on its own.&lt;/p&gt;&#xA;&lt;p&gt;The repository holds my hardened configuration templates for six terminal coding agents that run on local models (OpenCode V1 and V2, pi, omp, Hermes Agent, Claude Code), plus the guard code that stops them running destructive commands. It&#39;s public now, under MIT, as &lt;a href=&#34;https://gitlab.com/myrants-oss/harnesses&#34;&gt;one commit&lt;/a&gt;. On the first builds, the checks I&#39;d put in front of that commit, plus one review of the plan, found six things. Each pointed at a real defect, two of them in the scanner itself. One of them was me, by name, in the test cases of that guard.&lt;/p&gt;</description>
			</item>
			<item>
				<title>Another guard&#39;s test list found holes in mine</title>
				<link>https://tsvinc.dev/posts/second-guard-found-my-gaps/</link>
				<pubDate>Sat, 31 Oct 2026 00:00:00 +0000</pubDate>
				<guid>https://tsvinc.dev/posts/second-guard-found-my-gaps/</guid>
				<description>&lt;p&gt;&lt;em&gt;Builds on &lt;a href=&#34;https://tsvinc.dev/posts/guard-missed-bash-lc/&#34;&gt;My guard blocked bash -c. Models write bash -lc.&lt;/a&gt;, which explains the guard this post tests.&lt;/em&gt;&lt;/p&gt;&#xA;&lt;p&gt;Comparing two guards for coding agents sounds like a code review: read theirs, read mine, note the differences, nod. I walked in with the posture of a visiting inspector. It turned into the best test run my guard has had, none of the test cases came from my own list, and the inspection went the other way.&lt;/p&gt;</description>
			</item>
			<item>
				<title>Confine a coding agent&#39;s HTTPS to an allowlist with bwrap</title>
				<link>https://tsvinc.dev/guides/run-coding-agent-egress-allowlist/</link>
				<pubDate>Sat, 31 Oct 2026 00:00:00 +0000</pubDate>
				<guid>https://tsvinc.dev/guides/run-coding-agent-egress-allowlist/</guid>
				<description>&lt;p&gt;Run a command (a coding agent, a build, a shell) in a bubblewrap sandbox whose only way out is HTTPS through a small proxy on the host that lets through the hosts on your list and refuses the rest.&lt;/p&gt;&#xA;&#xA;&lt;dl class=&#34;guide-scope&#34;&gt;&lt;div class=&#34;guide-scope-row&#34;&gt;&lt;dt&gt;Tested on&lt;/dt&gt;&lt;dd&gt;Fedora Linux 44 (KDE), kernel 7.2.13-200.fc44.x86_64, bubblewrap 0.12.0, Python 3.14.8, curl 8.18.0, Node.js v22.23.1 (2026-10-31).&lt;/dd&gt;&lt;/div&gt;&lt;div class=&#34;guide-scope-row is-gap&#34;&gt;&lt;dt&gt;Not verified&lt;/dt&gt;&lt;dd&gt;running a coding agent inside the sandbox. The test commands were curl, Python, bash and Node&#39;s &lt;code&gt;fetch&lt;/code&gt;, run from a test script.&lt;/dd&gt;&lt;/div&gt;&lt;div class=&#34;guide-scope-row&#34;&gt;&lt;dt&gt;Needs&lt;/dt&gt;&lt;dd&gt;Linux with bubblewrap (&lt;code&gt;bwrap&lt;/code&gt;) and Python 3; curl for the tests. No root. Node.js for the two &lt;code&gt;fetch&lt;/code&gt; checks only.&lt;/dd&gt;&lt;/div&gt;&lt;div class=&#34;guide-scope-row&#34;&gt;&lt;dt&gt;Time&lt;/dt&gt;&lt;dd&gt;not timed. The scripted re-run of the whole procedure, failure captures included, took 3 s (13:44:32 to 13:44:35); that is script speed, not the time to set this up by hand.&lt;/dd&gt;&lt;/div&gt;&lt;div class=&#34;guide-scope-row&#34;&gt;&lt;dt&gt;Risk&lt;/dt&gt;&lt;dd&gt;low and reversible. Nothing outside the working directory and the project directory changes, and the proxy runs as your user. The project directory is mounted read-write, so the command inside can change or delete files there.&lt;/dd&gt;&lt;/div&gt;&lt;/dl&gt;&#xA;&lt;h2 id=&#34;before-you-start&#34;&gt;Before you start&lt;a class=&#34;heading-anchor&#34; href=&#34;#before-you-start&#34; aria-label=&#34;Link to section: Before you start&#34;&gt;#&lt;/a&gt;&lt;/h2&gt;&lt;ul&gt;&#xA;&lt;li&gt;Check the tools. My run:&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-console&#34; data-lang=&#34;console&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;gp&#34;&gt;$&lt;/span&gt; bwrap --version&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt; python3 --version&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt; curl --version &lt;span class=&#34;p&#34;&gt;|&lt;/span&gt; head -1&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt; /usr/bin/node --version&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt; uname -r&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt; grep PRETTY_NAME /etc/os-release&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;go&#34;&gt;bubblewrap 0.12.0&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;go&#34;&gt;Python 3.14.8&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;go&#34;&gt;curl 8.18.0 (x86_64-redhat-linux-gnu) libcurl/8.18.0 OpenSSL/3.5.9 zlib/1.3.1.zlib-ng brotli/1.2.1 libidn2/2.3.8 libpsl/0.21.5 libssh/0.12.2/openssl/zlib nghttp2/1.68.0 ngtcp2/1.22.1 nghttp3/1.18.0 mit-krb5/1.22.2 OpenLDAP/2.6.15&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;go&#34;&gt;v22.23.1&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;go&#34;&gt;7.2.13-200.fc44.x86_64&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;go&#34;&gt;PRETTY_NAME=&amp;#34;Fedora Linux 44 (KDE Plasma Desktop Edition)&amp;#34;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/li&gt;&#xA;&lt;li&gt;Pick a working directory for the scripts. Every command below runs from it, and the proxy&#39;s files go in &lt;code&gt;run/&lt;/code&gt; under it. In the output below it is shown as &lt;code&gt;/home/user/egress&lt;/code&gt;.&lt;/li&gt;&#xA;&lt;li&gt;Placeholders, used only in step 8:&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;code&gt;&amp;lt;PROJECT_DIR&amp;gt;&lt;/code&gt;: the directory the sandboxed command may read and write. It appears as &lt;code&gt;/work&lt;/code&gt; inside.&lt;/li&gt;&#xA;&lt;li&gt;&lt;code&gt;&amp;lt;AGENT&amp;gt;&lt;/code&gt;: the command to run in the sandbox.&lt;/li&gt;&#xA;&lt;li&gt;&lt;code&gt;&amp;lt;INSTALL_DIR&amp;gt;&lt;/code&gt;: the directory the agent is installed in, if it is not under &lt;code&gt;/usr&lt;/code&gt;.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;/li&gt;&#xA;&lt;li&gt;Back up or commit &lt;code&gt;&amp;lt;PROJECT_DIR&amp;gt;&lt;/code&gt; before you run anything in it: the sandbox mounts it read-write.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;How it fits together, from the two scripts:&lt;/p&gt;</description>
			</item>
			<item>
				<title>An exception in one layer deleted a block in another</title>
				<link>https://tsvinc.dev/posts/exception-removed-a-block/</link>
				<pubDate>Mon, 26 Oct 2026 00:00:00 +0000</pubDate>
				<guid>https://tsvinc.dev/posts/exception-removed-a-block/</guid>
				<description>&lt;p&gt;&lt;em&gt;Builds on &lt;a href=&#34;https://tsvinc.dev/posts/guard-missed-bash-lc/&#34;&gt;My guard blocked bash -c. Models write bash -lc.&lt;/a&gt;, which explains the guard&#39;s command-position rules and lists &lt;code&gt;$(echo rm) -rf victim&lt;/code&gt; among the things a regex can&#39;t see.&lt;/em&gt;&lt;/p&gt;&#xA;&lt;p&gt;At 20:01 I committed a convenience: my coding agents could now delete reverse-engineering output without asking. At 20:14 I committed a new deny rule for a command my own guard post had filed under &amp;quot;what a regex still can&#39;t see&amp;quot;. In the thirteen minutes between them, one end-to-end run showed that the convenience had quietly switched off the only thing covering that command, in the one agent where anything covered it at all.&lt;/p&gt;</description>
			</item>
			<item>
				<title>My guard blocked bash -c. Models write bash -lc.</title>
				<link>https://tsvinc.dev/posts/guard-missed-bash-lc/</link>
				<pubDate>Mon, 26 Oct 2026 00:00:00 +0000</pubDate>
				<guid>https://tsvinc.dev/posts/guard-missed-bash-lc/</guid>
				<description>&lt;p&gt;&lt;em&gt;Assumes you&#39;ve run a coding agent with auto-approval on, or written a hook that vets its shell commands.&lt;/em&gt;&lt;/p&gt;&#xA;&lt;p&gt;The guard looked at &lt;code&gt;bash -lc &#39;rm -rf build&#39;&lt;/code&gt; and said allow.&lt;/p&gt;&#xA;&lt;p&gt;It denied &lt;code&gt;bash -c &#39;rm -rf build&#39;&lt;/code&gt;. It denied &lt;code&gt;bash -l -c &#39;rm -rf build&#39;&lt;/code&gt;, the same two flags passed as separate arguments. Pack the &lt;code&gt;l&lt;/code&gt; and the &lt;code&gt;c&lt;/code&gt; into one argument and the guard waved the recursive delete through without so much as a question. The guard had applied its rule exactly as written. The rule had been written for a different spelling of the same command.&lt;/p&gt;</description>
			</item>
			<item>
				<title>My guard read the tool&#39;s name, never its arguments</title>
				<link>https://tsvinc.dev/posts/guard-judged-tools-by-name/</link>
				<pubDate>Mon, 26 Oct 2026 00:00:00 +0000</pubDate>
				<guid>https://tsvinc.dev/posts/guard-judged-tools-by-name/</guid>
				<description>&lt;p&gt;&lt;em&gt;Builds on &lt;a href=&#34;https://tsvinc.dev/posts/guard-missed-bash-lc/&#34;&gt;My guard blocked bash -c. Models write bash -lc.&lt;/a&gt;, which explains the guard and what it hard-denies.&lt;/em&gt;&lt;/p&gt;&#xA;&lt;p&gt;Every rename asked. Every comment asked. Every retyped variable stopped the agent and waited for me to click, and the binary it was all about never changed once. That was my guard&#39;s Ghidra policy doing exactly what I had written.&lt;/p&gt;&#xA;&lt;p&gt;My agent-config templates give six terminal coding agents a Ghidra MCP server for reverse engineering, off by default and available on demand: pyghidra-mcp 0.2.7, GhidraMCP 2.0 or ReVa 7.3.1. The same guard that hard-denies destructive shell commands also decides about these tools, and its Ghidra policy was cautious: an allowlist of read verbs (&lt;code&gt;get_&lt;/code&gt;, &lt;code&gt;list_&lt;/code&gt;, &lt;code&gt;decompile_&lt;/code&gt; and a few more), a deny for deleting a binary, and a question for everything else.&lt;/p&gt;</description>
			</item>
			<item>
				<title>My sandbox profile had never run as shipped</title>
				<link>https://tsvinc.dev/posts/sandbox-profile-never-ran/</link>
				<pubDate>Mon, 26 Oct 2026 00:00:00 +0000</pubDate>
				<guid>https://tsvinc.dev/posts/sandbox-profile-never-ran/</guid>
				<description>&lt;p&gt;&lt;em&gt;Assumes you&#39;ve seen Claude Code&#39;s sandboxed Bash tool; &lt;a href=&#34;https://tsvinc.dev/posts/model-that-only-says-run/&#34;&gt;The model that only says RUN&lt;/a&gt; describes the fake model used to drive it here.&lt;/em&gt;&lt;/p&gt;&#xA;&lt;p&gt;Claude Code&#39;s sandbox for its Bash tool is one of the better ideas in the coding-agent world, which is why it hurt a little to find that mine had never run. On Linux it runs each command under bubblewrap, takes away the network except through its own proxy, and fences off the filesystem with lists you control. My agent-config templates ship an opt-in profile for it, set up for Java and Go work. That profile had never been run as shipped. An audit of the templates pointed it out, and the first run found three ways it would break the builds it was set up for.&lt;/p&gt;</description>
			</item>
			<item>
				<title>My unit tests never typed `; echo`</title>
				<link>https://tsvinc.dev/posts/sql-rule-fix-broke-echo/</link>
				<pubDate>Mon, 26 Oct 2026 00:00:00 +0000</pubDate>
				<guid>https://tsvinc.dev/posts/sql-rule-fix-broke-echo/</guid>
				<description>&lt;p&gt;&lt;em&gt;Builds on &lt;a href=&#34;https://tsvinc.dev/posts/guard-missed-bash-lc/&#34;&gt;My guard blocked bash -c. Models write bash -lc.&lt;/a&gt;, which explains the guard and its command-position rules.&lt;/em&gt;&lt;/p&gt;&#xA;&lt;p&gt;Both guards agreed on more than 700 cases. Four coding agents then failed one end-to-end case, and the failures were right.&lt;/p&gt;&#xA;&lt;p&gt;The guards are the TypeScript and Python halves of the guard in my agent-config templates: one shared file of regex rules that hard-denies destructive shell commands even when an agent runs with auto-approval. Both halves have their own unit tests, and the two must agree. The case that failed was &lt;code&gt;git log -S &#39;DROP TABLE&#39; --oneline&lt;/code&gt;, which searches history and destroys nothing. It has been in the end-to-end suite as a near miss, a harmless command that has to keep running, ever since a harness&#39;s own &lt;code&gt;*DROP TABLE*&lt;/code&gt; permission glob blocked it. I had just made the SQL rule stricter, every unit case was green, and the agents now refused to run a search.&lt;/p&gt;</description>
			</item>
			<item>
				<title>One space in a path, and Hermes blocked echo hi</title>
				<link>https://tsvinc.dev/posts/space-in-hermes-home/</link>
				<pubDate>Mon, 26 Oct 2026 00:00:00 +0000</pubDate>
				<guid>https://tsvinc.dev/posts/space-in-hermes-home/</guid>
				<description>&lt;p&gt;&lt;em&gt;Builds on &lt;a href=&#34;https://tsvinc.dev/posts/guard-missed-bash-lc/&#34;&gt;My guard blocked bash -c. Models write bash -lc.&lt;/a&gt;, which explains the guard that Hermes runs as a hook here.&lt;/em&gt;&lt;/p&gt;&#xA;&lt;p&gt;&lt;code&gt;echo hi&lt;/code&gt; was blocked. So was every other terminal command and every file read and write Hermes Agent tried. The guard in my agent-config templates is supposed to stop destructive commands, and on this install it stopped everything, which is one way to be secure.&lt;/p&gt;&#xA;&lt;p&gt;Hermes runs my guard as a &lt;code&gt;pre_tool_call&lt;/code&gt; shell hook, with &lt;code&gt;fail_closed: true&lt;/code&gt;. That setting is deliberate: if the guard can&#39;t give a verdict, the tool call doesn&#39;t run. A guard that fails open is a guard that an unlucky crash switches off. The cause was the install&#39;s &lt;code&gt;HERMES_HOME&lt;/code&gt;, the directory Hermes keeps its config and hooks in: it had a space in its name.&lt;/p&gt;</description>
			</item>
			<item>
				<title>Tests that could not fail</title>
				<link>https://tsvinc.dev/posts/tests-that-could-not-fail/</link>
				<pubDate>Mon, 26 Oct 2026 00:00:00 +0000</pubDate>
				<guid>https://tsvinc.dev/posts/tests-that-could-not-fail/</guid>
				<description>&lt;p&gt;&lt;em&gt;Builds on &lt;a href=&#34;https://tsvinc.dev/posts/redaction-ate-the-marker/&#34;&gt;The secret filter made my security test pass&lt;/a&gt;, where the same rule caught a test that couldn&#39;t fail.&lt;/em&gt;&lt;/p&gt;&#xA;&lt;p&gt;One rule in my agent-config project has paid for itself several times: a new end-to-end case must fail on the old code. A test that passes before the fix and after it can&#39;t tell me whether the fix works.&lt;/p&gt;&#xA;&lt;p&gt;The suite drives six real coding agents (OpenCode V1 and V2, pi, omp, Hermes Agent, Claude Code) with a fake model and checks whether each command ran. For a fix to the guard, the shared regex rules that block destructive commands, &amp;quot;the old code&amp;quot; usually means the old rule file. For the guard cases below I broke the guard on purpose instead: a copy whose &amp;quot;ask&amp;quot; verdict returns &amp;quot;allow&amp;quot;. Every case that counts on that ask to stop a command should then fail. Two of the new ones didn&#39;t, and a third test, from a different fix, passed on the old code just as happily.&lt;/p&gt;</description>
			</item>
			<item>
				<title>The fix was in five agents. The sixth never called it.</title>
				<link>https://tsvinc.dev/posts/sixth-agent-never-called-the-guard/</link>
				<pubDate>Mon, 26 Oct 2026 00:00:00 +0000</pubDate>
				<guid>https://tsvinc.dev/posts/sixth-agent-never-called-the-guard/</guid>
				<description>&lt;p&gt;&lt;em&gt;Follows &lt;a href=&#34;https://tsvinc.dev/posts/guard-judged-tools-by-name/&#34;&gt;My guard read the tool&#39;s name, never its arguments&lt;/a&gt;, which introduced the fix this post tests.&lt;/em&gt;&lt;/p&gt;&#xA;&lt;p&gt;The last post ended with a fix I was pleased with. My guard used to decide about Ghidra MCP tools by their name alone; now &lt;code&gt;ghidraCheck(rules, tool, input, cwd)&lt;/code&gt; runs the guard&#39;s read rules over every path-like argument first, so an agent that asks Ghidra to import &lt;code&gt;~/.ssh/id_ed25519&lt;/code&gt; into its project gets refused. Both guard implementations, TypeScript and Python, had cases for it. One agent, Claude Code, had been probed against a fake MCP server. The post said the pi and omp extensions, the OpenCode plugin and the Hermes and Claude Code hook &amp;quot;all go through it&amp;quot;.&lt;/p&gt;</description>
			</item>
			<item>
				<title>The secret filter made my security test pass</title>
				<link>https://tsvinc.dev/posts/redaction-ate-the-marker/</link>
				<pubDate>Mon, 26 Oct 2026 00:00:00 +0000</pubDate>
				<guid>https://tsvinc.dev/posts/redaction-ate-the-marker/</guid>
				<description>&lt;p&gt;&lt;em&gt;Builds on &lt;a href=&#34;https://tsvinc.dev/posts/model-that-only-says-run/&#34;&gt;The model that only says RUN&lt;/a&gt;, which explains how a fake model drives the real agents in these tests.&lt;/em&gt;&lt;/p&gt;&#xA;&lt;p&gt;My end-to-end suite rests on one belief: if the marker doesn&#39;t come back, the command didn&#39;t run. Every &amp;quot;blocked&amp;quot; verdict for six coding agents is that belief applied to a tool result. It is a very convenient belief. It holds right up to the moment something between the command and the test eats part of the output, and then a command that ran reads as a command that was stopped.&lt;/p&gt;</description>
			</item>
			<item>
				<title>The test rig was the unsafe part</title>
				<link>https://tsvinc.dev/posts/test-rig-was-the-unsafe-part/</link>
				<pubDate>Mon, 26 Oct 2026 00:00:00 +0000</pubDate>
				<guid>https://tsvinc.dev/posts/test-rig-was-the-unsafe-part/</guid>
				<description>&lt;p&gt;&lt;strong&gt;TL;DR —&lt;/strong&gt; A throwaway &lt;code&gt;HOME&lt;/code&gt; isn&#39;t isolation when the tool under test honours its own &lt;code&gt;*_HOME&lt;/code&gt; or &lt;code&gt;*_CONFIG_DIR&lt;/code&gt; variable; my test installer would have written into real configs. &lt;code&gt;bwrap --dev-bind / / --unshare-net&lt;/code&gt; gives the command its own network and shares the rest: host processes stay visible and signalable, the filesystem stays writable, and a Unix socket that lives at a path, like Docker&#39;s, still connects. For a suite that runs destructive commands on purpose, I now use one container per suite with &lt;code&gt;--network none&lt;/code&gt;, only the repository mounted, and my own uid.&lt;/p&gt;</description>
			</item>
			<item>
				<title>The guard rails that said no</title>
				<link>https://tsvinc.dev/posts/guard-rails-that-said-no/</link>
				<pubDate>Sun, 18 Oct 2026 00:00:00 +0000</pubDate>
				<guid>https://tsvinc.dev/posts/guard-rails-that-said-no/</guid>
				<description>&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;Script Error: failure: mountpoint usb1-part1 is used by log-&amp;gt;file:/blockylog.0.txt (/disk/set *0 = *0) (/disk/set; line 7) (:import; line 1)&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;ABORT: whole SSD still shared (smb/media entries: 2)&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That was the deploy script for the MQTT broker on the router, stopping itself at step 0. Nothing after that line ran: no router backup, no password file, no broker. The second line had been added to the script two minutes earlier.&lt;/p&gt;&#xA;&lt;p&gt;The project behind &lt;a href=&#34;https://tsvinc.dev/posts/esp32-bluetti-ac180-bridge/&#34;&gt;the power station bridge&lt;/a&gt; hit several refusals like this one in a single day. Some came from scripts, one from a permission check in my tooling, one from a review note. Each one made the result better. The only expensive failure of the day came from a tool that didn&#39;t refuse at all.&lt;/p&gt;</description>
			</item>
			<item>
				<title>Deleting a GitLab project gives it a new name</title>
				<link>https://tsvinc.dev/posts/gitlab-delete-project-renames-it/</link>
				<pubDate>Mon, 12 Oct 2026 00:00:00 +0000</pubDate>
				<guid>https://tsvinc.dev/posts/gitlab-delete-project-renames-it/</guid>
				<description>&lt;p&gt;&lt;strong&gt;22:41:05 UTC.&lt;/strong&gt; The scrubbed history is ready: one orphan commit, nothing personal in it. The old history is parked on a local branch called &lt;code&gt;old-history-local&lt;/code&gt;, just in case.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;22:41:09.&lt;/strong&gt; &lt;code&gt;glab api -X DELETE projects/&amp;lt;group&amp;gt;%2F&amp;lt;repo&amp;gt;&lt;/code&gt;. The answer is the shortest thing GitLab will ever say:&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-text&#34; data-lang=&#34;text&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;{&amp;#34;message&amp;#34;:&amp;#34;202 Accepted&amp;#34;}&#xA;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;still exists: &amp;lt;group&amp;gt;/&amp;lt;repo&amp;gt;-deletion_scheduled-&amp;lt;id&amp;gt; marked_for_deletion_on= 2026-10-11&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;strong&gt;Five seconds later&lt;/strong&gt;, the second line. That one is a Python one-liner of mine over &lt;code&gt;GET /projects/:id&lt;/code&gt; on the original path, and it was supposed to print a 404. Instead the project answered, under a name with a suffix bolted on.&lt;/p&gt;</description>
			</item>
			<item>
				<title>Hardening the box you&#39;re logged in through</title>
				<link>https://tsvinc.dev/posts/hardening-a-box-youre-logged-into/</link>
				<pubDate>Mon, 20 Jul 2026 00:00:00 +0000</pubDate>
				<guid>https://tsvinc.dev/posts/hardening-a-box-youre-logged-into/</guid>
				<description>&lt;p&gt;Assumes you&#39;ve SSH&#39;d into something you couldn&#39;t physically reach.&lt;/p&gt;&#xA;&lt;p&gt;The last thing I did in that thread was switch off the instrument.&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;pkill -f &lt;span class=&#34;s1&#34;&gt;&amp;#39;port-test&amp;#39;&lt;/span&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A loop had been polling Transmission&#39;s &lt;code&gt;port-test&lt;/code&gt; every fifteen seconds — an external server asked to connect back to the public IP and report whether it got through. It had been printing &lt;code&gt;false&lt;/code&gt; for minutes. I had just read the router&#39;s dst-nat table, found a one-digit typo in it, fixed the typo, and then explained at length that the typo didn&#39;t matter, because the ISP was doing carrier-grade NAT and no port forward could ever work. Then I killed the poller, on the reasoning that it couldn&#39;t flip &lt;code&gt;true&lt;/code&gt; from there.&lt;/p&gt;</description>
			</item>
			<item>
				<title>The iPhone camera wanted to Google my private key</title>
				<link>https://tsvinc.dev/posts/wireguard-qr-ios-camera-footgun/</link>
				<pubDate>Tue, 07 Jul 2026 00:00:00 +0000</pubDate>
				<guid>https://tsvinc.dev/posts/wireguard-qr-ios-camera-footgun/</guid>
				<description>&lt;p&gt;Every WireGuard walkthrough ends the same way: &amp;quot;generate the config, render it as a QR code, scan it with your phone.&amp;quot; None of them tell you &lt;em&gt;which app&lt;/em&gt; does the scanning, because the author scanned it inside the WireGuard app and it never occurred to them you might not. I pointed an iPhone&#39;s Camera app at a freshly generated tunnel QR, and iOS did something helpful and catastrophic: it read the code as plain text and offered me a Google search — of the entire config, private key included.&lt;/p&gt;</description>
			</item>
			<item>
				<title>The router had been running fine, which was the problem</title>
				<link>https://tsvinc.dev/posts/hardening-a-neglected-mikrotik/</link>
				<pubDate>Tue, 07 Jul 2026 00:00:00 +0000</pubDate>
				<guid>https://tsvinc.dev/posts/hardening-a-neglected-mikrotik/</guid>
				<description>&lt;div class=&#34;tldr&#34;&gt;&#xA;  &lt;span class=&#34;tldr-label&#34;&gt;TL;DR&lt;/span&gt;&#xA;  A read-only audit of this hAP ac3 found the RouterBOOT bootloader still on a 6.x release under RouterOS 7, the plaintext services enabled, the bandwidth-test server on, MAC-telnet answering on every interface, NTP off, and &lt;code&gt;admin&lt;/code&gt; reachable from anywhere. Not one exotic vulnerability among them, just accumulated factory defaults nobody had looked at. Uptime tells you the thing works. It tells you nothing about who can reach it.&#xA;&lt;/div&gt;&#xA;&#xA;&lt;p&gt;A router that has run fine for years and a router nobody has looked at in years produce the same uptime graph, and the only way to tell them apart is to open it up and read what&#39;s actually enabled. This hAP ac3 had been quietly doing its job on RouterOS 7.22.1. Then I ran a read-only audit, and the job it had been doing all along turned out to include &amp;quot;leave the front door unlocked.&amp;quot;&lt;/p&gt;</description>
			</item>
			<item>
				<title>Transmission is a remote-code-execution box by default</title>
				<link>https://tsvinc.dev/posts/transmission-rce-by-default/</link>
				<pubDate>Tue, 07 Jul 2026 00:00:00 +0000</pubDate>
				<guid>https://tsvinc.dev/posts/transmission-rce-by-default/</guid>
				<description>&lt;div class=&#34;tldr&#34;&gt;&#xA;  &lt;span class=&#34;tldr-label&#34;&gt;TL;DR&lt;/span&gt;&#xA;  Transmission&#39;s &lt;code&gt;script-torrent-done-filename&lt;/code&gt; runs an executable of your choosing as the daemon user every time a torrent completes, and it is settable over the RPC interface — so anything that can reach the RPC port and pass its auth chooses what the daemon executes. That is remote code execution by design, not a bug, and you cannot seccomp it away because the daemon legitimately needs &lt;code&gt;execve&lt;/code&gt;. Narrow who can reach the RPC (the whitelist takes shell globs, not CIDR), then cage what an escaped command can touch with a systemd sandbox.&#xA;&lt;/div&gt;&#xA;&#xA;&lt;p&gt;The obvious way to stop a program from running arbitrary commands is to take away its ability to run commands. systemd has a knob for exactly this:&lt;/p&gt;</description>
			</item>
			<item>
				<title>HTC Desire Z screenshots on Android 2.3, the long way round</title>
				<link>https://tsvinc.dev/posts/screenshot-toast-war/</link>
				<pubDate>Sun, 09 Jun 2024 00:00:00 +0000</pubDate>
				<guid>https://tsvinc.dev/posts/screenshot-toast-war/</guid>
				<description>&lt;p&gt;How hard is it to get a picture of a phone&#39;s screen when you have root on the phone, its bootloader unlocked and a keyboard attached? I needed a few screenshots of MeshLauncher, the home-screen app I had just written for my HTC Desire Z, a 2010 Android slider with a four-row keyboard that I&#39;m rebuilding around software of my own. It runs CyanogenMod 7, the community build of Android 2.3, and on that ROM the honest answer is: hard enough that I ended up signing my app with Google&#39;s published platform key. Phones that refuse simple requests get my full attention, which has never once been good news for the phone or for my evening.&lt;/p&gt;</description>
			</item>
			<item>
				<title>Android test-keys: system UID with AOSP&#39;s public platform key</title>
				<link>https://tsvinc.dev/posts/aosp-test-keys/</link>
				<pubDate>Fri, 22 Dec 2023 00:00:00 +0000</pubDate>
				<guid>https://tsvinc.dev/posts/aosp-test-keys/</guid>
				<description>&lt;p&gt;My HTC Desire Z, a 2010 slider phone, runs CyanogenMod 7, a custom build of Android 2.3, and I wanted my own small apps on it to do system things: toggle GPS from a background service, take screenshots without the su toast popping up each time. The usual route is root, and root on this ROM announces itself every time, like a butler who insists on reading your name out at your own front door. This part is the other route, which the ROM leaves open by default.&lt;/p&gt;</description>
			</item>
	</channel>
</rss>
