Skip to main content
All insights

The scan was installed

securityaios

The scan was installed. It had never run.

I am putting two years of agents and skills on one harness. Claude Code, Codex, Grok, and Antigravity read the same rules, and the local work stays on the machine I already run. The two years stay. The first change in review is the security check that was supposed to be on.

Last week Forever Security reported a planted instruction file in a customer's Downloads folder. The coding agent read it and tried to install a package, and their report says Codex followed the file in more than 90% of tests while a similar file redirected Claude Code's traffic in about half of them. They said there is nothing for the vendor to patch, because the agent is doing what it was built to do. It trusts the instruction file in the folder it was opened in.

Four coding tools read one set of shared rules and checks while a planted AGENTS.md file slides in from the Downloads folder.

One set of rules for every tool. The control is which file the agent trusts.

That is why the skill move waits. The first change is the check that was already supposed to be running.

The sensitive-data checks were in the template, and setup and update copied that template only when no settings file existed, so a machine that already had settings never received the new checks. The same gap was on the laptop. The merge adds the missing checks, it does not remove a hook I set by hand, and it does not rewrite the permissions already on the machine. A difference in those permissions is reported and left as the machine has it. If the scan slows a session, I can switch it off, and the hooks stay installed.

A secret has two chances to be caught. When the scanner is on the machine, the check runs on what is about to be committed, and when the scanner is missing, that check warns and lets the commit through, so the automated check on the pull request is the backstop. If that check finds a key, the key has already reached the remote. Deleting the line is not enough, because the line is still in the history, so the key has to be rotated.

A masked key runs the pipeline twice. With the scanner installed, the commit hook blocks it. With the scanner missing, it passes the hook and the pull request check catches it after the push.

Two runs of the same key. The first is stopped before the commit. The second is caught after the push, and the key has to be rotated.

I walked the plan, and then I walked the code, before this went into review. Both came back clear. The change is in review. It is not on the machines yet.

The rest of this post is the working record behind that paragraph. It is a cleaned-up version of the plan file, the code and my ownership check, with client names, paths and internal tooling taken out. The code below is rewritten to show the idea. It is not the file in the repo.

What the read-only pass found

Before any plan, three read-only passes went through the repo and the live settings. Nothing was changed. The findings that set the order of work:

  • The setup script copied the settings template only when no settings file existed. The update script printed a notice and stopped. The live settings file was missing 5 hook entries from the template: the sensitive-data scan on tool calls, tool results, session start and each prompt, plus a context-size check at the end of a turn.
  • The scan's log folder did not exist. It had never written a line, so it had never run.
  • A second work profile is built from the live settings file, so it was missing the same 5 hooks.
  • 92 lines across 5 rules files were double-encoded (UTF-8 read as Windows-1252 and saved again), including the first heading of the shared rules file.
  • No secret scan ran anywhere: no config, no commit hook, no pull request check. A full history scan found 0 secrets. The 4 hits in the working tree were all inside an untracked Python virtual environment.

The plan, cleaned up

# Change What it never does
1 Merge the template's hooks into an existing settings file on setup and update Remove a hook, reorder a hook, or apply a permission difference
2 Rebuild the second profile from the merged file, so it has one owner Add a second merge path a later rebuild would overwrite
3 Copy each profile's own skills into that profile, backing up a copy that differs Touch a skill that matches
4 Repair the double-encoded lines, and fail the test on any new ones Change the wording of a rule
5 Add a secret scan: a commit hook and a pull request check Block a commit on a machine without the scanner

Decisions and the option I turned down each time:

  • Two pull requests, not one. The hooks ship first and the skills deploy follows. One large pull request would have been harder to review.
  • The merge adds and never removes. A permission difference is printed for me to decide. Applying the template's permissions would have overwritten choices I made by hand.
  • Run the scanner's own command line in CI, pinned and checksum-verified, instead of the packaged action, so no license question comes up.
  • Option Z, leave the settings alone. Turned down, because the scan would go on never running.

Each step also carries an autonomy level. The settings merge writes the live file, so it is set to Medium, and the merge waits for my word even when every check is green.

The code, simplified

The merge compares commands after normalising slashes, case and spacing, so a path written two ways is not added twice. It only ever appends.

def norm(cmd: str) -> str:
    return " ".join(cmd.replace("\\", "/").lower().split())

