<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Arpan Patra</title>
    <description>The latest articles on DEV Community by Arpan Patra (@imarpan).</description>
    <link>https://dev.to/imarpan</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1158747%2F514b6f38-b757-4de7-847b-3fbeeca3d4e5.jpg</url>
      <title>DEV Community: Arpan Patra</title>
      <link>https://dev.to/imarpan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/imarpan"/>
    <language>en</language>
    <item>
      <title>Building a user-isolated Gemini journal on Cloud Run</title>
      <dc:creator>Arpan Patra</dc:creator>
      <pubDate>Sun, 06 Sep 2026 16:41:56 +0000</pubDate>
      <link>https://dev.to/imarpan/building-a-user-isolated-gemini-journal-on-cloud-run-2fij</link>
      <guid>https://dev.to/imarpan/building-a-user-isolated-gemini-journal-on-cloud-run-2fij</guid>
      <description>&lt;p&gt;I spent a day building &lt;strong&gt;Waypoint&lt;/strong&gt; for the Google Gen AI APAC Ideathon. The brief was a "Personal Gemini Journal": Firebase Auth, multi-turn Gemini, per-user Firestore isolation, and API keys in Secret Manager. This is what I built on top of that, and the things that nearly went wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://waypoint-796599668154.us-central1.run.app" rel="noopener noreferrer"&gt;https://waypoint-796599668154.us-central1.run.app&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Code:&lt;/strong&gt; &lt;a href="https://github.com/imarpanpatra/waypoint" rel="noopener noreferrer"&gt;https://github.com/imarpanpatra/waypoint&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;You write about your day. Gemini talks it through with you as a real conversation. When you close a session it is distilled into its &lt;em&gt;bearing&lt;/em&gt;, one sentence naming the thread underneath the surface topics, plus a mood and energy score, themes, and the unfinished items you mentioned.&lt;/p&gt;

&lt;p&gt;Every closed session is embedded, so you can later ask "what was I stuck on in August?" and get an answer cited to dated entries. Each session also produces publication drafts: a LinkedIn post, an X thread, a blog outline and a video idea, grounded strictly in what you actually wrote.&lt;/p&gt;
&lt;h2&gt;
  
  
  The isolation decision
&lt;/h2&gt;

&lt;p&gt;The requirement was "zero cross-user leakage". The usual approach is a global collection with a &lt;code&gt;userId&lt;/code&gt; field and a &lt;code&gt;.where()&lt;/code&gt; clause on every query. That works right up until someone forgets one.&lt;/p&gt;

&lt;p&gt;Instead every document lives under the owner's subtree, and the uid is only ever read from the verified token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;userRoot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;uid&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;userRoot() requires a verified uid.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;firestore&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No route accepts a user id from a request body, query string or header. There is no code path that can be pointed at another account, because the reference is constructed from the decoded token at the point of use.&lt;/p&gt;

&lt;p&gt;That matters most for semantic search. A shared vector index is where cross-tenant leaks usually show up in RAG systems. Here the candidate set is read from &lt;code&gt;users/{uid}/sessions&lt;/code&gt;, so isolation is structural rather than filtered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Server-authoritative writes
&lt;/h2&gt;

&lt;p&gt;The starter rules in the codelab suggest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is correctly isolated, but it lets a client write directly to the database, which means server-side validation, sanitisation and embedding generation can all be skipped by anyone willing to open a console. My rules deny client writes entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isOwner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="kr"&gt;private&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;docId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;private/&lt;/code&gt; subcollection holds the Discord webhook URL, readable by nobody on the client, not even the owner. The API returns only a masked form.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving it instead of claiming it
&lt;/h2&gt;

