Software team reviewing Python subprocess and GNU grep argument-safety tests

How to Prevent GNU grep Option Injection in Python subprocess

Thu, Oct 29, 2026

subprocess.run(..., shell=False) can return a clean zero while GNU grep performs the wrong operation. In the supplied preparation experiment, the value --help was intended to be a literal fixed-string pattern in data.txt. Python transmitted it as one argument, no shell interpreted it, and GNU grep still recognized that argument as its own help option. The child exited successfully and emitted help text instead of the declared selected line. That is the central review problem: process success is not the same thing as search-contract success.

This article builds a bounded acceptance and repair playbook for one Linux utility, one Python process API, three owned temporary files, controlled stdin bytes, and twelve declared cases. The question is deliberately narrow: did the argument list ask GNU grep to search the intended literal pattern in the intended file, or did grep assign some supplied value a different role? Python's documentation recommends sequence arguments and describes shell=False as direct process execution on this POSIX path; it does not assign utility-specific meanings to those arguments. GNU grep documents its own leading-dash, -e, --, and special - rules.

The evidence therefore has to distinguish five things: no shell invocation, correct argument boundaries, correct argument roles, the selected input source, and a completed process whose status and bytes satisfy the intended search contract. The resulting dispositions are ACCEPT_BOUNDED_SEARCH, ACCEPT_NO_MATCH, REPAIR_ARGUMENT_ROLES, REPAIR_INPUT_SOURCE, HOLD_EXECUTION_ERROR, and HOLD_INCOMPLETE_EVIDENCE. A case marked PASS only means its declared observation occurred; several passing controls are intentionally unacceptable command constructions.

Define the intended search before approving the invocation

Start with a contract, not with an exit code. In this laboratory, the intended operation is one fixed-string search over exactly one owned ordinary file. The authored patterns are ASCII, nonempty, and newline-free. The file bytes are known in advance, and for each case the expected selected-line bytes are declared independently of the child output. That independent oracle is what makes a zero, a one, or a byte sequence interpretable as evidence rather than merely an observation.

The baseline contract can be stated as: run the fixed GNU grep executable with fixed search mode, bind exactly one declared literal pattern to the pattern role, bind exactly one declared owned pathname to the file role, provide only the controlled stdin bytes, capture stdout and stderr, wait for direct-child completion, and accept only an outcome consistent with the declared search oracle. For synthetic matches, exact stdout equality is the acceptance evidence. For the legitimate no-match control, empty stdout and stderr plus the documented no-match status are expected. For an execution error, no search decision is accepted.

That is a laboratory qualification, not production authorization. It says nothing about whether an untrusted path is permitted, whether a symlink should be followed, whether an input is too large, or whether a caller is entitled to inspect a particular file. Those belong to a separate policy layer. This separation mirrors the broader review discipline in Refonte Learning's GNU find selection and protected-path checks: each utility needs a contract tied to its own semantics rather than a generic belief that a Python list is automatically safe.

Use the dispositions narrowly. ACCEPT_BOUNDED_SEARCH means the tested match operation, source, status, and exact bytes agree. ACCEPT_NO_MATCH means a valid intended search completed with the qualified no-match result. REPAIR_ARGUMENT_ROLES means argument boundaries were preserved but grep assigned a value the wrong syntactic role. REPAIR_INPUT_SOURCE means the operation and pattern were viable but the operand selected stdin instead of the intended file. HOLD_EXECUTION_ERROR means the child reported an execution/search error. HOLD_INCOMPLETE_EVIDENCE covers missing cases, failed setup, launch or timeout failure, mismatched oracles, incomplete output, cleanup failure, or any report that cannot support the bounded decision.

Separate Python argument transmission from grep parsing

Python and GNU grep solve different parts of the problem. subprocess.run() accepts an argument sequence, waits for completion, and returns a CompletedProcess; capture_output=True, input=..., env=..., and returncode expose the process boundary used by the fixture. On the documented POSIX path with shell=False, Python does not implicitly route this list through a shell. That is valuable because a value containing spaces or shell metacharacters can remain data in one argv element.

But preserving an element is not the same as assigning it the intended semantic role. GNU grep owns the grammar after process creation. Its synopsis includes options, patterns, and files, and its usage guidance explicitly warns that a leading-dash pattern can be interpreted as an option unless the caller uses a mechanism such as -e; it similarly warns about leading-dash file names. The observed G02, G04, and G07 cases are therefore not evidence that Python split or corrupted arguments. Python delivered the values intact; grep parsed them according to grep rules.

