Skip to content

gateguard: Edit/Write first-touch should stop after N denials per session, not gate every new file forever #2608

Description

@kaiser-data

Summary

GateGuard's Edit/Write fact-force gate is per-file first-touch for the entire session. Scaffolding or multi-file implementation work produces a long trail of denials (denial #1denial #14 …) that re-enter the context window even after the model has already learned the four-fact protocol.

One (or a few) fact-force cycles per session is enough. After that the LLM remembers the pattern; re-injecting the same instruction on every new file is pure context tax and works against the dampening goal of #2142.

Suggested optimal workflow

GateGuard should feel like onboarding the agent once, not like a toll booth on every file.

Session shape (what we want)

t=0   Session starts, user asks for a multi-file feature / scaffold
t=1   Agent investigates (Grep/Read) for real
t=2   FIRST Edit or Write this session
        → Gate DENIES once (full four-fact block)
t=3   Agent states facts once, carefully:
        - importers/callers (or "new file; will be called by X")
        - public API surface affected
        - data schemas / formats if any
        - user instruction verbatim
t=4   Agent retries the same tool call → ALLOWED
t=5…N Remaining Edit/Write/MultiEdit on other files in this session
        → ALLOWED without another fact-force cycle
          (model already has the protocol + project context in-window)
later  Destructive Bash (rm -rf, drop table, force-push, …)
        → still DENY once per distinct command, always

Why this is optimal

Concern Per-file forever (today) Once-per-session protocol (proposed)
Investigation quality High on file 1, ritual on files 2–20 High on the one forced pass; later work uses real context
Context cost Deny + restate + retry × N files Deny + restate + retry × 1 (or small N)
Trust / safety Same first-touch tax on every path Still forces a deliberate start; destructive stays strict
Scaffold / greenfield Worst case (many first Writes) Best case (exactly when the budget helps)
Long autonomous runs Amplifies #2142-style repetition risk Aligns with #2142 dampening and reduces turn count

The load-bearing insight: the value of fact-forcing is teaching the agent to investigate before acting, not re-teaching it on every path. After one good cycle, the same session's later Writes are usually the same plan, same schemas, same user instruction — re-gating them is noise.

Concrete defaults (direct recommendation)

GATEGUARD_FACT_FORCE_FULL_DENIALS = 1   # one full four-fact block
GATEGUARD_FACT_FORCE_MAX_DENIALS  = 1   # NEW: then allow further first-touches

Optional slightly stricter default for teams that want a short ramp:

FULL_DENIALS = 1
MAX_DENIALS  = 3   # full once, two condensed, then free first-touch