&lt;p&gt;Documentation that says a system is isolated is worth very little. So the app has a &lt;code&gt;/security&lt;/code&gt; page that runs six probes in your browser against the live deployment:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read your own profile document. Should succeed.&lt;/li&gt;
&lt;li&gt;Read a document under a different uid. Should be &lt;code&gt;permission-denied&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Write directly to your own tree from the browser. Should be &lt;code&gt;permission-denied&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Call the API with no bearer token. Should be &lt;code&gt;HTTP 401&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Submit the cloud metadata address as a Discord webhook. Should be &lt;code&gt;HTTP 400&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Download the page's own JavaScript and scan it for Google API key literals.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Five of the six are supposed to fail. That is the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  SSRF, the feature that needed the most care
&lt;/h2&gt;

&lt;p&gt;Waypoint can push a weekly digest to a Discord webhook you own. A user-supplied URL that the server then fetches is a textbook SSRF primitive. On Cloud Run, pointing it at &lt;code&gt;169.254.169.254&lt;/code&gt; would hand over the runtime service account token.&lt;/p&gt;

&lt;p&gt;The control is a hostname allow-list, not a deny-list, because DNS rebinding and redirects defeat deny-lists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ALLOWED_HOSTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;discord.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;discordapp.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ptb.discord.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canary.discord.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;WEBHOOK_PATH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;api&lt;/span&gt;&lt;span class="se"&gt;\/(?:&lt;/span&gt;&lt;span class="sr"&gt;v&lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\/)?&lt;/span&gt;&lt;span class="sr"&gt;webhooks&lt;/span&gt;&lt;span class="se"&gt;\/\d{5,25}\/[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9_-&lt;/span&gt;&lt;span class="se"&gt;]{20,120}&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus https only, no embedded credentials, &lt;code&gt;redirect: 'error'&lt;/code&gt; so an allowed host cannot bounce the request somewhere internal, and an 8 second timeout so a hung third party cannot pin an instance open.&lt;/p&gt;

&lt;p&gt;There are eleven test cases for this, including the metadata server over https with a valid webhook path, which is the one that isolates the host check from the scheme check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things that nearly went wrong
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The free tier is regional
&lt;/h3&gt;

&lt;p&gt;I nearly deployed to &lt;code&gt;asia-south1&lt;/code&gt; because it is closer to home. Cloud Run's Always Free allowance only applies in &lt;code&gt;us-central1&lt;/code&gt;, &lt;code&gt;us-east1&lt;/code&gt; and &lt;code&gt;us-west1&lt;/code&gt;. Worse, the same value sets the Firestore location, which is permanent once the database is created. Getting that wrong means tearing the project down and starting again.&lt;/p&gt;

&lt;h3&gt;
  
  
  A caught error still killed the process
&lt;/h3&gt;

&lt;p&gt;The optional Maps secret lookup failed cleanly, logged a tidy warning, and then the service died a tick later.&lt;/p&gt;

&lt;p&gt;Secret Manager's client builds its gRPC stub lazily and holds the promise internally. When credentials cannot be resolved, that internal promise rejects with nobody awaiting it, and under Node 22 an unhandled rejection terminates the process. Catching the error from &lt;code&gt;accessSecretVersion&lt;/code&gt; is not enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// forces stub creation inside this try block&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;accessSecretVersion&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I confirmed this was the real fix rather than a coincidence by removing the process-level safety net and checking the service still stayed up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cloud Run gives you two URLs
&lt;/h3&gt;

&lt;p&gt;A new-style &lt;code&gt;service-projectnumber.region.run.app&lt;/code&gt; and a legacy &lt;code&gt;service-hash-uc.a.run.app&lt;/code&gt;. Both route to the same service, and Firebase Auth will refuse sign-in on whichever one you did not add to authorised domains.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying the security controls actually work
&lt;/h2&gt;

&lt;p&gt;There are 31 tests covering the pure functions where a regression would be silent: the SSRF allow-list, the prompt-injection fence a user must not be able to escape, the sanitiser that strips zero-width and bidi characters, and undefined-stripping before Firestore writes.&lt;/p&gt;