This distinction is also why the phrase Python subprocess argument injection needs precision. OWASP separates command construction from argument-level behavior and recommends structured argument APIs together with validation of the executable and arguments. It also notes that avoiding the shell does not by itself validate the arguments accepted by the target program. OpenSSF likewise recommends shell=False while acknowledging that application risks remain and that a suitable native library can remove an unnecessary OS-command boundary altogether. For this article, the external utility is fixed by scope; that does not turn the small wrapper into a universally safe subprocess adapter.

Preserve one argument without assuming its semantic role

An argument containing a space can remain one Python string and one child argument. G06 uses the single pattern element literal .*; Python does not split it at the space, and no shell expands *. Conversely, a single intact argument can still be significant to grep because of its leading dash. That is the key shell=False argument validation distinction: correct argument boundaries are necessary, but the child still decides whether an element is an option, an option value, a pattern, or an operand.

Shell quoting examples in manuals describe shell syntax; they are not instructions to insert quote characters into Python list elements. This playbook therefore does not teach shell interpolation, shlex escaping, or arbitrary command construction. It keeps the boundary direct and asks whether each already-separated value occupies the role intended by the search contract.

Establish owned files and independent input evidence

The supplied preparation experiment creates exactly three files inside a fresh temporary directory. data.txt contains six lines: needle ordinary, --help, -n, literal .*, literal AB, and other. A second file is literally named --help and contains needle from option-like filename. A third file is literally named - and contains needle from dash filename. Controlled stdin contains the distinct bytes needle from stdin\n.

Those bytes matter more than human descriptions because they are declared before the child runs. Each file is written from an in-memory FILES mapping, and the report stores each declared file's hexadecimal bytes and SHA-256. After every case, the fixture re-reads the directory and checks that the current {name: bytes} mapping still equals FILES. After the context manager exits, it verifies that the temporary directory was removed. These checks bound the experiment to its owned tree; they are not claims about unrelated filesystem state.

The child environment is also explicit: LC_ALL=C and PATH=/usr/bin:/bin. The fixture resolves grep through that path, records the resolved executable, reads its bytes to compute an executable SHA-256, probes --version, and requires the first line to identify GNU grep. The digest identifies the measured executable bytes; it is not publisher authentication, package provenance, or a supply-chain audit. The supplied reference observation used CPython 3.12.14, Linux 6.18.55 x86_64/glibc 2.39, and GNU grep 3.11 at /usr/bin/grep.

That completion model is intentionally simpler than Refonte Learning's separate article on subprocess output ownership, where descendants and inherited descriptors make direct-child exit insufficient for stream completion. Here every subprocess.run() call completes, and no descendant-worker or pipe-EOF problem is under test. Python documents both the waiting behavior of run() and the fact that a timeout is not a universal hard real-time guarantee because process creation can itself delay timeout reporting.

Give the file and stdin different identifying lines

G09 and G10 would be weak controls if the file named - and stdin contained the same line. The fixture deliberately gives them different known bytes. With identical argv apart from - versus ./-, and with the same supplied stdin in both cases, the returned line becomes an independent source oracle: needle from stdin\n identifies the stream, while needle from dash filename\n identifies the owned file.

This is not an authentication token or a production data-integrity scheme. It is a synthetic discriminator for one controlled experiment. Likewise, the unchanged-file test only states that the three owned ordinary files retained their declared contents after each read-only invocation. There are no concurrent writers, symlinks, external input files, or path-authorization decisions in scope.

Run the complete twelve-case argument laboratory

The preparation fixture is autonomous: it needs Python 3 and GNU grep available through /usr/bin:/bin, but no unpublished helper or research attachment. Save the following source exactly as grep_argument_fixture.py with UTF-8/LF line endings and its final newline. The source below mechanically hashes to the supplied SHA-256 c2c97830205449d5c4788fe4b6d316794ade1ff8c9963b1154f4fd21c5038803, and its whitespace-delimited count is 564 words.

"""Owned GNU grep argument-role lab; no shell, server, credentials or external file input."""
from datetime import datetime, timezone
import hashlib
import json
from pathlib import Path
import platform
import shutil
import subprocess
import sys
import tempfile

ENV = {"LC_ALL": "C", "PATH": "/usr/bin:/bin"}
FILES = {
    "data.txt": b"needle ordinary\n--help\n-n\nliteral .*\nliteral AB\nother\n",
    "--help": b"needle from option-like filename\n",
    "-": b"needle from dash filename\n",
}
STDIN = b"needle from stdin\n"


