Skip to content

9 min read

The guarantee the security team wants is a gate, not a sentence

On this page

The change that started the meeting was fourteen lines. Someone extended the idle-session timeout from 30 minutes to 12 hours in services/auth/src/session.ts, the agent wrote it, a teammate approved it in ninety seconds because the diff was small and the ticket said "usability", and it merged. The organisation's instruction — flag any change to authentication code for security review — was in the assembled context for that session, 1,180 tokens in, and it was also true that the project file said authentication changes in this repository are handled by the platform team. Nothing was flagged.

The security lead's request at the meeting is that org-layer rules become non-overridable. The request is reasonable and the phrasing points at the wrong object, because what they want is not a stronger sentence but a property: for this class of change, review happens, including on the run where a paragraph got weighted differently and including on the day someone deliberately edits the project file to make it stop. Nothing you can write in an instruction file has that property, for the reasons chapter 5 laid out. Three mechanisms outside the text do.

Three gates, three points in the lifecycle

Flowchart: agent proposes an edit → file written (gate 1 tool-call hook); file written → commit; commit → pull request; pull request → checks pass (gate 2 CI path check); checks pass → merge (gate 3 required review); agent proposes an edit → file written (org sentence: influences, does not block).

The first gate runs before the edit exists. Most hosts can call out to a program of yours before a tool call is executed — a hook, a script the host runs at a fixed point in the loop and whose exit value it obeys — and refuse the call based on its arguments, which means an attempt to write to an authentication path can be stopped in the loop and the agent told why.

bash
#!/usr/bin/env bash
# .claude/hooks/guard-auth-paths.sh — invoked before Edit/Write tool calls
path=$(jq -r '.tool_input.file_path')
case "$path" in
  */services/auth/*|*/packages/identity/*|*/middleware/session*)
    if [ -z "$SECURITY_REVIEW_TICKET" ]; then
      jq -n '{decision: "block",
              reason: "Authentication path. Open a security review ticket and
                       re-run with SECURITY_REVIEW_TICKET set."}'
      exit 0
    fi
    ;;
esac
jq -n '{decision: "allow"}'

Two details in that script are deliberate. It matches on the path rather than on the content of the edit, because the content arrives as a diff the hook would have to parse and misjudge, while a path is a string the hook can decide about in a millisecond. And it has an explicit escape via an environment variable, which sounds like a hole and is the difference between a control people work with and one they route around: an engineer who has opened the review ticket needs a way to proceed, and if the only way is to delete the hook, the hook will be deleted and will not come back. The escape is also the thing you log, which converts a bypass from an invisible event into a line someone can count.

The second gate runs in CI, after the change exists and before it can merge. It has the advantage of applying to every change regardless of whether an agent, an IDE or a text editor produced it, which matters because the control the security team wants is about authentication code rather than about agents.

yaml
# .github/workflows/auth-review.yml
name: auth-review
on: pull_request
jobs:
  require-security-review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - name: Fail if auth paths changed without a security review label
        run: |
          changed=$(git diff --name-only origin/${{ github.base_ref }}...HEAD)
          if echo "$changed" | grep -qE '^(services/auth/|packages/identity/)'; then
            labels='${{ toJson(github.event.pull_request.labels.*.name) }}'
            echo "$labels" | grep -q 'security-reviewed' || {
              echo "::error::Auth paths changed; security review required."
              exit 1
            }
          fi

The fetch-depth: 0 line is load-bearing, since a shallow clone cannot compute the three-dot diff — base...HEAD, which asks what this branch changed since it left the base rather than how the two tips differ today — against the base branch and the step will either error or, worse, report an empty change list and pass. A CI check that passes when it cannot evaluate its own condition is a control that fails open, and fails open is the default behaviour of most scripts written in a hurry. Whichever way you build this, decide explicitly what happens when the check cannot run — the label API is down, the base ref is missing, jq is not on the image — and make that outcome a failure.

The third gate is the one that needs no new code at all. A CODEOWNERS entry plus branch protection makes the security team a required reviewer on any pull request touching those paths, enforced by the forge rather than by anything running in your repository.

text
# .github/CODEOWNERS
/services/auth/          @acme/security
/packages/identity/      @acme/security
/middleware/session*.ts  @acme/security

A CODEOWNERS file on its own is advisory, since it only requests reviewers; the enforcement comes from a branch protection rule requiring review from code owners, and teams routinely ship the first without the second and believe they are protected. The other thing to check is whether the protection dismisses stale approvals on new commits, because otherwise an approval collected on a two-line diff persists while the branch grows another forty.

Tool-call hookCI checkRequired review
RunsBefore the editOn every PRBefore merge
Catches human changesNoYesYes
Bypassed byRunning the agent elsewhere, unsetting the hookAdmin merge, disabled workflowAdmin override, and it is logged
Feedback latencySeconds, in the loop2–4 minutesHours to days
Marginal cost per triggered changeZero~3 CI minutes20–40 minutes of a reviewer
Who maintains itWhoever owns the repo's hooksPlatformSecurity team's on-call