&lt;p&gt;I confirmed they catch regressions by deliberately breaking things. Disabling the SSRF host allow-list turns four tests red. Injecting a &lt;code&gt;dangerouslySetInnerHTML&lt;/code&gt; and a fake API key is caught by the pre-deploy scanner. A test that has never failed has not been shown to work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prompt injection
&lt;/h2&gt;

&lt;p&gt;Journal text is untrusted input. It gets wrapped in a delimiter the user cannot forge, and every system instruction states that content inside it is data, never instructions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;asUntrustedData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;escaped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;FENCE_OPEN&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[fence]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;FENCE_CLOSE&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[/fence]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;FENCE_OPEN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;escaped&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;FENCE_CLOSE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stripping the delimiters from user text first is what stops someone closing the fence early and escaping into instruction context.&lt;/p&gt;

&lt;p&gt;This is mitigation, not a solution. The blast radius is deliberately small: the model has no tools, no function calling and no database write access, so a successful injection affects the text of one reply and cannot reach data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model resilience
&lt;/h2&gt;

&lt;p&gt;No call site names a model. Everything routes through a fallback ladder that retries on recoverable upstream statuses, so one unavailable model cannot take the application down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT_LADDER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gemini-3.6-flash&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gemini-3.1-flash-lite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gemini-flash-latest&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gemini-3.7-flash&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RECOVERABLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ladder is overridable with an environment variable, so a model rename is a &lt;code&gt;gcloud run services update&lt;/code&gt; rather than a rebuild and redeploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worth stealing
&lt;/h2&gt;

&lt;p&gt;Two ideas I would use again on anything multi-tenant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make isolation structural, not conditional.&lt;/strong&gt; If the only way to address data is a helper that takes a verified uid and builds the path from it, there is no query left to forget a filter on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ship a page that tries to break your own app.&lt;/strong&gt; The security page took an hour and it is the thing I would show first. It converts a claim into something a reviewer can check in ten seconds from their own browser.&lt;/p&gt;

&lt;p&gt;The full source, the threat model, and the deployment steps are all in the repo: &lt;a href="https://github.com/imarpanpatra/waypoint" rel="noopener noreferrer"&gt;https://github.com/imarpanpatra/waypoint&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;#AccelerateAIwithCloudRun&lt;/p&gt;

</description>
      <category>googlecloud</category>
      <category>ai</category>
      <category>webdev</category>
      <category>security</category>
    </item>
    <item>
      <title>Building an AI Agent That Can Actually Take Action</title>
      <dc:creator>Arpan Patra</dc:creator>
      <pubDate>Sun, 30 Aug 2026 17:56:16 +0000</pubDate>
      <link>https://dev.to/imarpan/building-an-ai-agent-that-can-actually-take-action-4of6</link>
      <guid>https://dev.to/imarpan/building-an-ai-agent-that-can-actually-take-action-4of6</guid>
      <description>&lt;p&gt;Adding a dependency is the least careful thing most of us do all week.&lt;/p&gt;

&lt;p&gt;You run &lt;code&gt;npm install some-utils&lt;/code&gt;, and you've just agreed to run a stranger's code on your laptop, in your CI, and eventually in production. The registry ships thousands of new versions an hour. Typosquats, hijacked maintainer accounts, &lt;code&gt;postinstall&lt;/code&gt; hooks that phone home, code that ships in the tarball but never existed in the GitHub repo anybody actually reviewed, they all come through that door.&lt;/p&gt;

&lt;p&gt;And nobody reads the tarball. I certainly don't.&lt;/p&gt;

&lt;p&gt;So for the &lt;a href="https://www.wemakedevs.org/hackathons/trueforge" rel="noopener noreferrer"&gt;Agent Harness Hackathon&lt;/a&gt; I built &lt;strong&gt;Portcullis&lt;/strong&gt; - an agent that reads it for you, and can't add the dependency without asking first.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/imarpanpatra/portcullis" rel="noopener noreferrer"&gt;https://github.com/imarpanpatra/portcullis&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three-minute walkthrough, if you'd rather watch than read:&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/t042CZAsOuM" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  The job
&lt;/h2&gt;