def merge_hooks(live: dict, template: dict, skip: set[str]) -> list[str]:
    report = []
    hooks = live.setdefault("hooks", {})
    for event, groups in template.get("hooks", {}).items():
        live_groups = hooks.setdefault(event, [])
        for group in groups:
            target = next((g for g in live_groups
                           if g.get("matcher") == group.get("matcher")), None)
            for hook in group["hooks"]:
                cmd = norm(hook["command"])
                if cmd in skip:
                    report.append(f"skipped {event}: on the skip list")
                    continue
                if target and any(norm(h["command"]) == cmd for h in target["hooks"]):
                    continue
                if target is None:
                    target = {"matcher": group.get("matcher"), "hooks": []}
                    live_groups.append(target)
                target["hooks"].append(hook)
                report.append(f"added {event}")
    return report

The file is written only when something was added. It is backed up first, written to a temp file, and swapped in with a rename, so a crash cannot leave half a settings file. If the existing file is not valid JSON, nothing changes and the run exits with an error.

The commit hook warns and lets the commit through when the scanner is missing, so a machine without it is never blocked:

#!/bin/sh
if ! command -v gitleaks >/dev/null 2>&1; then
  echo "gitleaks not installed: commit allowed, the pull request check will scan it" >&2
  exit 0
fi
exec gitleaks git --pre-commit --staged --redact

The pull request check installs a pinned version and refuses to run if the download does not match its published checksum:

- uses: actions/checkout@v4
  with:
    fetch-depth: 0          # the whole history, not just the last commit
- name: Install gitleaks (pinned, verified)
  run: |
    curl -sSLo gl.tgz "$GITLEAKS_URL"
    echo "$GITLEAKS_SHA256  gl.tgz" | sha256sum -c -
    tar -xzf gl.tgz gitleaks
- name: Scan
  run: ./gitleaks git --redact --verbose

The ownership check

Before a plan is approved and before code leaves the branch, I run an ownership drill on it. The model asks, I answer in my own words, and it grades each answer against the plan or the code. This change touches security, so the drill could not be put off or skipped. Here are three of the plan questions, condensed.

Q. A session is slow and the scan's log fills with "unavailable". What is wrong, how do you switch it off without undoing the merge, and how do you roll back? "Unavailable" means the scan could not finish, not that it found something. Check whether the scan service is running, check for duplicate hooks, and compare with the normal 55 to 120 ms per scan. Switch it off with the OFF file and the hooks stay installed. Roll back by restoring the pre-merge backup, rebuilding the second profile, and stopping the next update from merging the hooks straight back in. Graded Solid. That last point was a real gap in the plan, because nothing stopped the update from re-adding a hook I had turned off. The plan gained a skip list, and the merge now reports what it skipped.

Q. Where does each secret-scan layer catch a key, and which one works without the scanner? The commit hook catches it before the commit, when the scanner is installed. Without it, the hook warns and passes, and the pull request check catches it. The allow-list keeps dependency files and placeholders from raising false alarms that teach people to bypass the scan. Graded Solid. Added: the pull request check finds a key after the push, so the key is already on the remote and has to be rotated. My point about keeping the allow-list narrow changed the plan, and the config was later dropped entirely, because the default rules raised nothing and an unneeded allow-list is a blind spot.

Q. How do you know the rules repair lost nothing? The baseline test compares every section of the old rules with the new ones, after repairing the old text the same way, so it compares the rules and not the encoding. A separate check fails on any new double-encoding. Graded Solid.

The code drill went through five chunks of the change the same way. Every answer was graded against the line it described, and all five came back Solid.

Where the checks stand

Every criterion in the plan has a row before any code is written. A row with no evidence stays unresolved.

Check Verdict Evidence
Dry run on a copy of the live file lists exactly the 5 missing hooks, and a second run adds nothing pass 10 to 15 hook entries, other keys unchanged, 20-check test passed
A hook on the skip list is not added, and the run says so pass Test written with backslashes and capitals to prove the normalised compare
Rules repair loses no section pass 92 lines in 5 files repaired, baseline test passed on 56 sections
A staged fake key is blocked, and the pull request check runs green pass Commit blocked with "leaks found: 1". 323 commits scanned, no leaks
A new session writes a scan log line on both profiles unresolved Due at deploy
Each tool opens a reference file through the new wording unresolved Due at deploy
The laptop update runs the merge unresolved Due at deploy

Three rows are still open, and they can only pass on the machines themselves. That is the next step after the merge.

What comes next

After it merges, the next unit is the skills. One deploy writes the copy each tool actually reads, and a check fails if a copy drifts. Grok and Antigravity get a short list first, because loading every skill at startup has already hung a session.

Search is a separate addition. It lets a model check a claim against pages, and it is not wired to every model yet. CopernicIQ is the local decision engine on the Spark. It checks a claim against text I supply, and it speaks the System One decision API. It does not search the web.

Have a Project in Mind?

Every project starts with understanding the problem. Tell us yours.