●3.8 FLASH — gemini-3.8-flash reached general availability on September 2, aimed at long-horizon software work, autonomous agents and complex enterprise workflows●LYRIA 3.5 — The lyria-3.5 music model is in public preview. It generates full-length songs in 44.1 kHz stereo and accepts both text and image input●SEPT 30 — Fifteen days until gemini-omni-flash-preview shuts down. Its successor, gemini-omni-1.1-flash, has been generally available since August 27●CACHE — The documented minimum for implicit caching is 4,096 tokens, but developers report nothing firing until past 12k. Whether it is working is something you have to measure yourself●NEW — When BLOCK_NONE changes nothing. Telling apart the cases a lower threshold clears from the ones it never will●403 — Listing models returns 200 while generateContent alone returns 403. The same question resurfaces weekly, including on projects that have just enabled billing●3.8 FLASH — gemini-3.8-flash reached general availability on September 2, aimed at long-horizon software work, autonomous agents and complex enterprise workflows●LYRIA 3.5 — The lyria-3.5 music model is in public preview. It generates full-length songs in 44.1 kHz stereo and accepts both text and image input●SEPT 30 — Fifteen days until gemini-omni-flash-preview shuts down. Its successor, gemini-omni-1.1-flash, has been generally available since August 27●CACHE — The documented minimum for implicit caching is 4,096 tokens, but developers report nothing firing until past 12k. Whether it is working is something you have to measure yourself●NEW — When BLOCK_NONE changes nothing. Telling apart the cases a lower threshold clears from the ones it never will●403 — Listing models returns 200 while generateContent alone returns 403. The same question resurfaces weekly, including on projects that have just enabled billing
The approval rules I had written never matched once — auditing my Gemini CLI policies
A deny rule I trusted for half a year had never matched. The pattern was tested against a JSON string, the folder I kept my rules in is not read, and ask_user turns into deny the moment nobody is watching. Here is the audit, and the script I wrote to keep doing it.
The asset conversion I had left running overnight had not moved a single line by morning.
As an indie developer I keep a set of wallpaper apps, and the dull part of shipping new images — resizing to fixed dimensions, renaming to match a convention — is something I hand to a coding agent. The work itself is simple enough that I only wanted to write down where the line sits, then leave it alone.
I opened the log expecting to find why it had stalled. What I found instead was less comfortable. The rule that was supposed to stop the dangerous command had never matched anything at all. Something else had stalled the run, and the line I had trusted for half a year was swinging at air.
I spent that day reading my policy files from the top. Here is what came out.
The short version: I recounted where matching actually happens
A policy rule is not a string compared against your command. The thing it is compared to, the folder it is loaded from, and the mode you are running in all change the answer. The four problems I hit were not about writing the rule wrong. In every case what I wrote never reached the point of being evaluated.
Symptom
What was really happening
How it shows up
A deny rule does nothing
The regex anchored to the start of a JSON string
You never once see the deny message
Project-level rules are ignored
That tier is not loaded right now
Moving the file to the user tier fixes it instantly
Unattended runs refuse instead of waiting
ask_user is treated as deny
The same run passes interactively
No confirmation on redirection
The downgrade is skipped in permissive modes
Default mode does prompt
Let me take them one at a time.
A ^ in commandRegex anchors to the JSON, not to the command
This was the line I had written first, meaning to stop destructive deletes.
There is nothing wrong with it as a regular expression. But the reference says commandRegex is tested against a stable JSON representation of the arguments. So the subject is not the command — it is the single string {"command":"rm -rf ./build"}.
The moment you add the anchor, the rule stops matching. Worse, nothing tells you. A deny rule that swings at air prints exactly the same thing as one that never had to fire — nothing. A deny rule you have never watched actually deny something is a rule with no evidence behind it.
If you want anchoring, commandPrefix is the safer tool: it means what it looks like it means. Keep commandRegex for conditions that genuinely need a regex, and leave the anchors out. That alone cleared up my swings.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You will be able to find, mechanically and from your own files, the approval rules that have never matched a single call
✦You will know which tier actually gets read, so you stop writing careful rules into a folder nobody loads
✦You will be able to decide where ask_user belongs and where it has to become an explicit allow or deny, before an unattended run quietly refuses itself
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The policies I kept in the project folder are not being read
This one came next. I wanted a different line for each client project, so I had been dropping TOML files into .gemini/policies/ at the root of each repository. Different work, different blast radius — carrying the rules alongside the code felt right.
The reference carries a note that workspace-tier policies are currently non-functional. The tiers are defined as Default, Extension, Workspace, User and Admin, and of those, Workspace alone is not loaded at the moment.
What makes this expensive is that putting the file in the wrong place produces no warning. The TOML is valid. The schema is right. Nobody comes to read it. I thought I had carefully separated my client work, and in reality one older file in the user tier was governing all of it.
Now I split per-project differences by filename inside the user tier, and the repository holds only a note explaining which line that project runs under. A setting in a folder nobody reads helps no one; a setting in the loaded folder, with its reasoning written down nearby, helps me six months later.
ask_user becomes deny the moment nobody is watching
The third one was the direct cause of the morning with nothing done.
There are three decisions: allow, deny, and ask_user. The reference gives ask_user a single extra line — in non-interactive mode it is treated as deny.
While you are at the keyboard, ask_user looks like the cautious option. This might be risky, so let me look at each one. Carry the same rule into an unattended path, though, and there is nobody to ask, so it refuses without waiting. There is no error to speak of. The agent reports the refusal back to the model and starts looking for another way around.
That is what my overnight run was doing. I had left ask_user on write_file and then gone to sleep. Deciding to confirm is only a decision if someone is there to answer.
These days I split the paths explicitly with interactive.
# Interactive, at my desk: I want to see each one[[rule]]toolName = "write_file"decision = "ask_user"interactive = truepriority = 120# Unattended: allow exactly the directory it may touch, refuse the rest[[rule]]toolName = "write_file"argsPattern = '"file_path":"[^"]*/assets/generated/'decision = "allow"interactive = falsepriority = 130[[rule]]toolName = "write_file"decision = "deny"denyMessage = "Unattended runs may only write under assets/generated"interactive = falsepriority = 110
I now always attach a denyMessage. If all the model gets back is "denied", it will keep trying variations. One line describing what would pass saves a surprising number of round trips.
The redirection prompt is skipped in permissive modes
The fourth one I did not hit myself. I found it mid-audit, and it made me sit up.
The reference documents an allowRedirection field, and says that by default the engine asks for confirmation when redirection (>, >> and friends) is detected, even if a rule matched the command. Operations that change where output lands get treated separately. That seemed sound to me.
A bug report filed on 13 September 2026 says otherwise for two modes. In AUTO_EDIT and YOLO, an early return skips the downgrade entirely. The reproduction quoted in the report is as plain as it gets — approvalMode=YOLO with command="echo x > /outside/ws" — and it also notes that shell the parser cannot read falls to allow rather than deny. It is still open, labelled area/security and priority/p1.
So the "we confirm by default" behaviour the reference describes cannot be relied on under auto-approval. I had been reasoning that only commands on my allowlist could run, so the rest would take care of itself. An allowlist looks at the name of the command. What decides how much damage it does is the arguments and the destination. I had those two backwards.
The fix on my side was small: any write-capable rule I allow in a permissive mode has to state its position on redirection. It removes "I didn't think about it" as an option, and it leaves something to read later.
An underscore in an MCP server name quietly detaches your rules
Here is one I would never have found without reading line by line.
MCP tools are assembled internally into a fully qualified name shaped like mcp_servername_toolname, and the parser splits on the first underscore after mcp_. Put an underscore in your server name and the split lands in the wrong place.
Read the second way, every rule you wrote targeting asset_tools misses. The reference warns that wildcard and security rules can fail silently in exactly this case. The simplest thing is to decide once that server names are hyphenated and never revisit it.
I stopped trying to win on priority numbers across tiers
I did try to work out the priority section, and then I stopped partway.
The table defines tier bases as Default 1, Extension 2, Workspace 3, User 4 and Admin 5. The worked examples in the same section say a User rule with priority: 100 becomes 3.100, and an Admin rule with priority: 20 becomes 4.020. The tier numbers in the table and in the examples do not agree. My guess is that the examples predate the Extension tier, but the page alone will not tell you which one is current.
There is a part you do not have to resolve, though. The relative order — Admin over User, User over Default — is the same under either numbering. So I gave up on winning by absolute value. Cross-tier control is left to the tiers, and I use priority only to order rules within one file.
The one number worth memorising is the range: priority runs from 0 to 999. Write 1000 by accident and the ordering is not what you meant.
I put the audit into one script
None of the five above is easy to catch by reading. So I made it something I can run. Hand it the user tier, and the project folder too if you like, and it lists the rules that cannot match alongside the ones written dangerously.
#!/usr/bin/env python3"""Read Gemini CLI policy TOML files and surface rules that cannot do their job.Usage: python3 policy_audit.py ~/.gemini/policies ./project/.gemini/policies"""from __future__ import annotationsimport sysfrom pathlib import Pathtry: # standard library on Python 3.11 and later import tomllibexcept ModuleNotFoundError: # pip install tomli on 3.10 and earlier import tomli as tomllib # type: ignore[no-redef]WRITE_TOOLS = {"run_shell_command", "write_file", "replace"}PERMISSIVE_MODES = {"yolo", "autoEdit"}ADMIN_DIRS = { Path("/etc/gemini-cli/policies"), Path("/Library/Application Support/GeminiCli/policies"),}def is_workspace_dir(d: Path) -> bool: """True for any .gemini/policies that is not a tier actually being loaded.""" d = d.resolve() if d == (Path.home() / ".gemini" / "policies").resolve(): return False if d in ADMIN_DIRS: return False return d.parts[-2:] == (".gemini", "policies")def load_rules(path: Path): """Read each TOML file and yield (filename, index, rule).""" out = [] for f in sorted(path.glob("*.toml")): try: data = tomllib.loads(f.read_text(encoding="utf-8")) except tomllib.TOMLDecodeError as e: # An unreadable file is skipped wholesale. Say so rather than stay quiet. out.append((f.name, None, {"__parse_error__": str(e)})) continue for i, rule in enumerate(data.get("rule", []), start=1): out.append((f.name, i, rule)) return outdef audit(rule: dict) -> list[tuple[str, str]]: """Inspect one rule and return a list of (severity, finding).""" found = [] names = rule.get("toolName") names = [names] if isinstance(names, str) else list(names or []) modes = set(rule.get("modes") or []) decision = rule.get("decision") creg = rule.get("commandRegex") if isinstance(creg, str) and creg.startswith("^"): found.append(("BLOCK", f"commandRegex starts with ^ ({creg!r}). " "The subject is a JSON string, so this never matches")) mcp = rule.get("mcpName") if isinstance(mcp, str) and "_" in mcp and mcp != "*": found.append(("BLOCK", f"mcpName contains an underscore ({mcp!r}). " "Use hyphens instead")) if decision == "ask_user" and rule.get("interactive") is not False: found.append(("WARN", "decision=ask_user becomes deny in non-interactive runs. " "State allow or deny explicitly for unattended paths")) if decision == "allow" and (modes & PERMISSIVE_MODES) and set(names) & WRITE_TOOLS: if "allowRedirection" not in rule: found.append(("WARN", f"write tool allowed in modes={sorted(modes & PERMISSIVE_MODES)}. " "State your position on redirection via allowRedirection")) if decision == "allow" and "*" in names and not rule.get("argsPattern"): found.append(("BLOCK", "toolName=* is allowed unconditionally. " "Add argsPattern or commandPrefix")) pri = rule.get("priority") if isinstance(pri, int) and not (0 <= pri <= 999): found.append(("BLOCK", f"priority={pri} is outside the 0-999 range")) return founddef main(argv: list[str]) -> int: if len(argv) < 2: print(__doc__) return 2 worst = 0 for raw in argv[1:]: d = Path(raw).expanduser() if not d.is_dir(): print(f"-- {d}: no such directory") continue if is_workspace_dir(d): print(f"-- {d}") print(" BLOCK workspace-tier policies are not loaded right now. " "Move them to the user tier or an admin directory") worst = max(worst, 2) continue print(f"-- {d}") rules = load_rules(d) if not rules: print(" (no rules)") continue for fname, idx, rule in rules: if "__parse_error__" in rule: print(f" BLOCK {fname}: cannot parse TOML - {rule['__parse_error__']}") worst = max(worst, 2) continue for level, msg in audit(rule): print(f" {level:<6} {fname} [[rule]] #{idx}: {msg}") worst = max(worst, 2 if level == "BLOCK" else 1) print("result:", {0: "OK", 1: "WARN", 2: "BLOCK"}[worst]) return 1 if worst == 2 else 0if __name__ == "__main__": sys.exit(main(sys.argv))
Pointed at something close to my files before the audit, it prints this.
-- /home/dolice/.gemini/policies BLOCK my-rules.toml [[rule]] #1: commandRegex starts with ^ ('^rm -rf'). The subject is a JSON string, so this never matches WARN my-rules.toml [[rule]] #2: write tool allowed in modes=['autoEdit', 'yolo']. State your position on redirection via allowRedirection BLOCK my-rules.toml [[rule]] #3: mcpName contains an underscore ('asset_tools'). Use hyphens instead WARN my-rules.toml [[rule]] #4: decision=ask_user becomes deny in non-interactive runs. State allow or deny explicitly for unattended paths BLOCK my-rules.toml [[rule]] #5: toolName=* is allowed unconditionally. Add argsPattern or commandPrefix BLOCK my-rules.toml [[rule]] #5: priority=1000 is outside the 0-999 range-- /home/dolice/work/client-site/.gemini/policies BLOCK workspace-tier policies are not loaded right now. Move them to the user tier or an admin directoryresult: BLOCK
It exits 1 on any BLOCK, so you can run it at the entrance of an unattended path and refuse to start the real work when it fails. Check the configuration before loading the configuration — that order feels right to me now.
One mistake worth confessing. My first version decided "is this a workspace directory?" by asking whether the path sat under the home directory. Handed a fake home during testing, it flagged the user-tier files as BLOCK too. The test is not "inside home" but "equal to the location that gets loaded" — a difference of a few characters that returns the opposite answer.
Where the line sits now
Six months of swinging at air compressed into one sentence:
Allow by destination rather than by name, and confirm only where a person is standing.
Listing command names in an allowlist is satisfying, but the thing that matters later is where the write lands. And choosing to confirm only means something while somebody is there to answer. Unattended paths need answers written down in advance, not questions.
Point the script at your policy directory once. If it stays quiet, good. If even one line comes back, that rule is probably not doing the job you believe it is.
I did not think my own files were in that state either, until I looked. I hope this saves you the morning I lost.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.