def require(condition, message):
    if not condition:
        raise AssertionError(message)


def digest(data):
    return hashlib.sha256(data).hexdigest()


def invoke(executable, directory, arguments, supplied_input=b""):
    return subprocess.run([executable] + arguments, cwd=directory, env=ENV,
                          shell=False, input=supplied_input, capture_output=True,
                          timeout=5, check=False)


def run(report):
    require(sys.platform.startswith("linux"), "This reference fixture is Linux-only")
    found = shutil.which("grep", path=ENV["PATH"])
    require(found is not None, "GNU grep is unavailable")
    executable = str(Path(found).resolve())
    version_process = invoke(executable, None, ["--version"])
    require(version_process.returncode == 0 and version_process.stderr == b"", "Version probe failed")
    version = version_process.stdout.decode("ascii").splitlines()[0]
    require(version.startswith("grep (GNU grep) "), "This fixture requires GNU grep")
    report["environment"] = {
        "python": sys.version, "platform": platform.platform(), "grep_version": version,
        "grep_executable": executable, "grep_sha256": digest(Path(executable).read_bytes()),
        "child_environment": ENV, "shell": False,
        "source_sha256": digest(Path(__file__).read_bytes()),
    }
    report["declared_files"] = {name: {"bytes_hex": data.hex(), "sha256": digest(data)}
                                for name, data in FILES.items()}
    with tempfile.TemporaryDirectory(prefix="grep-argv-owned-") as directory:
        root = Path(directory)
        for name, data in FILES.items():
            (root / name).write_bytes(data)
        # Each search oracle is declared independently of the child's actual output.
        cases = [
            ("G01_plain_baseline", ["-F", "needle", "data.txt"], b"",
             0, b"needle ordinary\n", b"needle ordinary\n", False),
            ("G02_pattern_becomes_help", ["-F", "--help", "data.txt"], b"",
             0, None, b"--help\n", False),
            ("G03_explicit_help_pattern", ["-F", "-e", "--help", "--", "./data.txt"], b"",
             0, b"--help\n", b"--help\n", False),
            ("G04_pattern_becomes_line_option", ["-F", "-n", "data.txt"], b"",
             1, b"", b"-n\n", False),
            ("G05_explicit_dash_n_pattern", ["-F", "-e", "-n", "--", "./data.txt"], b"",
             0, b"-n\n", b"-n\n", False),
            ("G06_literal_space_and_metacharacters", ["-F", "-e", "literal .*", "--", "./data.txt"], b"",
             0, b"literal .*\n", b"literal .*\n", False),
            ("G07_filename_becomes_help", ["-F", "-e", "needle", "--help"], b"",
             0, None, b"needle from option-like filename\n", False),
            ("G08_delimited_option_like_filename", ["-F", "-e", "needle", "--", "--help"], b"",
             0, b"needle from option-like filename\n", b"needle from option-like filename\n", False),
            ("G09_dash_still_selects_stdin", ["-F", "-e", "needle", "--", "-"], STDIN,
             0, b"needle from stdin\n", b"needle from dash filename\n", False),
            ("G10_explicit_dash_filename", ["-F", "-e", "needle", "--", "./-"], STDIN,
             0, b"needle from dash filename\n", b"needle from dash filename\n", False),
            ("G11_legitimate_no_match", ["-F", "-e", "absent-token", "--", "./data.txt"], b"",
             1, b"", b"", False),
            ("G12_missing_owned_file", ["-F", "-e", "needle", "--", "./missing.txt"], b"",
             2, b"", None, True),
        ]
        for identity, arguments, supplied_input, expected_rc, expected_stdout, search_stdout, expect_error in cases:
            process = invoke(executable, root, arguments, supplied_input)
            expected_help = expected_stdout is None
            observed = {
                "returncode": process.returncode,
                "stdout_matches_case": (process.stdout.startswith(b"Usage: ") if expected_help
                                        else process.stdout == expected_stdout),
                "stdout_is_help": process.stdout.startswith(b"Usage: "),
                "stderr_is_empty": process.stderr == b"",
                "missing_file_diagnostic": b"./missing.txt" in process.stderr,
                "search_bytes_match": search_stdout is not None and process.stdout == search_stdout,
            }
            expected = {
                "returncode": expected_rc, "stdout_matches_case": True,
                "stdout_is_help": expected_help, "stderr_is_empty": not expect_error,
                "missing_file_diagnostic": expect_error,
                "search_bytes_match": identity in {"G01_plain_baseline", "G03_explicit_help_pattern",
                    "G05_explicit_dash_n_pattern", "G06_literal_space_and_metacharacters",
                    "G08_delimited_option_like_filename", "G10_explicit_dash_filename",
                    "G11_legitimate_no_match"},
            }
            unchanged = {p.name: p.read_bytes() for p in root.iterdir()} == FILES
            row = {
                "case": identity, "argv": [executable] + arguments,
                "stdin_hex": supplied_input.hex(), "expected": expected, "observed": observed,
                "search_oracle_hex": None if search_stdout is None else search_stdout.hex(),
                "stdout_length": len(process.stdout), "stdout_sha256": digest(process.stdout),
                "stdout_excerpt": (process.stdout[:160] if expected_help else process.stdout).decode("ascii"),
                "stderr": process.stderr.decode("ascii"), "owned_files_unchanged": unchanged,
                "pass": observed == expected and unchanged,
            }
            report["cases"].append(row)
            require(row["pass"], identity + " differed from its declared oracle")
        require([r["case"] for r in report["cases"]] == [c[0] for c in cases], "Incomplete case inventory")
    report["owned_directory_removed"] = not root.exists()
    require(report["owned_directory_removed"], "Owned cleanup failed")
    report["status"] = "PASS_12_CASES"