Destructive Bash: unchanged (always first-attempt deny per command fingerprint).
Routine Bash: keep once-per-session or default-off (#2573); not part of this proposal.

Agent-side ritual (what the one cycle should look like)

On the single forced Edit/Write deny, the agent should:

  1. Search before inventingGrep/Glob for importers and same-purpose modules.
  2. Name the contract — public functions/classes, or "new module; caller will be services/ingestion.py".
  3. Show schema only if data is involved — field names + formats with synthetic values; skip with "no data files" otherwise.
  4. Quote the user instruction — one verbatim line/block.
  5. Retry the same tool call — no re-planning theatre.

After that succeeds, treat the protocol as established for the rest of the session. Do not restate the four facts before every subsequent file.

Operator mental model (one sentence)

"GateGuard makes me prove I investigated at the start of the session; it does not charge me again for every file in the same plan."

That is the product behavior operators already expect from the name of GATEGUARD_FACT_FORCE_FULL_DENIALS. Implement MAX_DENIALS (or redefine the existing knob as a hard stop) so the product matches that mental model.

Related

#2142 reduced message size. It did not reduce denial count. Condensed denials still fire, still block the tool, still force a retry turn, and still accumulate in context.

Observed behavior (ECC 2.0.0)

During a greenfield multi-file scaffold (many first Writes of new modules):

[Fact-Forcing Gate] (denial #14 this session) First creation of
…/app/services/chunking.py: briefly state importers/callers,
affected API, data schemas if any, and the user's verbatim instruction,
then retry. (ECC_GATEGUARD=off disables this gate.)

Operator already had:

{
  "env": {
    "GATEGUARD_BASH_ROUTINE_DISABLED": "1",
    "GATEGUARD_FACT_FORCE_FULL_DENIALS": "1",
    "GATEGUARD_EXEMPT_GLOBS": "**/tests/**,**/docs/**,**/*.md,…"
  }
}

Expectation from the name/docs of GATEGUARD_FACT_FORCE_FULL_DENIALS: after the first full denial, stop (or soft-pass).
Actual: every new path still denies once; only the wording shortens. Denial ordinal keeps climbing.

Hook path: scripts/hooks/gateguard-fact-force.js — Edit/Write branch keys on file_path (isChecked / markCheckedAndCountDenial). getFullDenialBudget() only chooses full vs condensed message; it never allows an unchecked path.

Why this is a product bug, not operator error

  1. Diminishing returns. The first deny forces investigation (importers, schemas, verbatim user instruction). Denials 2–N for different new files usually get a ritual recitation of the same template, not new awareness — especially when building a vertical slice from a handoff doc.
  2. Context pollution. Each deny + model restate + retry is 2–3 tool turns. At 10–20 new files that is dozens of near-duplicate blocks in the window — exactly the class of problem GateGuard fact-force restate-retry loop amplifies model repetition traps in long sessions #2142 tried to reduce.
  3. Misleading knob. GATEGUARD_FACT_FORCE_FULL_DENIALS reads like a session budget for fact-forcing. It is only a message-shape budget. Operators set it to 1 expecting "once is enough" and still hit denial 🎉 v1.0.0 Released - Now a Claude Code Plugin #14.
  4. Nuclear recovery only. Condensed messages point at ECC_GATEGUARD=off. There is no graduated "stop first-touch after N denials this session" knob.

Routine Bash is already once-per-session (__bash_session__). Edit/Write has no equivalent session ceiling.

Proposal

Preferred: session-wide first-touch budget that actually stops

Add something like:

GATEGUARD_FACT_FORCE_MAX_DENIALS   # default e.g. 3 (or same as FULL_DENIALS)

Semantics:

Denial ordinal Behavior
1 … FULL_DENIALS Full four-fact block (current)
FULL_DENIALS+1 … MAX_DENIALS Condensed one-liner (current dampening)
> MAX_DENIALS Allow first-touch without denying; optionally one stderr note once per session

Set MAX_DENIALS=0 or FULL_DENIALS=0 + MAX_DENIALS=0 for "never fact-force Edit/Write" without killing the destructive-Bash gate.

Simpler alternative that matches user mental model:

  • Treat GATEGUARD_FACT_FORCE_FULL_DENIALS as the hard stop after which further first-touches are allowed (not merely condensed). That is a breaking semantic change — so a new MAX_DENIALS env is safer, with docs clarifying the two knobs.

Also good

  1. Document the per-file vs per-session distinction in skills/gateguard/SKILL.md in one sentence: "Edit/Write gates the first touch of each path; setting FULL_DENIALS only shortens later messages, it does not stop denials."
  2. Recovery hint on condensed denies should mention the new budget env, not only ECC_GATEGUARD=off.
  3. Optional: once-per-session "protocol established" key — after one successful fact-present + retry on any file, subsequent first-touches of new files in the same session auto-allow (aggressive; MAX_DENIALS is more tunable).

Non-goals

  • Do not weaken the destructive-Bash gate.
  • Do not remove first-touch entirely by default — N=1..3 of real investigation still has value.
  • Subagent passthrough (parent already gated) can stay as-is.

Workaround (today)

{
  "env": {
    "ECC_GATEGUARD": "off"
  }
}

or surgically:

{
  "env": {
    "ECC_DISABLED_HOOKS": "pre:edit-write:gateguard-fact-force"
  }
}

(GATEGUARD_FACT_FORCE_FULL_DENIALS=1 is not a sufficient workaround for multi-file scaffolds.)

Env changes require a new Claude Code session to take effect.

Environment

  • ECC 2.0.0 plugin (ecc@ecc), marketplace affaan-m/ECC
  • Claude Code on macOS
  • Multi-file greenfield work under a project scaffold (~10–20 first Writes per session)

Acceptance criteria

  • After N Edit/Write fact-force denials in a session, further first-touches of new paths are allowed without a deny (configurable N).
  • Destructive Bash still denies on first attempt of a destructive command.
  • Docs state clearly: full-denial budget vs max-denial (stop) budget.
  • Condensed deny recovery text mentions the stop-budget env when present.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions