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-Xflags thatcg_guarddoes not recognise are forwarded to the active resolver (see Resolver Protocol).Options:
-q: Quiet mode, suppresses warnings.-n <name_filter>: Usename_filterinstead of the defaultcg_mkfname_prefixto compute the wrapper function name for plain-name and absolute-path tokens. Has no effect onfname=…tokens. See Name Filter Protocol. May appear at most once.-p <value>: Set the name filter parameter(s). For the defaultcg_mkfname_prefixfilter,valueis the prefix string prepended to the bare name. For custom filters,valueis a packed parameter list (see Name Filter Protocol — packed value syntax). An empty-p ""with the default filter emits a[WARNING]unless-qis active. Has no effect onfname=…tokens. May appear at most once.-r <resolver>: Useresolverinstead ofcg_safe_resolverto resolve plain-name andfname=name(non-absolute RHS) tokens.-z <packed>: Unpackpackedand 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: passcg_search_snapsoutput 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-rmay appear at most once;-zmay be repeated. Repeating-q,-n,-p, or-ris aCG_ERR_SYNTAX_ERROR.
Token forms (all forms may be mixed in a single call):
Token
Generated function name
Path source
fname=/abs/pathfname(prefix not applied)verbatim absolute path
fname=namefname(prefix not applied)active resolver
/abs/path<prefix>basenameverbatim absolute path
name<prefix>nameactive resolver
Rules:
fnameand plainnamemust be valid Bash identifiers (^[a-zA-Z_][a-zA-Z0-9_]*$).For
fname=rhs: ifrhscontains/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 thefname=/abs/pathform with an explicit identifier.
Returns:
0on success, including when zero tokens are provided (with optional warning).CG_ERR_INVALID_NAMEwhen a token contains an invalid Bash identifier.CG_ERR_MISSING_ARGUMENTwhen a guard option (-ror-p) is present but its required argument is missing.CG_ERR_NOT_FOUNDwhen a command cannot be resolved or a path is invalid or non-executable.CG_ERR_SYNTAX_ERRORwhen a relative path is used in thefname=rhsform (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 returnsCG_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...]fnmust be a declared Bash function (verified withdeclare -f).The fake PATH value is randomised (
SRANDOMon Bash 5.1+;${-}${RANDOM}fallback) to prevent an attacker from pre-populating/nonexistent-<fixed>with malicious symlinks.Returns:
CG_ERR_INVALID_NAMEiffnis not a declared function.Hard abort (
CG_ERR_PATH_VIOLATION) propagating through all callers if an unguarded external command is attempted insidefn.Whatever
fnreturns on success.
Use
cg_unsafeto wrap library-initialization code (cg_guardcalls) inside acg_safe_runcontext.
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
$PATHduring initialisation — code the caller does not control and that would fail undercg_safe_run’s read-only PATH.cg_guarditself never needscg_unsafe: bothcg_safe_resolverandcg_path_resolverestablish their own PATH independently.Why it is needed inside
cg_safe_run: third-party libraries sometimes set or rely on$PATHduring initialisation; undercg_safe_runthe PATH is read-only and such libraries would abort.cg_unsafelocally reverses the restriction for the duration of the called function, then the restriction is reinstated automatically when the function returns.Risk:
cg_unsaferestores 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 triggeringcg_command_not_found_handle. This suspends the enforcement guarantee ofcg_safe_runfor 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 thelocal PATHbinding ofcg_unsafe— it is discarded whencg_unsafereturns —$PATHmust be captured while still inside that scope.cg_guardnever reads$PATHon its own; the extended directories must always be passed explicitly via-d "$PATH"tocg_path_resolver. The wrapper must therefore either callcg_guard -r cg_path_resolver -d "$PATH" ...from within its own body, or capture$PATHinto 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 tocg_unsafe. Example: a library whose binaries live in/opt/optlib/binbut 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
fnreturns.
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
0and prints the absolute path on success.Returns
CG_ERR_NOT_FOUNDon failure (also prints the rawcommand -pvoutput, which may beexecfor builtins oralias …for aliases;cg_guarduses this to produce specific diagnostics).Returns
CG_ERR_SYNTAX_ERRORwith a diagnostic message when called with more than one argument (structural misuse; any attempt to forward a resolver option whilecg_safe_resolveris active causescg_guardto abort withCG_ERR_SYNTAX_ERRORvia the probe mechanism).Returns
CG_ERR_MISSING_ARGUMENTwhen 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;-dmay 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_PATHvariable. Option order is respected:-sinserts the safe path at its position in the search order relative to any-doptions.Builds a
local PATHfrom the accumulated directories in the order the options appear, then usescommand -vto resolve the command.Returns
0and prints the absolute path on success.Returns
CG_ERR_NOT_FOUNDon failure (command not resolved in the given directories).Returns
CG_ERR_SYNTAX_ERRORwith a diagnostic message when an unexpected token appears before the command name.Returns
CG_ERR_MISSING_ARGUMENTwhen 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_handlemay delegate to this function as a chaining call:command_not_found_handle() { my_application_handler "$@" cg_command_not_found_handle "$@" }
command_not_found_handleis 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:
$1is the prefix (possibly empty) and$2is the bare name. This matches the calling convention established bycg_guard— the default-p ""always supplies an empty-string prefix.Prints the concatenated
prefix + bare-nameon success; returns 0.Returns
CG_ERR_SYNTAX_ERRORwith a diagnostic if the argument count is not exactly 2.Returns
CG_ERR_INVALID_NAMEwith 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 orsnap debug pathsdoes not yield a usableSNAPD_BINdirectory. This is a no-op injection: the-zcase incg_guardinjects nothing and processing continues normally.$'-z\x1F-d\x1F/snap/bin'(actual path fromSNAPD_BIN) when snap is present and the directory exists.
Emits a
[WARNING]to stderr when thesnapbinary is found butsnap debug pathsfails orSNAPD_BINis 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 thatcg_guardcan 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_guarduses this to determine which forwarded options take an argument, via a probe call (seecg_guardoption forwarding).Resolvers must be pure (no side effects).
cg_guarddiscards 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_guardcaller.
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 |
|---|---|
|
Single element; the whole value is passed through as-is. |
|
Single empty-string element (one |
Any other character (e.g. |
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 withHS_ERR_INVALID_VAR_NAME).CG_ERR_MISSING_ARGUMENT=8: required argument missing — no command name supplied to a resolver, or a guard option-r/-pis missing its argument (aligned withHS_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 withHS_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_runhard-aborts the entire script on a PATH violation; there is no mechanism to catch or recover from it. This is by design.cg_path_resolversearches only the directories supplied via-dand/or-s. It does not fall back to the POSIX default PATH unless-sis present; list all required directories explicitly or add-sto include the standard locations.The
command_not_found_handlehook 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 viacg_command_not_found_handle.The
guardalias is a single global resource. The library defines it only if unclaimed; applications that define their ownguardfunction before sourcing the library will keep their version. Usecg_guarddirectly whenguardmay 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]