if name == "__main__":
    report = {"status": "HOLD", "cases": [], "started_utc": datetime.now(timezone.utc).isoformat()}
    exit_status = 0
    try:
        run(report)
    except Exception as error:
        report["failure"] = {"type": type(error).__name__, "message": str(error)}
        exit_status = 1
    report["finished_utc"] = datetime.now(timezone.utc).isoformat()
    print(json.dumps(report, indent=2))
    raise SystemExit(exit_status)

Run it normally and redirect only the fixture's JSON report:

python3 grep_argument_fixture.py > grep_argument_results.json

Immediately check the Python process result before treating the JSON as evidence. A nonzero launcher result is a hold. Then inspect the report rather than assuming the expected values occurred. The supplied preparation run, attributed here as supplied evidence rather than a run performed for this article, reported PASS_12_CASES from 2026-10-29T11:44:11.474270+00:00 to 2026-10-29T11:44:11.504792+00:00. Its recorded environment was CPython 3.12.14, Linux 6.18.55 x86_64/glibc 2.39, GNU grep 3.11 at /usr/bin/grep, LC_ALL=C, child PATH=/usr/bin:/bin, and shell: false. Keep that measured GNU grep 3.11 observation separate from the online GNU Grep 3.12 manual used for documentation here. Likewise, the supplied Python runtime was 3.12.14 while the maintained Python 3.12 documentation page inspected on the cutoff date displayed 3.12.16.

The report has three evidence layers. environment records Python, platform, GNU grep identity, executable path and digest, child environment, shell setting, and source digest. declared_files records each authored file's bytes and digest. Each ordered case row records the exact argv, supplied stdin in hex, expected and observed predicate dictionaries, the independent search oracle, output length and digest, a help excerpt or direct short search output, stderr, the unchanged-file check, and a pass flag. Help output is intentionally not copied in full; the report preserves its length, digest, and a 160-byte excerpt.

The twelve controls are ordered G01 through G12. G01 is the plain baseline. G02 and G03 distinguish an unmarked --help pattern from an explicitly bound -e --help pattern. G04 and G05 do the same for -n. G06 proves the authored fixed-string punctuation-and-space case. G07 and G08 compare an option-like filename before and after the -- option delimiter. G09 and G10 distinguish the special stdin operand - from the file spelling ./-. G11 is a legitimate no-match. G12 is a missing-file error.

Several cases are designed to pass while demonstrating unacceptable construction. For example, G02 passes only if help really appears, and G09 passes only if stdin really wins over the owned file named -. The fixture's require(row["pass"], ...) therefore means “the negative control behaved as declared,” not “approve this argv.” Production acceptance comes later, after interpreting the role, source, status, and byte evidence.

Treat setup and evidence failures conservatively. Missing GNU grep, a failed version probe, launch failure, timeout, an assertion mismatch, an incomplete ordered inventory, a file-integrity failure, failed owned-directory cleanup, or an incomplete report is HOLD_INCOMPLETE_EVIDENCE. A different GNU grep version is not automatically a failure (the fixture requires GNU identity, not literal version 3.11), but it is a new observation that must record and qualify its own version and executable digest. Never copy the supplied PASS_12_CASES label onto a rerun whose actual result differs.

Expose help output that arrives with status zero

G01 establishes the intended shape in the simplest non-leading-dash case: grep -F needle data.txt returns code 0 and exactly needle ordinary\n. Its search bytes agree with the independent oracle. That gives a baseline for interpreting G02.

G02 supplies ['-F', '--help', 'data.txt']. Python still passes three distinct arguments and uses no shell. GNU grep, however, recognizes --help as its option. The supplied run returned code 0 and help output; the expected search oracle was the literal line --help\n from data.txt, and search_bytes_match was false. The child succeeded at the operation it actually selected, but that operation was not the intended search.

This is the cleanest grep option injection control in the fixture, but it should not be inflated into a claim of arbitrary command execution. Nothing launches another executable, nothing invokes a shell, and no external account or server is involved. The defect is semantic: a value intended as a literal pattern occupied an option position. GNU's usage documentation explicitly warns that a leading-dash pattern can be treated as an option and recommends -e as one remedy.

A zero-only gate would therefore approve the wrong result. returncode == 0 establishes process-level success under the child's chosen operation; it cannot prove the command searched the intended bytes. For G02 the correct disposition is REPAIR_ARGUMENT_ROLES, retaining the help transcript digest and mismatched oracle as evidence of why the original construction was rejected.

Diagnose the leading-dash pattern that disappears

G04 is subtler because it returns the status many wrappers associate with a normal no-match. Its argv is ['-F', '-n', 'data.txt'], supplied stdin is empty, stdout is empty, and the return code is 1. The intended search was the literal pattern -n in data.txt, whose declared selected line is -n\n. That search did not happen.

GNU grep consumes -n as the line-number option. With no -e pattern option present, the next positional argument, data.txt, becomes the pattern. No file operand remains. grep therefore reads its input from stdin, and the fixture deliberately supplies empty stdin. No line can match the pattern data.txt, so the child returns the normal no-selected-lines status 1. GNU documents the general synopsis and the leading-dash pattern problem; the supplied observation shows the exact consequence for this argv.

This is a false no-match for the intended request. It is not evidence that data.txt lacks the literal text -n; in fact, the declared file explicitly contains that line. It is also not a filesystem failure or a Python quoting failure. Argument boundaries remained intact while argument roles changed under grep's parser.

G05 repairs the role explicitly: ['-F', '-e', '-n', '--', './data.txt']. The supplied run returns 0 and exactly -n\n, matching the search oracle. The correct disposition changes from REPAIR_ARGUMENT_ROLES for G04 to ACCEPT_BOUNDED_SEARCH for G05, within this authored fixed-string case.

Bind the literal search pattern explicitly

For this bounded wrapper, the most legible tested shape is:

[grep_executable, "-F", "-e", pattern, "--", owned_file]

Each element has one reviewable job. -F selects fixed-string matching. -e introduces the next argument as pattern data, which is the critical defense for a grep leading dash pattern in this experiment. -- ends option parsing before the file operand. The owned filename is then spelled so that the special stdin operand cannot be confused with the intended file when necessary.

G03 demonstrates grep -e pattern handling with --help: the argv ['-F', '-e', '--help', '--', './data.txt'] returns exactly --help\n. G05 does the same for -n. G06 supplies literal .* as one Python string after -e, and the fixed-string search returns exactly literal .*\n; it does not return the separate literal AB\n control line. Those observations connect correct argument boundaries to the intended pattern role and then to exact accepted bytes.

That does not constitute a complete input-validation policy. OWASP recommends fixing executable and options where possible and validating operands according to the application's policy; -- is a parser control, not authorization. A native API may still be preferable when it directly satisfies the requirement because it removes an external command grammar from the application boundary. This article keeps GNU grep because the fixed external utility is the system under qualification.

Keep matching syntax and argument roles separate

-F, -e, and -- solve different problems. -F changes how grep interprets pattern syntax. -e assigns the following argument to the pattern role even when it begins with a dash. -- ends option parsing for later arguments. GNU documents -- as an option-list delimiter and documents -e as accepting one or more patterns.

That last detail is an important limit: GNU says newlines within a single -e argument separate patterns. One argv element therefore need not equal one semantic pattern. The fixture excludes newline-bearing patterns, empty strings, NULs, and non-ASCII input, so G06 proves only the authored ASCII, nonempty, newline-free fixed-string behavior. “Passed as one argument” is not a universal validation result.

End option parsing before an option-like filename

The filename --help really exists in the owned temporary directory. That fact does not force GNU grep to interpret the spelling as a file operand. G07 uses ['-F', '-e', 'needle', '--help']. The pattern role is already explicit, but the final argument is still in option-parsing territory. The supplied observation is return code 0 with help output, not the declared file line needle from option-like filename\n.

G08 changes only the operand boundary: ['-F', '-e', 'needle', '--', '--help']. The supplied run returns 0 and exactly needle from option-like filename\n. GNU's documentation states that -- delimits the option list so later arguments are not treated as options even if they begin with -. This is the grep double dash delimiter behavior the fixture qualifies.

The comparison matters because a reviewer can otherwise fix the pattern and still leave the file role ambiguous. In G07, -e correctly protects needle but does nothing to make the later --help spelling a file. In G08, both roles are explicit enough for the authored case: pattern via -e, file after --.

Do not generalize that delimiter beyond its documented target utility. Not every executable implements --, and even for GNU grep the delimiter only changes option parsing. It does not grant access to a path, prove that a path belongs to an allowed tree, defeat symlink races, or impose input-size limits. Those policies are deliberately outside this comparison. For G07 choose REPAIR_ARGUMENT_ROLES; for G08 choose ACCEPT_BOUNDED_SEARCH because its exact bytes identify the intended owned file in the supplied experiment.

Prove which source the dash operand selects

The most important limit on “just add --” is G09. Its argv is ['-F', '-e', 'needle', '--', '-'], and the fixture supplies stdin bytes needle from stdin\n. The owned directory also contains a real ordinary file whose name is the single character -, but that file contains different bytes: needle from dash filename\n.

GNU's usage guidance documents - as the special standard-input filename and specifically notes that a leading-dash workaround based on -- still leaves a file named - ambiguous because grep interprets it as standard input. G09 reproduces exactly that rule. The child returns 0 and outputs needle from stdin\n. Option parsing has ended, the pattern role is correct, and the process status is normal, yet the declared file-search contract is wrong because the operand selected the stream.

G10 keeps the same fixed flags, pattern, and supplied stdin but spells the owned file as ./-: ['-F', '-e', 'needle', '--', './-']. The child again returns 0, this time with exactly needle from dash filename\n. Since stdin remained the same, the byte difference is strong evidence about which source grep consumed. This is why the experiment does not infer source identity from status or from argument appearance alone.

The disposition needs its own category. G09 is not primarily REPAIR_ARGUMENT_ROLES: after --, the final token is already an operand. The failure is that this operand has a documented special source meaning inconsistent with the intended file. Mark it REPAIR_INPUT_SOURCE. G10 is ACCEPT_BOUNDED_SEARCH because the explicit spelling removes that special meaning in the tested working directory and the output matches the file oracle.

This distinction is useful in release review. “No shell,” “one argv element,” “after --,” and “return code zero” can all be true while the wrong input source is selected. An evidence-driven wrapper should make the file-versus-stream decision visible rather than treating every dash as categorically dangerous or every post-delimiter token as an ordinary path.

Use an explicit file spelling without claiming containment

In this fixed working directory, ./- names the ordinary file - and no longer has GNU grep's special stdin-operand spelling. That is enough to distinguish G10 from G09. It is not path authorization. The fixture does not prove that an arbitrary caller-supplied path stays inside a permitted tree, and it does not defend against symlink substitution, rename races, mount changes, or hostile concurrent writers.

A production design that accepts paths needs its own ownership and authorization policy. This laboratory instead preserves one declared decision: search the owned file, not stdin. That is why it is better to model G09 as REPAIR_INPUT_SOURCE than to say “reject all dashes.” A dash may be legitimate pattern data after -e, an option marker before --, the special stdin operand when that is intentionally desired, or an ordinary filename when spelled ./-. The semantic role is the thing to qualify.

Distinguish a match, no match and execution error

Exit status becomes useful only after the intended operation and source have been established. GNU grep documents the exit-status convention as 0 when a line is selected, 1 when no lines are selected, and 2 when an error occurs; quiet modes have an exception, and other implementations can differ. The fixture never adds -q, --quiet, or --silent, so that quiet-mode exception is outside the observed cases.