None of the three is a strictly better version of another, and the sensible arrangement uses more than one: the hook gives fast feedback so an engineer learns at the moment of the edit rather than twenty minutes later, and the required review is the thing that actually holds. A hook alone is the weakest of the three as a control, because the agent's configuration is exactly what an engineer under deadline pressure will edit, and a hook lives in the same repository as the file it is protecting.

Before choosing among them it helps to state the threat model out loud, because "non-overridable" means different things against different adversaries. The session-timeout change was produced by indifference: nobody was trying to evade anything, and a rule that holds against a distracted engineer and a model that weighted a paragraph oddly is sufficient. A control that has to hold against someone deliberately shipping an authentication weakening is a much stronger requirement, and only the third gate comes close, because it is enforced by a system the person changing the code does not administer. Ask which of the two the security team is buying, since the answer determines whether a hook in the repository counts at all. In most organisations the honest answer is the first, and saying so keeps the conversation from drifting into controls nobody will staff.

Every one of these has a bill

The security lead should hear the price before agreeing, because the org-layer sentence cost nothing and the replacement does not.

The first cost is that coverage stops at the paths someone enumerated. services/auth/ and packages/identity/ were the authentication code at the time the list was written, and the session-timeout constant moved into packages/config/defaults.ts during a consolidation two quarters later, at which point the gate protected a directory that no longer contained the decision. Nobody noticed, because a gate that stops firing produces silence and silence looks like compliance. The list needs an owner and a review cadence, and a quarterly check that greps the codebase for authentication primitives outside the protected paths is the cheapest version of that.

The second cost is friction on legitimate changes, and it is worth measuring rather than estimating. In the quarter after this was introduced, 140 pull requests touched the protected paths; the security team judged 12 of them to warrant real scrutiny. The other 128 were dependency bumps, log-line changes, test fixtures and type-only refactors, each waiting a median of 6 hours for a review that took a reviewer two minutes and produced no finding. That is roughly 4.3 reviewer-hours a quarter, which is affordable, and 128 instances of a team learning that the security gate is usually noise, which is expensive in a way that does not appear on any dashboard. Narrowing the trigger — file globs plus a content filter for the twenty or so symbols that actually matter — cut it to 41 pull requests without changing which 12 were caught.

The third cost is an owner. The org-layer sentence needed nobody: it was written once, distributed automatically, and applied on most runs at a marginal cost of zero. A required review needs someone on call to answer within a working day, a path list that stays current, an escalation route for the Friday-evening hotfix, and a documented override with an audit trail. That is a standing commitment of a few hours a week, and a control with no owner degrades into a rubber stamp within about two quarters, which is worse than no control because it produces an audit trail that says review happened.

A control nobody tests is a claim rather than a control, and gates are unusually prone to silent decay because their success state is indistinguishable from their absence. The cheap remedy is a scheduled pull request — monthly, automated — that adds a comment line to a file inside the protected paths and asserts that the CI check fails and the security team is requested. It takes an afternoon to write and it catches the two failure modes that matter: a path list that no longer covers the code, and a workflow that was disabled during an unrelated incident and never re-enabled. On the team in this chapter, the first such test after the config consolidation is what surfaced that the timeout constant had moved.

Keep the sentence

Building the gate is not a reason to delete the instruction, and the two do different work. The gate is a floor: on the runs where everything else fails, the change cannot merge unreviewed. The instruction raises the base rate, so most changes arrive at the gate already flagged, already split into a reviewable diff, already carrying a note about what the author thought the security implications were. Removing it would push more traffic through the expensive mechanism, which is exactly the wrong direction given what the 128 uninteresting reviews cost.

What should change about the sentence is the project-layer line that was outvoting it. Chapter 5's rule applies here unchanged: the team's statement that authentication changes are handled by the platform team should either be deleted or rewritten to describe the actual process, which now involves a required reviewer they cannot bypass. Leaving it in place while a gate quietly enforces the opposite produces an agent that confidently tells engineers no security review is needed, immediately before CI tells them it is.

It is also worth saying plainly what the gate does not give the security team, since the gap is where the next incident lives. It is a path-based control over a semantic property, so a change that weakens authentication without touching a protected path — a permissive CORS entry, a logging change that starts writing session tokens, a dependency bump that swaps the JWT library's default algorithm — passes every gate in this chapter. Import-graph analysis and symbol-level ownership narrow the gap and do not close it. The honest framing for the meeting is that this converts an unreliable control over everything into a reliable control over an enumerated subset, which is a real improvement and a different shape of coverage rather than a strictly larger one.

Everything so far has assumed the layered arrangement is worth keeping and that the work is making it behave. That assumption is not universally shared. A platform team looking at four gates, a decision log, a precedence test and a leaked Result type can reasonably conclude that the layering itself is the defect, and propose a single file at the repository root with nothing nested and nothing personal. The next chapter takes that proposal seriously, including the compromise that sounds like it resolves the argument.

Scoping and precedence — read the current version at https://daviddominguez.dev/learn/en/scoping-and-precedence/the-guarantee-the-security-team-wants-is-a-gate-not-a-sentence

Back to top