&lt;p&gt;You ask it: &lt;em&gt;can I add &lt;code&gt;left-pad&lt;/code&gt; to this repo?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It checks whether the &lt;strong&gt;name&lt;/strong&gt; is the attack before it reads any code. Then it pulls registry signals age, publish cadence, maintainers, downloads, known advisories. Then it downloads the actual published tarball into a sandbox, unpacks it, and reads what ships: install hooks, obfuscated payloads, &lt;code&gt;child_process&lt;/code&gt; usage, and a diff against the project's own GitHub source at that tag.&lt;/p&gt;

&lt;p&gt;Then it gives you a verdict with evidence. And if you want it added, it stops and asks, because that means a branch, a commit and a pull request on a real repository.&lt;/p&gt;

&lt;p&gt;That last part is the whole point. Everything before it is research; that step is a write you can't take back.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it's wired
&lt;/h2&gt;

&lt;p&gt;Three pieces, and TrueForge handles everything between them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A custom MCP server&lt;/strong&gt; I wrote for npm intelligence. Four read-only tools over public keyless APIs: registry metadata, download stats, OSV advisories, and typosquat detection.&lt;/p&gt;

&lt;p&gt;That last one taught me something. The obvious approach is to search the registry for the package name and look at the neighbours. It doesn't work. Ask npm's search API for &lt;code&gt;expres&lt;/code&gt; and &lt;code&gt;express&lt;/code&gt; is &lt;strong&gt;nowhere in the twenty-five results&lt;/strong&gt;. The victim is missing from the exact query designed to find it.&lt;/p&gt;

&lt;p&gt;So instead of hoping search finds it, I generate the names an attacker would plausibly have registered deletions, adjacent transpositions, doubled letters, homoglyphs like &lt;code&gt;rn&lt;/code&gt;→&lt;code&gt;m&lt;/code&gt; and probe for them directly. The downloads API takes 128 names per request and returns &lt;code&gt;null&lt;/code&gt; for ones that don't exist, so a few hundred candidates resolve in two HTTP calls.&lt;/p&gt;

&lt;p&gt;Pointed at &lt;code&gt;expres&lt;/code&gt;, it finds &lt;code&gt;express&lt;/code&gt; and rather than writing me a security report about a package I never meant to install, it stops and asks:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi6rzoy0jb44vg05rrumn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi6rzoy0jb44vg05rrumn.png" alt="The agent asking whether I meant express, showing 5,481 weekly downloads against 132,879,571" width="800" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A git-backed skill&lt;/strong&gt; holding the audit procedure and a Python inspector. TrueForge clones it into the sandbox on demand. The inspector is standard library only which felt right for a tool whose entire subject is install steps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The GitHub connector&lt;/strong&gt; does the write, and it's gated.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwiiksd5y7gbs9cghjhye.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwiiksd5y7gbs9cghjhye.png" alt="Two connectors configured in TrueForge: github, and portcullis described as npm registry, download statistics and OSV advisories, read-only, no credentials" width="800" height="374"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The whole agent is one JSON document a model, instructions, those connectors, the skill, and the config:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9ljoseyk51gsprq09oqk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9ljoseyk51gsprq09oqk.png" alt="The agent in the TrueForge library: portcullis, gpt-5-5, two connectors, one skill" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What TrueForge actually did for me
&lt;/h2&gt;

&lt;p&gt;I wrote about 3,000 lines. None of it was the agent loop.&lt;/p&gt;

&lt;p&gt;The harness gave me tool routing over MCP, a sandbox provisioned only when the agent needs one, the approval gate, subagents, sessions that survive the client going away, and Generative UI so the report renders as real components instead of a wall of markdown.&lt;/p&gt;