G01 is the valid match control: correct plain pattern, intended file, code 0, exact expected line, empty stderr. G11 uses ['-F', '-e', 'absent-token', '--', './data.txt']. Its pattern and source roles are explicit, stdout is empty, stderr is empty, and the child returns 1. Because the intended operation was actually performed and its no-match bytes agree with the declared oracle, G11 is ACCEPT_NO_MATCH, not a failure requiring repair.

G12 uses the same safe role structure but names ./missing.txt. The child returns 2 and stderr contains a diagnostic naming the missing file. There is no accepted search result because the input source could not be opened as intended. The disposition is HOLD_EXECUTION_ERROR; retaining the diagnostic is part of the evidence, but the wrapper must not reinterpret code 2 as “no matching lines.”

The status here is captured directly by Python. That keeps this problem separate from Refonte Learning's article on preserving the correct command status, which examines a Bash construct that can mask another command's failure. No shell declaration, pipeline, or status propagation layer exists in this fixture. The surprising zero in G02 and G07 is genuine success for the unintended help operation.

Check the operation before interpreting its status

A wrapper that calls check=True for every grep invocation converts every nonzero status into CalledProcessError; Python documents that behavior. That is often too coarse for a search API because code 1 can represent a legitimate no-match. Conversely, accepting only zero is too weak because G02 and G07 return zero for help instead of the intended search.

The application therefore needs its own policy after verifying the operation and source. For this bounded fixture: correct search plus 0 and exact selected bytes means ACCEPT_BOUNDED_SEARCH; correct search plus 1 and the declared empty result means ACCEPT_NO_MATCH; code 2 is HOLD_EXECUTION_ERROR. A zero that belongs to help never enters the search-status interpretation at all; it first fails the operation contract.

Reconcile the evidence and choose a disposition

The twelve cases form a compact decision ledger. “PASS” below means the fixture observed its declared control; the disposition states whether the command construction can support the intended search contract.

Case

Discriminating observation

Operational disposition

G01

rc 0; exact needle ordinary\n from declared data.txt

ACCEPT_BOUNDED_SEARCH

G02

rc 0; help output; expected --help\n absent

REPAIR_ARGUMENT_ROLES

G03

rc 0; exact --help\n with -e and delimited file

ACCEPT_BOUNDED_SEARCH

G04

rc 1; empty stdout; -n consumed as option; empty stdin searched for pattern data.txt

REPAIR_ARGUMENT_ROLES

G05

rc 0; exact -n\n after explicit -e

ACCEPT_BOUNDED_SEARCH

G06

rc 0; exact literal .*\n, not literal AB\n

ACCEPT_BOUNDED_SEARCH

G07

rc 0; help output from option-like filename spelling

REPAIR_ARGUMENT_ROLES

G08

rc 0; exact option-like-file line after --

ACCEPT_BOUNDED_SEARCH

G09

rc 0; exact stdin line although a file named - exists

REPAIR_INPUT_SOURCE

G10

rc 0; exact dash-file line with ./- and same stdin

ACCEPT_BOUNDED_SEARCH

G11

rc 1; empty stdout/stderr after intended search

ACCEPT_NO_MATCH

G12

rc 2; missing-file diagnostic

HOLD_EXECUTION_ERROR

The acceptance rule is deliberately stronger than “expected status.” For synthetic accepted matches, require the intended argv roles, the intended source, process completion, and exact agreement with the independent selected-line oracle. For G11, require the qualified no-match structure. Across the report, require the complete ordered G01–G12 inventory, each declared observation to match, the owned files to remain unchanged, and the owned directory to be removed successfully.

Anything less is HOLD_INCOMPLETE_EVIDENCE. That includes a truncated report that happens to contain a few green cases, a rerun with missing version data, or a source whose SHA no longer matches the reviewed fixture without an explicit new qualification. The source digest is particularly useful as a release-review key because it identifies the exact harness bytes under discussion; it does not authenticate who wrote or distributed those bytes.

The negative controls are part of the acceptance argument. G02, G04, G07, and G09 are valuable precisely because they pass while demonstrating wrong semantics. If a review dashboard collapses their pass: true flags into “approved,” it destroys the distinction the experiment was designed to preserve.

Recover a trustworthy search decision

These calls are read-only, so recovery does not mean restoring file content. The repair is to correct the command contract, retain the rejected evidence, and rerun the intended bounded search. For a leading-dash pattern such as --help or -n, move the value into an explicit -e pattern slot. For an option-like file such as --help, put the file after GNU grep's -- delimiter. For the ordinary file named -, spell the file explicitly as ./- in this fixed working directory so the operand no longer selects stdin.

