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

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
440    for ((i=0; i<${#valid_fnames[@]}; i++)); do
441        eval "${valid_fnames[i]}() { \"${valid_paths[i]}\" \"\$@\"; }"
442    done
443}
444
445# Define 'guard' as a short alias for cg_guard only if unclaimed.
446if ! declare -f guard >/dev/null 2>&1; then
447    guard() { cg_guard "$@"; }
448fi
449
450return 0
451
452# --- Change History -------------------------------------------------------
453# | PR    | Summary                                                        |
454# |-------|----------------------------------------------------------------|
455# | #8    | initial library                                                |
456# | #18   | multiple arguments support in guard function                   |
457# | #23   | feature/skills update                                          |
458# | #84   | remove unused CG_ERR_MISSING_COMMAND [closes #24]              |
459# | #86   | shellcheck: quote args, fix source annotations [closes #85]    |
460# | #113  | name=path guard token syntax [closes #111]                     |
461# | #114  | PATH enforcement API — cg_safe_run, cg_unsafe [closes #112]    |
462# | #118  | name filter and snap search API [closes #116, #117]            |

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]