&lt;p&gt;Ask it about five packages and it fans out one subagent each, running at once, results merged into one ranked answer:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbi9ruwynk27osfgd1bdl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbi9ruwynk27osfgd1bdl.png" alt="Five subagents running in parallel, one per package, with verdicts" width="800" height="454"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two of those are worth calling out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sandbox-as-tool.&lt;/strong&gt; The agent loop and all credentials stay in the harness; the sandbox only executes. So the thing unpacking a stranger's tarball never sees my GitHub token. For this project specifically that's not a nice-to-have it's the reason the project is safe to run at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The approval gate is enforced by the harness, not by my client being polite.&lt;/strong&gt; A gated call ends the turn. Resuming requires sending an approval event. So a client that ignores the pause doesn't get a pull request faster it gets no pull request. I couldn't have accidentally written past it if I'd tried.&lt;/p&gt;

&lt;p&gt;I deliberately didn't add a &lt;code&gt;--yes&lt;/code&gt; flag. A flag that pre-approves every write defeats the only claim the project makes, and it'd be the first thing anyone reached for in CI, which is exactly where nobody's watching.&lt;/p&gt;

&lt;p&gt;Approve it, and you get the branch, the commit, and the pull request with the exact dependency line it added:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz6jgmnt2scf7irbq74si.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz6jgmnt2scf7irbq74si.png" alt="The agent reporting the pull request it opened after approval, with branch, commit and dependency line" width="800" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke
&lt;/h2&gt;

&lt;p&gt;This is the useful part.&lt;/p&gt;

&lt;h3&gt;
  
  
  The sandbox had no bash
&lt;/h3&gt;

&lt;p&gt;First real run, the agent gave me a confident, well-structured verdict on &lt;code&gt;express&lt;/code&gt;. Registry signals, advisories, the lot.&lt;/p&gt;

&lt;p&gt;It had never opened the package.&lt;/p&gt;

&lt;p&gt;Every sandbox &lt;code&gt;exec&lt;/code&gt; was coming back &lt;code&gt;fork/exec /usr/bin/bash: no such file or directory&lt;/code&gt;. The agent tried three shell variations, gave up, and answered from registry metadata alone sounding exactly as certain as it would have if it had done the work.&lt;/p&gt;

&lt;p&gt;The fix wasn't to find a shell that happened to exist on that image. It was to stop needing one: the inspector now exposes a callable and the skill imports it under Code Mode. Python is the one interpreter the sandbox guarantees, because the harness already runs its own client there.&lt;/p&gt;

&lt;h3&gt;
  
  
  The docs and the server disagreed about a path
&lt;/h3&gt;

&lt;p&gt;Docs say skills land at &lt;code&gt;/opt/tfy/skills/{name}&lt;/code&gt;. The running server logs &lt;code&gt;/opt/tf/skills&lt;/code&gt;. My skill wouldn't have been found even with a working shell.&lt;/p&gt;

&lt;p&gt;Small thing, twenty minutes lost, and I only caught it by reading the server's own startup log instead of trusting the documentation.&lt;/p&gt;

&lt;h3&gt;
  
  
  The model mattered more than the prompt
&lt;/h3&gt;

&lt;p&gt;On a mini-class model, the agent skipped the tarball inspection entirely. It has registry tools that answer in a second and a sandbox step that doesn't, so it took the fast path and produced a verdict that had never looked inside the package.&lt;/p&gt;

&lt;p&gt;Same instructions, same skill, on a larger model: loads the skill, runs the inspector, quotes the report back.&lt;/p&gt;

&lt;p&gt;The mini model was never &lt;em&gt;wrong&lt;/em&gt; about anything it said. That's the problem. It sounded equally certain having looked at strictly less. I now pin the model in the README and say why.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc4s1nls06i2w87j3uy1n.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc4s1nls06i2w87j3uy1n.png" alt="The models configured in TrueForge, with gpt-5-4-mini and gpt-5-5 both available" width="800" height="370"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Switching is a one-line change, which is part of why this bit is easy to get wrong nothing stops you running the cheap one and believing the output.&lt;/p&gt;

&lt;h3&gt;
  
  
  It cried wolf on express
&lt;/h3&gt;