Do not rewrite the history of the bad run. A later correct G03-style search proves what the repaired invocation inspected; it cannot prove that the earlier G02 help invocation searched data.txt. Likewise, G10 cannot retroactively convert G09 into a file search. Keep the original argv, stdin summary, return code, output digest or exact short bytes, stderr, and disposition so downstream reviewers can see why the first result was rejected.

Operational logging should preserve enough decision context to support ownership without publishing sensitive production arguments indiscriminately. That principle aligns with Refonte Learning's discussion of API security and decision observability: security decisions are easier to review when the relevant policy inputs and outcomes are observable. In a real service, however, raw patterns or paths may themselves be sensitive; this synthetic lab contains no credentials, accounts, external requests, or secrets.

A practical repair sequence is: preserve the failed evidence; classify the defect as role, source, execution, or evidence completeness; change only the relevant argv construction or source spelling; rerun the complete bounded fixture if the wrapper changed; verify the new source SHA and environment record; and accept a search result only after the repaired observation satisfies the same independent oracle. That produces a new decision, not a retrospective reinterpretation.

Maintain the argument contract at the release gate

Turn the twelve-case comparison into a regression qualification for the specific wrapper, not into a universal subprocess security badge. Re-run it when changing the resolved executable, fixed flags, locale, working-directory policy, source-selection policy, or code that constructs argv. A change from GNU grep 3.11 to another GNU version is new evidence. A change from LC_ALL=C to another locale is new evidence. Adding extra flags is new evidence because an option can alter parsing, matching, output, or exit behavior.

This is where automated security testing in CI is useful as a workflow concept: repeat a narrow security-relevant contract at change boundaries rather than relying on reviewer memory. The broader page discusses many testing categories; this fixture uses none of those product claims as primary technical evidence. Its job is much smaller: keep argument-role and source semantics visible in the release gate.

Do not expand the pass label beyond the corpus. The fixture says nothing about empty patterns, embedded newlines, NUL bytes, non-ASCII text, binary files, recursive searches, multiple-file prefix formatting, very large output, unusual locale behavior, additional grep modes, process descendants, pipe EOF, or hard resource limits. GNU's own -e documentation is enough to show why newline-bearing patterns need separate policy: newlines inside one pattern argument separate multiple patterns. Another executable requires another grammar review; even -- is not a universal convention.

A release gate should therefore ask whether the evidence still matches the exact contract that downstream code consumes. If the application only needs “found/not found,” do not silently discard error status. If it consumes selected bytes, compare the expected bounded bytes in the qualification corpus. If it changes the intended input source from file to stdin, write that as a new contract rather than reusing G10's file-search approval.

Assign the executable, operand and consumer decisions

Ownership should be explicit. One maintainer owns the fixed executable and fixed option set. Another policy owner may define which pattern values and file operands are permitted in the real application. A consumer owner defines how ACCEPT_BOUNDED_SEARCH, ACCEPT_NO_MATCH, and held results influence downstream behavior. Security review checks that evidence collection supports those decisions without substituting for authorization.

Retain the executable version and digest, fixture source SHA, exact argv, controlled-input description, return code, relevant stdout/stderr evidence, ordered case inventory, and cleanup result. That record separates three activities: collecting what the child actually did, approving whether that behavior satisfies the bounded policy, and deciding what the application does next.

Build secure workflow judgment with Refonte Learning

The practical skill in this experiment is not memorizing one grep incantation. It is learning to separate transport, parser roles, input-source selection, process completion, and acceptance evidence, and to make each boundary reviewable. That same habit supports application-security testing and DevSecOps work because secure automation depends on explicit contracts rather than assumptions about what a tool “must have meant.”

Refonte Learning's Cybersecurity & DevSecOps program lists secure CI/CD pipelines, DAST/SAST/IAST, OWASP and threat modeling, incident response, and security workflow automation among its competencies. It also lists a 12–14 Hours/Week dedication figure, a virtual internship model, and Training Certificate plus Certificate of Internship outcomes on successful completion. The page names Dr. Christine Baker as an educational mentor.

Those confirmed foundations are relevant to the review method here, but the program page does not establish that it teaches GNU grep, Python subprocess, or this exact laboratory, so no such promise is made. Its own duration and prerequisite language is internally mixed, so those points should be verified directly before enrollment rather than repeated here as fixed claims. If you want to strengthen the broader security-review and automation judgment behind this kind of release gate, the program page is a reasonable place to inspect the current curriculum and admissions details.