Command Guard Library

Location

  • config/command_guard.sh

Purpose

This library provides a single entry point, cg_guard, that defines a Bash function named after an external command. The generated function shadows the external command and dispatches to it by full path, ensuring command resolution is not affected by untrusted PATH prefixes. A short alias guard is defined automatically unless a function named guard already exists at source time.

Additionally, cg_safe_run provides function-scoped PATH restriction: any unguarded external command invoked inside the called function produces a hard abort (Bash readonly-assignment failure), making command-injection vulnerabilities visible at runtime rather than silently exploiting the caller’s PATH.

Quick Start

# Source once in the main script of your library
source "$(dirname "$0")/config/command_guard.sh"

cg_guard ls
ls -l

PATH-safe entry point:

source "$(dirname "$0")/config/command_guard.sh"

my_main() {
    cg_guard uname date
    uname -s
    date -u
}

cg_safe_run my_main

Public API

cg_guard

Defines a function named <command> that forwards to the external command by full path. Also available as guard (short alias, defined only if unclaimed — see guard alias below).

  • Usage: cg_guard [-q] [-n <name_filter>] [-p <value>] [-r <resolver>] [-z <packed>] [resolver-opts] [--] [token ...]

  • Guard options must precede resolver options. The recommended order is -n filter -p value -r resolver resolver-opts tokens. All -X flags that cg_guard does not recognise are forwarded to the active resolver (see Resolver Protocol).

  • Options:

    • -q: Quiet mode, suppresses warnings.

    • -n <name_filter>: Use name_filter instead of the default cg_mkfname_prefix to compute the wrapper function name for plain-name and absolute-path tokens. Has no effect on fname=… tokens. See Name Filter Protocol. May appear at most once.

    • -p <value>: Set the name filter parameter(s). For the default cg_mkfname_prefix filter, value is the prefix string prepended to the bare name. For custom filters, value is a packed parameter list (see Name Filter Protocol — packed value syntax). An empty -p "" with the default filter emits a [WARNING] unless -q is active. Has no effect on fname=… tokens. May appear at most once.

    • -r <resolver>: Use resolver instead of cg_safe_resolver to resolve plain-name and fname=name (non-absolute RHS) tokens.

    • -z <packed>: Unpack packed and inject the resulting tokens back into the option-parsing loop at the current position, as if they had been written on the command line. The value is parsed by the packed-value convention (see Name Filter Protocol — packed value syntax). May be repeated; each occurrence injects one independent batch. Primary use: pass cg_search_snaps output to the active resolver:

      cg_guard -r cg_path_resolver -s "$(cg_search_snaps)" docker
      
    • --: End of options; required when a token name starts with -.

    • Each of -q, -n, -p, and -r may appear at most once; -z may be repeated. Repeating -q, -n, -p, or -r is a CG_ERR_SYNTAX_ERROR.

  • Token forms (all forms may be mixed in a single call):

    Token

    Generated function name

    Path source

    fname=/abs/path

    fname (prefix not applied)

    verbatim absolute path

    fname=name

    fname (prefix not applied)

    active resolver

    /abs/path

    <prefix>basename

    verbatim absolute path

    name

    <prefix>name

    active resolver

    Rules:

    • fname and plain name must be valid Bash identifiers (^[a-zA-Z_][a-zA-Z0-9_]*$).

    • For fname=rhs: if rhs contains / but is not absolute, the token is rejected (CG_ERR_SYNTAX_ERROR).

    • For /abs/path: the basename of the path must be a valid Bash identifier; if not (e.g. /usr/local/bin/my-cmd), use the fname=/abs/path form with an explicit identifier.

  • Returns:

    • 0 on success, including when zero tokens are provided (with optional warning).

    • CG_ERR_INVALID_NAME when a token contains an invalid Bash identifier.

    • CG_ERR_MISSING_ARGUMENT when a guard option (-r or -p) is present but its required argument is missing.

    • CG_ERR_NOT_FOUND when a command cannot be resolved or a path is invalid or non-executable.

    • CG_ERR_SYNTAX_ERROR when a relative path is used in the fname=rhs form (absolute path required); when a guard option (-q, -n, -r, -p) is repeated; or when a forwarded option flag is rejected by the active resolver as unrecognised (probe returns CG_ERR_SYNTAX_ERROR).

    • The name filter’s own exit code when the filter rejects a token. The filter is responsible for its own diagnostic message.

  • Validation is all-or-nothing: no wrapper functions are created unless every token passes validation.

guard alias

After cg_guard is defined, the library defines guard as a short alias:

guard() { cg_guard "$@"; }

This alias is installed only if no function named guard already exists at source time (same pattern as command_not_found_handle). Applications that define their own guard function before sourcing the library will not have it overwritten. Both names are fully supported; cg_guard is the canonical name.

cg_safe_run

Executes a declared Bash function under a restricted, read-only PATH. Any attempt to invoke an unguarded external command inside the function (or any function it calls) triggers a Bash readonly-assignment failure that aborts the entire call stack unconditionally.

  • Usage: cg_safe_run <fn> [args...]

  • fn must be a declared Bash function (verified with declare -f).

  • The fake PATH value is randomised (SRANDOM on Bash 5.1+; ${-}${RANDOM} fallback) to prevent an attacker from pre-populating /nonexistent-<fixed> with malicious symlinks.

  • Returns:

    • CG_ERR_INVALID_NAME if fn is not a declared function.

    • Hard abort (CG_ERR_PATH_VIOLATION) propagating through all callers if an unguarded external command is attempted inside fn.

    • Whatever fn returns on success.

  • Use cg_unsafe to wrap library-initialization code (cg_guard calls) inside a cg_safe_run context.

cg_unsafe

Executes a function with a writable local PATH set to the compiled-in Bash default (discovered once at source time via a subshell; never hardcoded).

  • Usage: cg_unsafe <fn> [args...]

  • Intended for wrapping third-party init functions that modify or rely on $PATH during initialisation — code the caller does not control and that would fail under cg_safe_run’s read-only PATH. cg_guard itself never needs cg_unsafe: both cg_safe_resolver and cg_path_resolver establish their own PATH independently.

  • Why it is needed inside cg_safe_run: third-party libraries sometimes set or rely on $PATH during initialisation; under cg_safe_run the PATH is read-only and such libraries would abort. cg_unsafe locally reverses the restriction for the duration of the called function, then the restriction is reinstated automatically when the function returns.

  • Risk: cg_unsafe restores a writable PATH set to the compiled-in Bash default — not the full system PATH, but enough to find most standard commands. Any unguarded command reachable on that PATH will execute silently, without triggering cg_command_not_found_handle. This suspends the enforcement guarantee of cg_safe_run for the entire duration of the called function. Keep the scope as narrow as possible. Because any PATH extension made by the third-party init lives only inside the local PATH binding of cg_unsafe — it is discarded when cg_unsafe returns — $PATH must be captured while still inside that scope. cg_guard never reads $PATH on its own; the extended directories must always be passed explicitly via -d "$PATH" to cg_path_resolver. The wrapper must therefore either call cg_guard -r cg_path_resolver -d "$PATH" ... from within its own body, or capture $PATH into a variable and return it so the caller can pass it as -d.

  • Typical use: an init wrapper that calls the third-party init (which may extend PATH), then immediately calls cg_guard -r cg_path_resolver -d "$PATH" to register the commands it discovered — all inside the wrapper passed to cg_unsafe. Example: a library whose binaries live in /opt/optlib/bin but whose init script is installed in /usr/bin:

    # optlib_wrapper.sh — source this to initialise optlib in a guarded app.
    
    # Guard the init script via cg_safe_resolver (uses command -pv; no
    # cg_unsafe needed even inside cg_safe_run).
    cg_guard optlib_init
    
    _optlib_init_wrapper() {
        # optlib_init extends PATH to include /opt/optlib/bin.
        optlib_init
        # Guard its commands while the PATH extension is still live.
        cg_guard -r cg_path_resolver -d "$PATH" optfoo optbar
    }
    
    # cg_unsafe makes PATH writable so optlib_init can extend it.
    # Binaries guarded above are callable safely after this line.
    cg_unsafe _optlib_init_wrapper
    
  • Returns: whatever fn returns.

cg_safe_resolver

The default resolver used by cg_guard. Resolves a command name to its absolute path using command -pv (Bash builtin, POSIX default PATH). Accepts no options; pass all arguments directly to cg_guard.

  • Protocol: cg_safe_resolver <cmd-name> (see Resolver Protocol for the calling convention).

  • Returns 0 and prints the absolute path on success.

  • Returns CG_ERR_NOT_FOUND on failure (also prints the raw command -pv output, which may be exec for builtins or alias for aliases; cg_guard uses this to produce specific diagnostics).

  • Returns CG_ERR_SYNTAX_ERROR with a diagnostic message when called with more than one argument (structural misuse; any attempt to forward a resolver option while cg_safe_resolver is active causes cg_guard to abort with CG_ERR_SYNTAX_ERROR via the probe mechanism).

  • Returns CG_ERR_MISSING_ARGUMENT when called with no arguments.

cg_path_resolver

An extended resolver that searches a caller-specified set of directories instead of the POSIX default PATH.

  • Protocol: cg_path_resolver [-d dir-or-colon-list] [-s] ... <cmd-name> (see Resolver Protocol).

  • -d <dir-or-colon-list>: add one or more directories to the search PATH (cumulative; -d may be repeated; its value may be a single directory or a colon-separated list such as /a:/b:/c).

  • -s: append the compiled-in Bash safe path (equivalent to -d "$_CG_DEFAULT_PATH"). Use this option when standard commands must be resolved alongside custom directories without referencing the internal _CG_DEFAULT_PATH variable. Option order is respected: -s inserts the safe path at its position in the search order relative to any -d options.

  • Builds a local PATH from the accumulated directories in the order the options appear, then uses command -v to resolve the command.

  • Returns 0 and prints the absolute path on success.

  • Returns CG_ERR_NOT_FOUND on failure (command not resolved in the given directories).

  • Returns CG_ERR_SYNTAX_ERROR with a diagnostic message when an unexpected token appears before the command name.

  • Returns CG_ERR_MISSING_ARGUMENT when called with no command name.

Example — guard a binary installed in a custom directory:

cg_guard -r cg_path_resolver -d /opt/myapp/bin myapp

Example — custom directory plus standard commands in one call:

# -s appends the safe path after /opt/myapp/bin so both are reachable:
cg_guard -r cg_path_resolver -d /opt/myapp/bin -s myapp uname date

Note

For snap binaries, use cg_search_snaps() to discover the snap bin directory at runtime rather than hard-coding it here.

Example — safe path searched first, custom directory as fallback:

cg_guard -r cg_path_resolver -s -d /opt/myapp/bin uname myapp

cg_command_not_found_handle

Public handler for the command_not_found_handle hook. When CG_DEBUG is set (non-empty), prints a [WARNING] message and a guard suggestion to stderr; otherwise silent. Always returns 127 (Bash convention for command-not-found).

  • Usage: cg_command_not_found_handle <cmd>

  • Applications that define their own command_not_found_handle may delegate to this function as a chaining call:

    command_not_found_handle() {
        my_application_handler "$@"
        cg_command_not_found_handle "$@"
    }
    
  • command_not_found_handle is installed automatically by the library only if no such function is already defined at source time.

cg_mkfname_prefix

The default name filter used by cg_guard. Prepends a fixed prefix to the bare command name and validates the result as a legal Bash identifier.

  • Usage: cg_mkfname_prefix <prefix> <bare-name>

  • Always receives exactly 2 arguments: $1 is the prefix (possibly empty) and $2 is the bare name. This matches the calling convention established by cg_guard — the default -p "" always supplies an empty-string prefix.

  • Prints the concatenated prefix + bare-name on success; returns 0.

  • Returns CG_ERR_SYNTAX_ERROR with a diagnostic if the argument count is not exactly 2.

  • Returns CG_ERR_INVALID_NAME with a diagnostic if the result is not a valid Bash identifier (^[a-zA-Z_][a-zA-Z0-9_]*$).

When used as the default filter with no -p, cg_guard passes "" as the prefix, so the wrapper function name equals the bare command name.

cg_search_snaps

Discovers the snap binary directory and returns it as a -z-packed argument suitable for passing directly to cg_guard -r cg_path_resolver.

  • Usage: "$(cg_search_snaps)" — always use quoted command substitution.

  • Always outputs a string starting with -z (never empty):

    • $'-z\x1F' when snap is absent or snap debug paths does not yield a usable SNAPD_BIN directory. This is a no-op injection: the -z case in cg_guard injects nothing and processing continues normally.

    • $'-z\x1F-d\x1F/snap/bin' (actual path from SNAPD_BIN) when snap is present and the directory exists.

  • Emits a [WARNING] to stderr when the snap binary is found but snap debug paths fails or SNAPD_BIN is missing or not a directory.

  • Returns 0 in all cases.

Typical usage:

cg_guard -r cg_path_resolver "$(cg_search_snaps)" docker compose

Because cg_search_snaps always outputs a -z-prefixed value, it is safe to use unconditionally; when snap is absent the argument is a no-op.

The snap binary directory is appended at the position cg_search_snaps appears in the cg_guard argument list, after any preceding -d options. This matches the snap convention: the snap paths directory is added at the end of PATH by the snap package itself.

Resolver Protocol

A resolver is a function that maps a command name to its absolute path. The calling convention is:

resolver_fn [forwarded-opts...] <cmd-name>
  • The last positional argument is always the command name.

  • All preceding arguments are options specific to the resolver.

  • On success: print the resolved absolute path to stdout; return 0.

  • On failure: return non-zero. The function should print the raw command -v (or equivalent) output even on failure, so that cg_guard can distinguish builtins, aliases, and truly missing commands.

  • Required contract: when called with no command name (all arguments were consumed as option parameters), the resolver must return CG_ERR_MISSING_ARGUMENT. cg_guard uses this to determine which forwarded options take an argument, via a probe call (see cg_guard option forwarding).

  • Resolvers must be pure (no side effects). cg_guard discards probe-call results.

Custom resolver example:

my_resolver() {
    # forwarded-opts are ignored; last arg is the command name
    local cmd="${@: -1}"
    local resolved="/opt/myapp/bin/$cmd"
    printf '%s' "$resolved"
    [[ -x "$resolved" ]] || return "$CG_ERR_NOT_FOUND"
}

cg_guard -r my_resolver mytool

Name Filter Protocol

A name filter is a function that computes the wrapper function name from a set of filter parameters and a bare command name. The calling convention is:

filter_fn [params...] <bare-name>
  • The last positional argument is always the bare name.

  • All preceding arguments are the filter parameters supplied via -p.

  • On success: print the wrapper function name to stdout; return 0. The result must be a valid Bash identifier (^[a-zA-Z_][a-zA-Z0-9_]*$).

  • On failure: print a diagnostic to stderr; return non-zero. The exit code is propagated directly to the cg_guard caller.

The default filter is cg_mkfname_prefix. It always receives exactly 2 arguments: an empty or non-empty prefix string, and the bare name.

Packed value syntax (-p and -z)

Both -p and -z use the same packed-value convention:

First character of value

Interpretation

[a-zA-Z0-9_-]

Single element; the whole value is passed through as-is.

"" (empty string)

Single empty-string element (one "" argument to the filter).

Any other character (e.g. :, \x1F)

That character is the separator. Strip it; split the remainder on it. Empty results from splitting are dropped.

Examples:

# -p "pfx_"          → filter receives: "pfx_"  bare_name
# -p ""              → filter receives: ""       bare_name  (+ warning with default filter)
# -p ":run_:_cb"     → filter receives: "run_"  "_cb"  bare_name
# -p $'\x1Fa\x1Fb'  → filter receives: "a"     "b"    bare_name

Custom name filter example:

my_filter() {
    local prefix="$1" bare_name="$2"
    local fname="${prefix}${bare_name}"
    [[ "$fname" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] || {
        echo "[ERROR] my_filter: '${fname}' is not a valid identifier." >&2
        return "$CG_ERR_INVALID_NAME"
    }
    printf '%s' "$fname"
}

cg_guard -n my_filter -p "my_" uname date

PATH Enforcement

cg_safe_run restricts PATH to a non-existent random value for the duration of the called function.

Unguarded external commands fail with exit code 127 (command not found). The installed command_not_found_handle is invoked; with CG_DEBUG=1 it prints a warning and a cg_guard suggestion to stderr. The caller receives 127 and may handle it normally.

Any attempt to assign to PATH inside the called function causes Bash to emit:

bash: PATH: readonly variable

and returns exit code 1 (CG_ERR_PATH_VIOLATION).

Guarded commands are unaffected because their wrapper functions dispatch by absolute path and do not use PATH.

The typical use case is wrapping a third-party library whose init modifies PATH to expose its binaries. Write an init wrapper that runs the library init under cg_unsafe (so PATH is writable and arbitrary commands can run), then guards the discovered binaries with cg_path_resolver -d:

_my_lib_init_wrapper() {
    # PATH is writable here; third_party_init may extend it freely.
    third_party_init
    # Guard the library's commands by the directory it installed to.
    cg_guard -r cg_path_resolver -d /opt/mylib/bin cmd1 cmd2
}

my_main() {
    cg_unsafe _my_lib_init_wrapper
    cmd1 --version
}

cg_safe_run my_main

CG_DEBUG=1 enables the command_not_found_handle warning and suggestion output. It is safe to enable in development but should be unset in production.

Error Codes

  • CG_ERR_PATH_VIOLATION=1: Bash readonly-assignment failure exit code. Produced by the Bash runtime, not by library code. The constant is provided for documentation and test assertions only.

  • CG_ERR_NOT_FOUND=3: command not found, path invalid, or non-executable.

  • CG_ERR_INVALID_NAME=5: invalid Bash identifier (aligned with HS_ERR_INVALID_VAR_NAME).

  • CG_ERR_MISSING_ARGUMENT=8: required argument missing — no command name supplied to a resolver, or a guard option -r/-p is missing its argument (aligned with HS_ERR_MISSING_ARGUMENT).

  • CG_ERR_SYNTAX_ERROR=9: structural calling-convention violation — function called with the wrong number or type of arguments, or a path that violates a structural constraint (e.g. relative path where absolute is required) (aligned with HS_ERR_INVALID_ARGUMENT_TYPE).

Behavior Details

Command resolution by cg_safe_resolver uses command -pv, which uses the Bash builtin restricted default PATH independently of the $PATH variable.

cg_path_resolver uses command -v with a local PATH built from the caller-supplied directories. It does not fall back to the POSIX default PATH; list all required directories explicitly.

It is an error to call cg_guard on aliases and shell builtins. An error message is printed to stderr and the script is aborted.

Subshells will be exited but the overall script may continue to run. Avoid constructs that generate subshells in favour of returning results via out-variables:

myfunction() {
    local arg1=$1
    local -n out=$2
    # ... compute result ...
    out=$result
}

if myfunction "$arg" result; then
    : # use "$result"
else
    : # handle failure
fi

Asynchronous Use

Every wrapper function generated by cg_guard is safe to background with &, use inside command substitution $(…), pipeline stages, and coproc. The foreground path is a transparent pass-through identical to the unguarded binary call. The behaviour below applies only when the wrapper runs in a subshell context.

Why backgrounded commands inside $(…) can block the substitution. $(…) works by connecting bash’s reader to one end of a kernel pipe and the substitution body’s stdout to the other end. Bash reads until the pipe delivers EOF. EOF arrives only when every process that holds the write end has closed it. A command started with & inside $(…) forks a child that inherits fd 1 — the write end of the capture pipe. The main body finishes and bash closes its copy of the write end, but the background child still holds the pipe open. Bash cannot see EOF and cannot return from $(…) until that child also closes fd 1 — whether by exiting normally, being killed, or explicitly redirecting its stdout. With a guarded command this is aggravated because the child that holds the pipe is the binary itself, whose PID is not directly reachable through $! without the mechanism described in Capture pipe safety below.

How the wrapper detects a subshell context

The wrapper tests $BASHPID != $$. This condition is true in every subshell context — &, $(…), pipeline stages (including the last stage with shopt -s lastpipe disabled), and coproc — and false when the wrapper runs directly in the calling shell. When false the wrapper forwards directly to the binary with no extra forks or traps.

Kill propagation

When the caller backgrounds a guarded command and later sends a signal to $!, the signal reaches the actual binary:

cg_guard nc
nc -lU "$sock" <&"$fd_in" >&"$fd_out" &
nc_pid=$!
# … later …
kill         "$nc_pid"   # SIGTERM forwarded to /usr/bin/nc
kill -INT    "$nc_pid"   # binary terminated via SIGTERM (see note below)
kill -HUP    "$nc_pid"   # SIGHUP  forwarded to /usr/bin/nc
wait         "$nc_pid"   # exit status reflects binary's termination

The wrapper installs forwarding traps for SIGTERM, SIGINT, and SIGHUP, and an EXIT trap as a backstop. The EXIT trap fires even on unhandled signals or unexpected wrapper exits, ensuring the binary is never left as an orphan.

Note

SIGINT and background processes. POSIX specifies that asynchronous commands in non-interactive shells have SIGINT set to SIG_IGN. Because the binary is started with & inside a subshell that is itself a background job, it inherits SIG_IGN for SIGINT. Forwarding SIGINT to it would be silently discarded. The INT trap therefore sends SIGTERM to the binary, which is not subject to SIG_IGN. The net effect — the binary is terminated when the caller sends kill -INT $! — is the same; only the signal received by the binary differs.

SIGKILL cannot be trapped. Sending kill -9 $! terminates the wrapper subshell without propagating to the binary; callers that need unconditional teardown should use a process group kill (kill -- -$pgid) with job control enabled (set -m).

Signals that have no trap in the wrapper (e.g. SIGUSR1) reach the wrapper with the default action (typically terminate). The EXIT trap then kills the binary cleanly.

Capture pipe safety

A guarded command backgrounded inside $(…) does not hold the capture pipe open after the caller kills it. The EXIT trap kills the binary promptly, so the pipe is released and the substitution returns:

cg_guard sleep
result=$(
    sleep 10 &
    job_pid=$!
    do_work
    kill "$job_pid"    # EXIT trap kills /usr/bin/sleep → pipe released
    echo "done"
)
# $result == "done"; substitution returns promptly

Without the trap the orphaned binary holds fd 1 (the capture pipe) open until it exits on its own, blocking the substitution for the full remaining duration of the binary’s execution.

Constructs supported

The following constructs all behave correctly after the fix:

Construct

Notes

wrapped_cmd args &

Standard backgrounding; $! is the wrapper subshell PID.

wrapped_cmd args >/dev/null &

Stdout redirect applied before wrapper body runs; still propagates kill.

{ wrapped_cmd args; } &

Brace group forks one subshell; $BASHPID != $$ triggers the gate.

: | wrapped_cmd args &

Wrapper runs as the last pipeline stage in its own subshell.

coproc CPROC { wrapped_cmd args; }

$CPROC_PID is the wrapper subshell PID; same trap mechanism applies.

set -m; wrapped_cmd args &

Job control assigns a new process group; kill propagation is unchanged.

out=$(wrapped_cmd args &; …)

Capture pipe is released when kill $! fires the EXIT trap.

cmd1 & p1=$!; cmd2 & p2=$!

Each wrapper subshell has its own independent trap state; concurrent backgrounding of the same or different guarded commands is safe.

stdin of the binary

When the wrapper is called in a shell with job control inactive (the default for non-interactive shells and all subshells), the inner & redirects the binary’s stdin to /dev/null. In practice this is the same value the wrapper subshell itself received from the outer &, so the binary’s stdin is /dev/null in all normal backgrounding contexts.

Callers that need the binary to read from a specific fd should redirect at the call site (wrapped_cmd <&"$fd_in" &); the redirect is applied to the wrapper subshell before the wrapper body runs and the binary inherits it.

Wait loop and signal-interrupted wait

Bash’s wait builtin returns early (exit code > 128) when a trapped signal arrives, even if the binary is still running. The wrapper retries wait in a loop until the binary is confirmed dead:

wait "$_cg_p" || _cg_rc=$?
while (( _cg_rc > 128 )) && kill -0 "$_cg_p" 2>/dev/null; do
    wait "$_cg_p" || _cg_rc=$?
done

This covers the case where the binary ignores or handles the forwarded signal and continues running. Without the loop the wrapper would return prematurely and orphan the binary.

The || _cg_rc=$? form is used throughout rather than a plain assignment so that the wrapper body is safe when the caller has set -e active. The library does not control set -e or inherit_errexit; the || prevents errexit from firing before the exit code is captured.

Exit status

The wrapper propagates the binary’s exit status through wait:

cg_guard sleep
sleep 0 &
wait "$!"        # returns 0 (sleep exited normally)

sleep invalid &
wait "$!"        # returns 1 (sleep rejected the argument)

Compatibility with shell options

The wrapper body is designed to work correctly regardless of the caller’s active shell options. The library does not set or clear any shell option.

Option

Effect on wrapper

set -e / inherit_errexit

Handled by wait \|\| _cg_rc=$?; errexit cannot fire mid-capture.

set -u

All wrapper locals (_cg_p, _cg_rc) are initialised before use.

shopt -s lastpipe

When the last pipeline stage runs in the main shell, $BASHPID == $$ and the direct branch executes. No indirection is needed: the binary is already a direct child of the calling shell.

set -m (job control)

The wrapper subshell may receive a new process group; the EXIT and signal traps are unaffected.

Developer Reference

Warning

The items in this section are internal implementation details not part of the public API. They may change without notice.

_CG_DEFAULT_PATH

Set once at source time:

_CG_DEFAULT_PATH="$(unset PATH; "$(command -pv bash)" -c 'echo "$PATH"')"

Contains the compiled-in Bash default PATH (the value Bash uses when PATH is unset). Used by cg_unsafe to restore a writable PATH inside a cg_safe_run context without hardcoding a PATH string.

Known Limitations

  • cg_safe_run hard-aborts the entire script on a PATH violation; there is no mechanism to catch or recover from it. This is by design.

  • cg_path_resolver searches only the directories supplied via -d and/or -s. It does not fall back to the POSIX default PATH unless -s is present; list all required directories explicitly or add -s to include the standard locations.

  • The command_not_found_handle hook is a single global resource. The library installs it only if unclaimed; applications that need their own handler should define it before sourcing the library, or chain via cg_command_not_found_handle.

  • The guard alias is a single global resource. The library defines it only if unclaimed; applications that define their own guard function before sourcing the library will keep their version. Use cg_guard directly when guard may be claimed.

Examples

Guarding standard commands:

source "$(dirname "$0")/config/command_guard.sh"
cg_guard uname date hostname
uname -s

Guarding with an explicit path:

cg_guard "myuname=/usr/bin/uname"
myuname -s

Guarding with a prefix (library namespace isolation):

cg_guard -p mylib_ uname date
mylib_uname -s

Guarding with a custom name filter:

my_filter() { printf '%s' "${1}${2}"; }   # same as default but custom
cg_guard -n my_filter -p "ns_" uname date
ns_uname -s

Guarding a tool that may be installed as a snap or system package:

cg_guard -r cg_path_resolver -s "$(cg_search_snaps)" docker

Guarding a snap binary by absolute path token:

cg_guard /snap/bin/snapd

Guarding a binary whose filename is not a valid identifier:

cg_guard "bash5=/usr/bin/bash5.0"

Full cg_safe_run pattern — guard at initialisation time, enforce at runtime:

source "$(dirname "$0")/config/command_guard.sh"

# Guard external commands once, before entering the safe region.
# cg_safe_resolver uses command -pv, which reinstates the POSIX default
# PATH regardless of the local $PATH set by cg_safe_run.
cg_guard uname date hostname

_my_main() {
    uname -s
    date -u
}

cg_safe_run _my_main

Guarding a tool that may be installed via apt or snap inside cg_safe_run:

source "$(dirname "$0")/config/command_guard.sh"

_my_init() {
    # cg_guard uses command -pv internally; no cg_unsafe needed inside cg_safe_run.
    cg_guard uname date
    # docker-compose may be an apt or snap package; cg_search_snaps handles both.
    cg_guard -r cg_path_resolver -s "$(cg_search_snaps)" docker-compose
}

_my_main() {
    _my_init
    uname -s
    docker-compose version
}

cg_safe_run _my_main

Source Listing

  1#!/bin/bash
  2# File: config/command_guard.sh
  3# Description: Guard external commands by shadowing them with full-path wrappers.
  4# Author: Jean-Marc Le Peuvédic (https://calcool.ai)
  5
  6# Sentinel
  7[[ -z ${__COMMAND_GUARD_SH_INCLUDED:-} ]] && __COMMAND_GUARD_SH_INCLUDED=1 || return 0
  8
  9# --- Public error codes --------------------------------------------------------
 10# shellcheck disable=SC2034  # used by test assertions, not by library code
 11readonly CG_ERR_PATH_VIOLATION=1
 12readonly CG_ERR_NOT_FOUND=3
 13readonly CG_ERR_INVALID_NAME=5
 14readonly CG_ERR_MISSING_ARGUMENT=8
 15readonly CG_ERR_SYNTAX_ERROR=9
 16
 17# --- Compiled-in default PATH (discovered once; used by cg_unsafe) ------------
 18# shellcheck disable=SC2016  # $PATH intentionally unexpanded here; expands inside the subshell
 19_CG_DEFAULT_PATH="$(unset PATH; "$(command -pv bash)" -c 'echo "$PATH"')"
 20readonly _CG_DEFAULT_PATH
 21
 22# --- Public resolvers ---------------------------------------------------------
 23
 24# Function:
 25#   cg_safe_resolver
 26# Description:
 27#   Default resolver: resolves a command name to its absolute path using
 28#   command -pv (POSIX default PATH, independent of $PATH).
 29#   Prints the command -pv output (even on failure, so guard can diagnose
 30#   builtins and aliases). Accepts no options; returns CG_ERR_MISSING_ARGUMENT
 31#   when called with no arguments (required by the resolver protocol).
 32# Usage:
 33#   cg_safe_resolver <cmd-name>
 34cg_safe_resolver() {
 35    [[ $# -eq 0 ]] && return "$CG_ERR_MISSING_ARGUMENT"
 36    if [[ $# -ne 1 ]]; then
 37        echo "[ERROR] cg_safe_resolver: accepts exactly one argument (command name); use -r with cg_path_resolver to pass options." >&2
 38        return "$CG_ERR_SYNTAX_ERROR"
 39    fi
 40    local cmd="$1"
 41    local resolved
 42    resolved="$(command -pv -- "$cmd")" || return "$CG_ERR_NOT_FOUND"
 43    printf '%s' "$resolved"
 44    [[ -x "$resolved" && "${resolved:0:1}" == "/" ]] || return "$CG_ERR_NOT_FOUND"
 45}
 46
 47# Function:
 48#   cg_path_resolver
 49# Description:
 50#   Extended resolver: builds a local PATH from -d options, then resolves
 51#   via command -v. The -d option may be repeated; its value may be a single
 52#   directory or a colon-separated list of directories.
 53#   Prints the command -v output (even on failure). Returns
 54#   CG_ERR_MISSING_ARGUMENT when called with no command name.
 55# Usage:
 56#   cg_path_resolver [-d dir-or-colon-list] ... <cmd-name>
 57cg_path_resolver() {
 58    local extra_path=""
 59    # Option processing without getopts
 60    while [[ $# -gt 1 ]]; do
 61        case "$1" in
 62            -d) extra_path="${extra_path:+$extra_path:}$2"; shift 2 ;;
 63            -s) extra_path="${extra_path:+$extra_path:}$_CG_DEFAULT_PATH"; shift ;;
 64            *)  echo "[ERROR] cg_path_resolver: unexpected token '$1'; use -d for each directory or -s for the safe path." >&2
 65                return "$CG_ERR_SYNTAX_ERROR" ;;
 66        esac
 67    done
 68    [[ $# -eq 0 ]] && return "$CG_ERR_MISSING_ARGUMENT"
 69    local cmd="$1"
 70    local PATH="$extra_path"
 71    local resolved
 72    resolved="$(command -v -- "$cmd" 2>/dev/null)"
 73    printf '%s' "$resolved"
 74    [[ -x "$resolved" && "${resolved:0:1}" == "/" ]] || return "$CG_ERR_NOT_FOUND"
 75}
 76
 77# --- Internal helpers ---------------------------------------------------------
 78
 79# Function:
 80#   _cg_guard_resolve
 81# Description:
 82#   Resolve <cmd_name> via <resolver> with forwarded options.
 83#   Prints the resolved path to stdout (even on failure, so the caller can
 84#   inspect it). Prints a diagnostic to stderr and returns the resolver's
 85#   error code on failure — CG_ERR_NOT_FOUND for missing commands,
 86#   CG_ERR_SYNTAX_ERROR when the resolver rejects the call.
 87# Usage:
 88#   _cg_guard_resolve <resolver> [forward_opts...] <cmd_name>
 89_cg_guard_resolve() {
 90    local _cgr_resolver="$1"; shift
 91    local _cgr_name="${!#}"
 92    local _cgr_path _cgr_rc
 93    _cgr_path="$("$_cgr_resolver" "$@")"
 94    _cgr_rc=$?
 95    printf '%s' "$_cgr_path"
 96    if [[ $_cgr_rc -ne 0 ]]; then
 97        if [[ "$_cgr_path" == "$_cgr_name" ]]; then
 98            echo "[BUG] cg_guard: '$_cgr_name' is a builtin and should not be guarded." >&2
 99        elif [[ "$_cgr_path" == alias\ * ]]; then
100            echo "[BUG] cg_guard: '$_cgr_name' is an alias and should not be used in scripts." >&2
101        else
102            echo "[ERROR] cg_guard: unable to resolve full path for '$_cgr_name'. Use the full path." >&2
103        fi
104        return "$_cgr_rc"
105    fi
106}
107
108# Function:
109#   _cg_unpack_args
110# Description:
111#   Unpack a packed-value string into the caller-visible array _cg_unpacked.
112#   Convention: first char in [a-zA-Z0-9_-] → single element, value as-is,
113#   except "-" alone which is the empty-list sentinel → zero elements.
114#   Empty string → one empty-string element. Any other first character is
115#   the separator: strip it, split remainder on it, preserving empty segments.
116#   Examples: ":−p" → ("-p"); ":-p:" → ("-p" ""); ":-p::" → ("-p" "" "");
117#             ":"   → (""); ";;"  → ("" ""); "..a.." → ("" "a" "" "").
118# Usage:
119#   local -a _cg_unpacked; _cg_unpack_args <packed>
120_cg_unpack_args() {
121    local _cgu_val="$1"
122    _cg_unpacked=()
123    if [[ -z "$_cgu_val" ]]; then
124        _cg_unpacked=("")
125        return 0
126    fi
127    local _cgu_sep="${_cgu_val:0:1}"
128    if [[ "$_cgu_sep" =~ [a-zA-Z0-9_-] ]]; then
129        [[ "$_cgu_val" == "-" ]] && return 0
130        _cg_unpacked=("$_cgu_val")
131        return 0
132    fi
133    local _cgu_rest="${_cgu_val:1}"
134    local _cgu_remaining="$_cgu_rest"
135    while [[ "$_cgu_remaining" == *"$_cgu_sep"* ]]; do
136        _cg_unpacked+=("${_cgu_remaining%%"$_cgu_sep"*}")
137        _cgu_remaining="${_cgu_remaining#*"$_cgu_sep"}"
138    done
139    _cg_unpacked+=("$_cgu_remaining")
140}
141
142# Function:
143#   _cg_guard_mkfname
144# Description:
145#   Dispatch to the active name filter, unpacking the packed p-value first.
146#   The filter receives: [unpacked_p_params...] <bare-name>.
147# Usage:
148#   _cg_guard_mkfname <filter_fn> <packed_p_value> <bare-name>
149_cg_guard_mkfname() {
150    local -a _cg_unpacked
151    _cg_unpack_args "$2"
152    "$1" "${_cg_unpacked[@]}" "$3"
153}
154
155# --- Public API ---------------------------------------------------------------
156
157# Function:
158#   cg_safe_run
159# Description:
160#   Executes a declared Bash function under a restricted, read-only PATH.
161#   Any attempt to assign to PATH inside the function (or its callees)
162#   fails with a readonly-assignment error (CG_ERR_PATH_VIOLATION).
163#   Unguarded external commands fail with exit 127 (command not found).
164# Usage:
165#   cg_safe_run <fn> [args...]
166cg_safe_run() {
167    declare -f "$1" >/dev/null 2>&1 || {
168        echo "[ERROR] cg_safe_run: '$1' is not a function." >&2
169        return "$CG_ERR_INVALID_NAME"
170    }
171    local -r PATH="/nonexistent-${SRANDOM:-${-}${RANDOM}}"
172    "$@"
173}
174
175# Function:
176#   cg_unsafe
177# Description:
178#   Executes a function with a writable local PATH set to the compiled-in
179#   Bash default. Use inside cg_safe_run to allow library guard calls.
180#   The local PATH in cg_unsafe shadows the local -r PATH from cg_safe_run.
181#   Typical use: wrapping init of third-party (unsafe) libraries that call
182#   external commands not guarded by cg_guard. cg_guard with cg_path_resolver
183#   also works inside cg_unsafe (e.g. to guard tools installed via apt or snap).
184# Usage:
185#   cg_unsafe <fn> [args...]
186cg_unsafe() {
187    local PATH="$_CG_DEFAULT_PATH"
188    "$@"
189}
190
191# Function:
192#   cg_command_not_found_handle
193# Description:
194#   Public handler for the command_not_found_handle hook. When CG_DEBUG is
195#   set (non-empty), prints a [WARNING] and a cg_guard suggestion to stderr.
196#   Always returns 127. Applications can chain to this from their own handler.
197# Usage:
198#   cg_command_not_found_handle <cmd>
199cg_command_not_found_handle() {
200    local cmd="$1"
201    if [[ -n "${CG_DEBUG:-}" ]]; then
202        local resolved
203        resolved="$(command -pv "$cmd" 2>/dev/null)"
204        if [[ -n "$resolved" ]]; then
205            echo "[WARNING] cg_guard: non-guarded command: $cmd" >&2
206            echo "[WARNING] Suggestion: cg_guard ${cmd}=${resolved}" >&2
207        else
208            echo "[WARNING] cg_guard: non-guarded command not found: $cmd" >&2
209        fi
210    fi
211    return 127
212}
213
214# Install command_not_found_handle only if unclaimed.
215if ! declare -f command_not_found_handle >/dev/null 2>&1; then
216    command_not_found_handle() { cg_command_not_found_handle "$@"; }
217fi
218
219# Function:
220#   cg_mkfname_prefix
221# Description:
222#   Default name filter used by cg_guard. Prepends a fixed prefix to the
223#   bare command name and validates the result as a legal Bash identifier.
224# Usage:
225#   cg_mkfname_prefix <prefix> <bare-name>
226cg_mkfname_prefix() {
227    if [[ $# -ne 2 ]]; then
228        echo "[ERROR] cg_mkfname_prefix: requires exactly 2 arguments (prefix, bare-name)." >&2
229        return "$CG_ERR_SYNTAX_ERROR"
230    fi
231    local _cgp_result="${1}${2}"
232    if ! [[ "$_cgp_result" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
233        echo "[ERROR] cg_mkfname_prefix: '${_cgp_result}' is not a valid Bash identifier." >&2
234        return "$CG_ERR_INVALID_NAME"
235    fi
236    printf '%s' "$_cgp_result"
237}
238
239# Function:
240#   cg_search_snaps
241# Description:
242#   Discovers the snap binary directory and returns it as a -z-packed argument
243#   suitable for passing directly to cg_guard -r cg_path_resolver.
244#   Always returns 0; outputs -z- (empty-list sentinel) when snap is absent or
245#   broken, and -z<FS>-d<FS><dir> when snap is present.
246# Usage:
247#   cg_guard -r cg_path_resolver "$(cg_search_snaps)" <cmd>
248cg_search_snaps() {
249    if ! command -p snap >/dev/null 2>&1; then
250        printf '%s' '-z-'
251        return 0
252    fi
253    local _cgs_paths
254    if ! _cgs_paths="$(command -p snap debug paths 2>/dev/null)"; then
255        echo "[WARNING] cg_search_snaps: 'snap debug paths' failed; snap binary directory will not be discovered." >&2
256        printf '%s' '-z-'
257        return 0
258    fi
259    local _cgs_bin="" _cgs_line
260    while IFS= read -r _cgs_line; do
261        if [[ "$_cgs_line" == SNAPD_BIN=* ]]; then
262            _cgs_bin="${_cgs_line#SNAPD_BIN=}"
263            break
264        fi
265    done <<< "$_cgs_paths"
266    if [[ -z "$_cgs_bin" || ! -d "$_cgs_bin" ]]; then
267        echo "[WARNING] cg_search_snaps: SNAPD_BIN not found or not a directory; snap binary directory will not be discovered." >&2
268        printf '%s' '-z-'
269        return 0
270    fi
271    printf '%s' $'-z\x1F-d\x1F'"$_cgs_bin"
272}
273
274# Function:
275#   cg_guard
276# Description:
277#   Defines a wrapper function for each token that dispatches to the
278#   resolved full path with all arguments forwarded.
279# Usage:
280#   cg_guard [-q] [-n <filter>] [-p <value>] [-r <resolver>] [-z <packed>] [resolver-opts] [--] [token ...]
281# Options:
282#   -q          Quiet: suppress warnings for zero tokens and empty -p.
283#   -n filter   Use filter instead of cg_mkfname_prefix to compute wrapper
284#               function names for plain-name and /abs/path tokens.
285#               Has no effect on fname=... tokens. May appear at most once.
286#   -p value    Pass value as parameter(s) to the name filter. For the default
287#               cg_mkfname_prefix, value is the prefix string. For custom
288#               filters, value is a packed parameter list. An empty -p "" with
289#               the default filter emits a [WARNING] unless -q is active.
290#               Has no effect on fname=... tokens. May appear at most once.
291#   -r resolver Use resolver instead of cg_safe_resolver. All unrecognised
292#               option flags are forwarded to the resolver; cg_guard probes the
293#               resolver to determine which flags take an argument.
294#               Guard options must precede resolver options.
295#   -z packed   Unpack packed and inject the resulting tokens into forward_opts
296#               at the current position. May be repeated; each occurrence
297#               injects one independent batch. Primary use: cg_search_snaps.
298#   --          End of options; required when a token name starts with -.
299# Token forms:
300#   fname=/abs/path  explicit fname, absolute path verbatim
301#   fname=name       explicit fname, name resolved via resolver
302#   /abs/path        function name = filter(prefix, basename), path verbatim
303#   name             function name = filter(prefix, name), resolved via resolver
304# Errors:
305#   CG_ERR_INVALID_NAME      invalid Bash identifier in a token
306#   CG_ERR_MISSING_ARGUMENT  cg_guard option -r, -p, -n, or -z missing argument
307#   CG_ERR_NOT_FOUND         command not found or path invalid/non-executable
308#   CG_ERR_SYNTAX_ERROR      relative path where absolute required; a guard option
309#                            (-q, -r, -p, -n) repeated; or a forwarded option
310#                            rejected by the resolver (not recognised)
311# Notes:
312#   Validation is all-or-nothing: no wrapper is created unless every token
313#   passes validation.
314cg_guard() {
315    local quiet=false resolver="cg_safe_resolver" prefix="" name_filter_fn="cg_mkfname_prefix"
316    local -a forward_opts=() _cg_unpacked
317    local opt_q=false opt_r=false opt_p=false opt_n=false
318
319    # Parse guard's own options with getopts; unknown flags are forwarded to
320    # the resolver after an arity probe. Guard options must precede resolver
321    # options: guard [-q] [-n filter] [-p value] [-r resolver] [-z packed] [resolver-opts] [--] tokens
322    OPTIND=1
323    while getopts ":qr:p:n:z:" opt; do
324        case $opt in
325            q) [[ "$opt_q" == true ]] && { echo "[ERROR] cg_guard: option -q specified more than once." >&2; return "$CG_ERR_SYNTAX_ERROR"; }
326               opt_q=true; quiet=true ;;
327            r) [[ "$opt_r" == true ]] && { echo "[ERROR] cg_guard: option -r specified more than once." >&2; return "$CG_ERR_SYNTAX_ERROR"; }
328               opt_r=true; resolver="$OPTARG" ;;
329            p) [[ "$opt_p" == true ]] && { echo "[ERROR] cg_guard: option -p specified more than once." >&2; return "$CG_ERR_SYNTAX_ERROR"; }
330               opt_p=true; prefix="$OPTARG" ;;
331            n) [[ "$opt_n" == true ]] && { echo "[ERROR] cg_guard: option -n specified more than once." >&2; return "$CG_ERR_SYNTAX_ERROR"; }
332               opt_n=true; name_filter_fn="$OPTARG" ;;
333            z) _cg_unpack_args "$OPTARG"
334               forward_opts+=("${_cg_unpacked[@]}") ;;
335            \?)
336                local flag="-$OPTARG"
337                local next="${*:OPTIND:1}"
338                # When $next does not start with a dash and is not --, it is either
339                # an option parameter to the resolver, or the first token to convert.
340                # We test it first as an option parameter, verbatim, and reach
341                # a conclusion if the resolver complains that the name to resolve is
342                # missing.  If the resolver rejects the flag as a syntax error, the
343                # option is not recognised and cg_guard returns immediately.
344                if [[ -n "$next" && "${next:0:1}" != "-" && "$next" != "--" ]]; then
345                    "$resolver" "${forward_opts[@]}" "$flag" "$next" >/dev/null 2>&1
346                    local probe_rc=$?
347                    if [[ $probe_rc -eq "$CG_ERR_MISSING_ARGUMENT" ]]; then
348                        forward_opts+=("$flag" "$next")
349                        (( OPTIND++ ))
350                    elif [[ $probe_rc -eq "$CG_ERR_SYNTAX_ERROR" ]]; then
351                        echo "[ERROR] cg_guard: option '$flag' is not recognised by resolver '$resolver'." >&2
352                        return "$CG_ERR_SYNTAX_ERROR"
353                    else
354                        forward_opts+=("$flag")
355                    fi
356                else
357                    forward_opts+=("$flag")
358                fi
359                ;;
360            :)  echo "[ERROR] cg_guard: option -$OPTARG requires an argument." >&2
361                return "$CG_ERR_MISSING_ARGUMENT" ;;
362        esac
363    done
364    shift $((OPTIND - 1))
365    [[ "${1-}" == "--" ]] && shift
366
367    if [[ "$opt_p" == true && -z "$prefix" && "$name_filter_fn" == "cg_mkfname_prefix" && "$quiet" != true ]]; then
368        echo "[WARNING] cg_guard: -p \"\" with the default filter has no effect; omit -p or specify a non-empty prefix." >&2
369    fi
370
371    # Handle zero tokens
372    if [[ $# -eq 0 ]]; then
373        if [[ "$quiet" != true ]]; then
374            echo "[WARNING] cg_guard: no commands specified." >&2
375        fi
376        return 0
377    fi
378
379    # First pass: validate all tokens (all-or-nothing)
380    local token fname rhs bname full_path
381    local -a valid_fnames=()
382    local -a valid_paths=()
383
384    for token in "$@"; do
385        if [[ "${token:0:1}" == "/" ]]; then
386            # /abs/path form — filter applied to basename
387            bname="${token##*/}"
388            fname="$(_cg_guard_mkfname "$name_filter_fn" "$prefix" "$bname")" || return $?
389            if [[ ! -x "$token" ]]; then
390                echo "[ERROR] cg_guard: unable to resolve full path for '$token'. Use the full path." >&2
391                return "$CG_ERR_NOT_FOUND"
392            fi
393            valid_fnames+=("$fname")
394            valid_paths+=("$token")
395
396        elif [[ "$token" == *=* ]]; then
397            # fname=rhs form — prefix NOT applied
398            fname="${token%%=*}"
399            rhs="${token#*=}"
400
401            if ! [[ "$fname" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
402                echo "[ERROR] cg_guard: invalid command identifier '$fname'." >&2
403                return "$CG_ERR_INVALID_NAME"
404            fi
405
406            if [[ "${rhs:0:1}" == "/" ]]; then
407                # Absolute path — verbatim
408                if [[ ! -x "$rhs" ]]; then
409                    echo "[ERROR] cg_guard: unable to resolve full path for '$fname'. Use the full path." >&2
410                    return "$CG_ERR_NOT_FOUND"
411                fi
412                full_path="$rhs"
413            elif [[ "$rhs" == */* ]]; then
414                # Contains / but not absolute
415                echo "[ERROR] cg_guard: '$rhs' must be an absolute path." >&2
416                return "$CG_ERR_SYNTAX_ERROR"
417            else
418                # Plain name — resolve via resolver
419                full_path="$(_cg_guard_resolve "$resolver" "${forward_opts[@]}" "$rhs")" || return $?
420            fi
421            valid_fnames+=("$fname")
422            valid_paths+=("$full_path")
423
424        else
425            # plain name — prefix applied, resolved via resolver
426            if ! [[ "$token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
427                echo "[ERROR] cg_guard: invalid command identifier '$token'." >&2
428                return "$CG_ERR_INVALID_NAME"
429            fi
430
431            full_path="$(_cg_guard_resolve "$resolver" "${forward_opts[@]}" "$token")" || return $?
432            fname="$(_cg_guard_mkfname "$name_filter_fn" "$prefix" "$token")" || return $?
433            valid_fnames+=("$fname")
434            valid_paths+=("$full_path")
435        fi
436    done
437
438    # Second pass: create wrapper functions
439    local i _cg_fname _cg_path_q
440    for ((i=0; i<${#valid_fnames[@]}; i++)); do
441        _cg_fname="${valid_fnames[i]}"
442        _cg_path_q="$(printf '%q' "${valid_paths[i]}")"
443        eval "$(cat <<CGEOF
444${_cg_fname}() {
445  if [[ \$BASHPID != \$\$ ]]; then
446    ${_cg_path_q} "\$@" &
447    local _cg_p=\$! _cg_rc=0
448    trap 'kill "\$_cg_p" 2>/dev/null' EXIT
449    trap 'kill      "\$_cg_p" 2>/dev/null' INT
450    trap 'kill -TERM "\$_cg_p" 2>/dev/null' TERM
451    trap 'kill -HUP  "\$_cg_p" 2>/dev/null' HUP
452    wait "\$_cg_p" || _cg_rc=\$?
453    while (( _cg_rc > 128 )) && kill -0 "\$_cg_p" 2>/dev/null; do
454      wait "\$_cg_p" || _cg_rc=\$?
455    done
456    return "\$_cg_rc"
457  else
458    ${_cg_path_q} "\$@"
459  fi
460}
461CGEOF
462)"
463    done
464}
465
466# Define 'guard' as a short alias for cg_guard only if unclaimed.
467if ! declare -f guard >/dev/null 2>&1; then
468    guard() { cg_guard "$@"; }
469fi
470
471# --- Change History -------------------------------------------------------
472# | PR    | Summary                                                        |
473# |-------|----------------------------------------------------------------|
474# | #8    | initial library                                                |
475# | #18   | multiple arguments support in guard function                   |
476# | #23   | feature/skills update                                          |
477# | #84   | remove unused CG_ERR_MISSING_COMMAND [closes #24]              |
478# | #86   | shellcheck: quote args, fix source annotations [closes #85]    |
479# | #113  | name=path guard token syntax [closes #111]                     |
480# | #114  | PATH enforcement API — cg_safe_run, cg_unsafe [closes #112]    |
481# | #118  | name filter and snap search API [closes #116, #117]            |
482# | #127  | gated trap-EXIT wrapper for async kill-propagation [closes #127] |
483# | #134  | remove top-level return 0 — fixes SC2317 in sourcing files [closes #133] |

Change History

PR

Summary

#8

initial documentation

#23

feature/skills update

#113

name=path guard token syntax [closes #111]

#114

PATH enforcement API – cg_safe_run, cg_unsafe [closes #112]

#118

name filter and snap search API [closes #116, #117]

#127

gated trap-EXIT wrapper for async kill-propagation [closes #127]