&lt;p&gt;My first working inspector flagged &lt;code&gt;express&lt;/code&gt; the most ordinary package in the ecosystem for URLs in &lt;code&gt;package.json&lt;/code&gt; and a read of &lt;code&gt;NODE_ENV&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's useless. A tool that panics about express teaches you to ignore it, and then the one real finding gets ignored too. Three rounds of tightening later: &lt;code&gt;express&lt;/code&gt;, &lt;code&gt;chalk&lt;/code&gt; and &lt;code&gt;ms&lt;/code&gt; come back completely silent, while &lt;code&gt;esbuild&lt;/code&gt; reports its &lt;code&gt;postinstall&lt;/code&gt;, &lt;code&gt;child_process&lt;/code&gt; use and network egress all true, and the right answer there is &lt;em&gt;admit with conditions&lt;/em&gt;, not refuse.&lt;/p&gt;

&lt;p&gt;The regression I care about now isn't a missed finding. It's a benign package starting to produce them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Qodo kept catching my own fixes
&lt;/h2&gt;

&lt;p&gt;Every change went through a pull request reviewed by Qodo. Thirty-three findings across eight PRs.&lt;/p&gt;

&lt;p&gt;The ones that stung were the second-order ones bugs my &lt;em&gt;earlier fixes&lt;/em&gt; introduced:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I added extraction size caps to stop decompression bombs. Those caps then let a truncated repository tree manufacture &lt;strong&gt;false critical findings&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;I capped severity in response. That went too far the other way and understated a &lt;em&gt;proven&lt;/em&gt; content mismatch.&lt;/li&gt;
&lt;li&gt;I taught the scanner to read &lt;code&gt;.sh&lt;/code&gt; files, but forgot to extend the provenance check so shell scripts ended up half-examined.&lt;/li&gt;
&lt;li&gt;My reconnect logic had the initial stream outside the try block. So a dropped connection skipped the reattach loop entirely: the one failure it existed to survive was the one that got past it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And my favourite, caught the day of submission: I'd written a report template that always referenced a "limitations" card, then instructed the agent to omit that card when there were no limitations. An undefined reference in OpenUI renders as &lt;em&gt;nothing at all&lt;/em&gt;. So clean audits express, ms, chalk would have produced an empty report on camera, while packages with problems rendered fine.&lt;/p&gt;

&lt;p&gt;The good case was the broken one. I'd have found that during the demo.&lt;/p&gt;

&lt;p&gt;I disagreed with two findings and said why in the thread rather than quietly changing code. One claimed the SDK returned turns newest-first; I checked against a real two-turn session and it's oldest-first. I still rewrote that code to not depend on ordering at all, which is the durable fix either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Would I hand this job to an agent again
&lt;/h2&gt;

&lt;p&gt;Yes, but narrowly.&lt;/p&gt;

&lt;p&gt;What makes this work isn't that the model is clever. It's that the harness gave it a place to run dangerous code, a way to reach real data, and a hard stop before the irreversible bit. Take any of those away and it's a chatbot with opinions about npm.&lt;/p&gt;

&lt;p&gt;The part I'd still tell people to be careful about: the agent is &lt;em&gt;most&lt;/em&gt; convincing when it has done the least work. Both times mine produced a confident wrong-shaped answer the missing bash, the mini model the output looked great. It was the tool traces that gave it away.&lt;/p&gt;

&lt;p&gt;Code's all open source, MIT: &lt;a href="https://github.com/imarpanpatra/portcullis" rel="noopener noreferrer"&gt;https://github.com/imarpanpatra/portcullis&lt;/a&gt;&lt;br&gt;
Demo: &lt;a href="https://youtu.be/t042CZAsOuM" rel="noopener noreferrer"&gt;https://youtu.be/t042CZAsOuM&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Built with AI assistance, implementation and working through review findings. The design decisions, severity calibration and the calls on each review finding were mine.)&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>security</category>
      <category>node</category>
    </item>
  </channel>
</rss>
