Skip to content

B-05: interstitial args between git subcommand and flag bypass the matcher (installed release; fixed on main) #2644

Description

@JDiz00

B-05: ordinary arguments between Git subcommands and flags bypass matching

Summary: In the installed ECC release, an ordinary argument or option between a dangerous Git subcommand and its flag defeats the matcher because the flag must immediately follow the subcommand.

Affected version: 2.0.0-rc.1, commit 841beea45cb2.

Environment

The observation is against the installed copy of gateguard-fact-force.js, which is 487 lines. The plugin version is 2.0.0-rc.1 at commit 841beea45cb2.

Reproduction

Save as repro.js and run with node repro.js. Replace <plugin> with the installed
plugin directory.

const os = require('os'), fs = require('fs'), path = require('path');

// Isolate hook state so the repro cannot touch a real ~/.gateguard directory.
process.env.HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'ggrepro-'));

// The module exports exactly one function: run(rawInput).
const { run } = require('<plugin>/scripts/hooks/gateguard-fact-force.js');

// A deny is signalled by run() returning something OTHER than the object passed in.
function decide(command) {
  const input = { tool_name: 'Bash', tool_input: { command }, session_id: 'repro' };
  return run(input) === input ? 'ALLOWED' : 'DENIED';
}

decide('printf ready');   // prime — see note below

for (const command of [
  'git reset --quiet --hard HEAD',
  'git checkout -q -- src/f.py',
  'git clean -d -f',
  'git push origin --force main',
  'git reset --hard HEAD',   // control: still denied
]) console.log(decide(command).padEnd(8), command);

Two notes that otherwise make this confusing to reproduce:

  • tool_name is required. Without it, run() falls through every branch and returns
    the input unchanged, so every command reads as ALLOWED and the defect looks absent.
  • Prime the session. The first Bash command of a session trips a separate routine
    gate, which masks the destructive arm on whichever command happens to be first.

Observed results

Command Installed decision
git reset --quiet --hard HEAD ALLOWED
git checkout -q -- src/f.py ALLOWED
git clean -d -f ALLOWED
git push origin --force main ALLOWED
git reset --hard HEAD (control) DENIED
git clean -f (control) DENIED

The two controls matter: the matcher is not broken in general, only adjacency-dependent.
Each pair differs solely by an ordinary flag between the subcommand and the dangerous flag.

Impact

These spellings can discard uncommitted work, delete untracked files, or force-update a remote branch. git clean -d -f is especially notable because it is a command spelling people type by habit.

Suggested fix

Locate the Git subcommand structurally, then accept ordinary interstitial arguments before evaluating the dangerous flag. Upstream main already contains a structural findGitSubcommand() parser that addresses this failure mode. Please cut a release containing that parser and add regression tests for these cases.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P0Critical priorityarea:hooksHook behavior, compatibility, or configurationbugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions