Recipe Context Environment Export — Reference¶
Complete reference for how amplihack recipe run exports every recipe context
variable as an environment variable so that bash steps can read them directly —
including under set -u and inside nested sub-recipes.
This contract complements the {{placeholder}} template substitution already
documented in Recipe Executor Environment.
Template substitution rewrites step text; context environment export makes the
same values available to the process environment of every shell step.
Contents¶
- Why it exists
- Behavior summary
- Key transformation rules
- Reserved-name denylist
- Aggregate environment budget
- Precedence and no-regression guarantees
- Nested and sub-recipe propagation
- Skip logging
- API:
context_env_pairsand budget helpers - Examples
- Configuration
- Security model
- resolve-bundle-asset availability
- Related
Why it exists¶
Recipe context variables such as task_description and repo_path were
substituted into bash step text as {{task_description}} placeholders but were
not present in the process environment of the shell step. A bash step that
referenced $TASK_DESCRIPTION or $REPO_PATH directly would therefore fail
under set -u (the default hardening for recipe shell steps):
This blocked every multi-workstream campaign that reached
step-03-create-issue, because that step reads context from the environment.
Six prior follow-up workstreams all failed at the same step and produced no
pull requests.
Context environment export closes the gap: the values that feed
{{placeholders}} are now also exported as environment variables, so bash
steps may use either form interchangeably. The export happens once on the
recipe-runner-rs subprocess and is inherited by every nested shell step,
including those launched from sub-recipes.
The aggregate-size failure (issue #1023)¶
Exporting every context value protected each individual value against
E2BIG (the per-variable cap, see rule 6), but the
mirror originally had no aggregate cap. recipe-runner-rs inherits the whole
environment and re-exports it to every bash step (Command::envs(child_env)), so
in a long workflow the cumulative environment grows until the total argv+envp
size crosses the kernel's ARG_MAX limit. The first small bash step can spawn
fine, but a late step — for example step-19d-verification-gate, after all the
real work is already done — fails at spawn time with:
This is a false failure: the recipe is reported as failed even though every
meaningful step succeeded. The runner-side analysis is in
rysweet/amplihack-recipe-runner#130;
because the runner inherits its environment from amplihack recipe run, the
primary fix belongs here, on the CLI side, and is described in
Aggregate environment budget.
Behavior summary¶
When amplihack recipe run launches recipe-runner-rs, it applies the merged
recipe context (the same map fed to {{placeholder}} substitution) to the child
process environment:
| Aspect | Behavior |
|---|---|
| Source map | The merged recipe context (context block + -c/--context flags + inferred values) |
| Name | Context key, ASCII-uppercased (task_description → TASK_DESCRIPTION) |
| Value | Context value, unchanged |
| Scope | The recipe-runner-rs subprocess and — through normal process-environment inheritance — every shell and sub-recipe step it spawns (see propagation) |
| Validity | Names must be valid POSIX shell identifiers; invalid keys are skipped |
| Safety | Reserved/dangerous names are never exported (see denylist) |
| Precedence | Lowest — builder-managed and correlation variables always win |
No recipe YAML changes are required. Existing recipes that only use
{{placeholders}} are unaffected; recipes that read $UPPERCASE_NAME from the
environment now work.
Key transformation rules¶
Each context entry (key, value) is transformed into a candidate environment
pair (NAME, value):
- Uppercase.
NAME = key.to_ascii_uppercase(). Only ASCII letters are case-folded; non-ASCII characters are left unchanged and consequently fail validation in step 2. - Validate as a shell identifier.
NAMEmust match^[A-Z_][A-Z0-9_]*$. This rejects: - empty keys,
- keys whose uppercased form begins with a digit,
- keys containing any character outside
[A-Z0-9_](spaces, dots, dashes,=, non-ASCII, etc.). - Reject control characters in the value. A value containing a NUL byte
(
\0) cannot be represented in a process environment and is skipped. - Reject reserved names.
NAMEmust not appear in the reserved-name denylist and must not begin with theAMPLIHACK_prefix. - Reject oversized values. A single environment string longer than the
kernel's
MAX_ARG_STRLEN(≈128 KB on Linux) makes the spawn fail withE2BIG. Values above a conservative per-variable byte cap are therefore not mirrored into the environment; they are still delivered to the runner via the recipe context file for{{placeholder}}substitution. - Fit within the aggregate budget. After all per-key checks pass, the
surviving pairs are admitted against a single, runtime-derived
aggregate environment budget so the total
environment handed to
recipe-runner-rs(and inherited by every bash step) stays safely under the kernel'sARG_MAX. A small set of essential keys is always admitted; the remaining keys are filled smallest-first until the budget is exhausted, and any key that does not fit is skipped from the environment mirror only. Skipped values are still delivered via the recipe context file, exactly like an oversized value in rule 5.
A candidate that passes all checks is exported. A candidate that fails any check is skipped (never exported, never fatal) and a name-only warning is emitted (see Skip logging).
Common mappings¶
| Context key | Exported environment variable |
|---|---|
task_description |
TASK_DESCRIPTION |
repo_path |
REPO_PATH |
issue_number |
ISSUE_NUMBER |
branch_name |
BRANCH_NAME |
target_path |
TARGET_PATH |
Collision behavior¶
If two distinct context keys uppercase to the same environment name (for example
repo_path and REPO_PATH), the context map is iterated in deterministic
(sorted) order and the last writer wins. This is well-defined but should be
avoided in recipe authoring.
Reserved-name denylist¶
Some environment variables change how the shell or dynamic loader behaves before
a single line of the step runs. Exporting attacker- or author-controlled values
into those names would be a code-execution vector. The exporter therefore
never sets any name in the reserved denylist, regardless of context, and
never sets any name beginning with AMPLIHACK_ (those are owned by the
subprocess environment builder).
| Category | Reserved names |
|---|---|
| Dynamic linker | LD_PRELOAD, LD_LIBRARY_PATH, DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH, GLIBC_TUNABLES |
| Shell startup / RCE | BASH_ENV, ENV, PS4, PROMPT_COMMAND, SHELLOPTS, BASHOPTS |
| Word splitting | IFS |
| Path and identity | PATH, HOME, SHELL, PWD, USER, LOGNAME |
| Interpreter options | PYTHONPATH, NODE_OPTIONS, PERL5OPT, RUBYOPT |
| Framework-owned prefix | any name starting with AMPLIHACK_ |
BASH_ENV and PS4 are the most commonly overlooked code-execution vectors —
BASH_ENV names a file sourced before a non-interactive script runs, and PS4
is expanded (and can contain command substitution) whenever set -x is active.
Both are denied.
The denylist is the primary control that makes bare (un-prefixed) export names acceptable. It is exhaustively covered by tests. See Security model.
Aggregate environment budget¶
The per-variable cap (rule 5) stops any single
value from triggering E2BIG, but it cannot stop the sum of many valid values
from overflowing the kernel's total argv + envp limit (ARG_MAX). Because
recipe-runner-rs inherits this environment and re-exports it to every bash
step, an unbounded mirror made late steps in long workflows fail with
Argument list too long (os error 7) (issue #1023). The aggregate budget is the
fix: the exporter admits context pairs only up to a runtime-derived byte budget
that keeps the whole inherited environment comfortably under ARG_MAX.
The budget is adaptive, not a fixed magic number. It is derived at spawn time from the actual kernel limit and the environment this process already carries.
How the budget is derived¶
budget = ARG_MAX
− bytes already consumed by the inherited process environment
− a reservation for argv and the runner's own added variables
(floored at 0)
- Query the kernel limit. On Unix the exporter reads
sysconf(_SC_ARG_MAX). If the call returns≤ 0, or an implausibly small value (below 64 KB — smaller than any real platform and a sign of a broken or emulated environment), it falls back to a conservative constant of 128 KB. POSIX only guarantees_POSIX_ARG_MAX= 4096, which is too small to reason about, so the floor and fallback are deliberately generous. Non-Unix targets always use the 128 KB fallback. - Subtract the pass-through environment. The exporter sums
name.len() + value.len() + per-entry-overheadover the current process environment (std::env::vars_os()) — this is the environment that will pass through to the runner regardless of the mirror. The per-entry overhead (16 bytes) accounts for the=separator, the trailingNUL, and the argv/envp pointer slot on a 64-bit host. - Subtract a reservation. A single flat reservation (128 KB) covers the
process argv, the runner's own
RECIPE_VAR_*re-exports, theAMPLIHACK_*variables added by the environment builder, and a safety margin. - Floor at zero. All arithmetic is saturating, so a pathologically large
inherited environment yields a budget of
0(essentials only) rather than a wrapped, huge number.
The derivation is factored into a pure helper so it is unit-testable without
depending on the host's real ARG_MAX (see
API).
Operator override¶
Set AMPLIHACK_CONTEXT_ENV_BUDGET_BYTES to an explicit non-negative integer to
override the derived budget. When set and valid, the override takes
precedence over the derived value, so policy can be pinned explicitly (for
example in CI images with unusual ARG_MAX).
| Value | Effect |
|---|---|
| unset | Budget is derived adaptively (default). |
a valid usize, e.g. 262144 |
That exact byte budget is used for the mirror. |
0 |
Valid — mirror only the essential keys, drop all non-essential mirroring. |
| invalid (non-numeric, negative, overflowing) | Ignored; a name-only WARN with reason invalid_env_budget_override is logged and the derived budget is used. |
See the environment variable reference.
Essential keys (always exported)¶
A small, known set of context keys is always mirrored into the environment,
regardless of the budget, because bash steps under set -u depend on them and
they are tiny:
- exact keys:
task_description,repo_path,existing_branch - prefix keys: any key beginning with
should_(e.g.should_create_pr)
Essential keys are matched on the original (lowercased) context key, before
uppercasing. They are counted against the budget first. In the pathological case
where the essential set alone exceeds the budget, the essentials are still
exported and a loud WARN with reason essential_env_exceeds_budget is
emitted — a required variable is never silently dropped. In that case no
non-essential keys are mirrored.
Essentials are exempt from the budget only. They are never exempt from the per-key filters or the denylist: a key that looks essential but is reserved, invalid, NUL-bearing, or oversized is still dropped.
Smallest-first fill for non-essential keys¶
After essentials are admitted, the remaining budget is filled with the
non-essential pairs smallest-first by exported entry size
(name.len() + value.len() + overhead), with a stable tie-break by name for
determinism. This maximizes the number of useful variables that survive: many
small vars are kept in preference to one large var. Each non-essential key that
does not fit is dropped from the mirror with a name-only WARN whose reason is
aggregate_env_budget.
Every dropped value — essential-exceeds notwithstanding — remains available to
the recipe via the unchanged --set / --context-file delivery path, so
{{placeholder}} substitution is unaffected. Only the environment mirror is
trimmed, never the runner's view of the context. This is the same contract as
the per-value cap in rule 5 (issue #784).
Worked example¶
Given a budget of 4096 bytes and this context (post-filter, post-uppercase):
| Key | Exported size | Class | Admitted? |
|---|---|---|---|
task_description |
40 B | essential | ✅ always |
repo_path |
30 B | essential | ✅ always |
SMALL_NOTE |
100 B | non-essential | ✅ (fits, smallest) |
MEDIUM_BLOB |
2 KB | non-essential | ✅ (fits) |
HUGE_BLOB |
80 KB | non-essential | ❌ aggregate_env_budget |
HUGE_BLOB is dropped from the environment but still substitutes into
{{huge_blob}} and is delivered to the runner via the context file.
Precedence and no-regression guarantees¶
Context environment variables are applied at the lowest precedence. The spawn seam writes them first, then layers the subprocess environment builder and the correlation variable on top:
1. command.envs(context_env_pairs(context, budget)) // context, lowest priority
2. env_builder.apply_to_command(&mut command) // AMPLIHACK_*, pager-safe, PATH/HOME fallbacks
3. command.env("AMPLIHACK_RECIPE_RUN_ID", …) // correlation id, highest priority
Guarantees that follow from this ordering:
- Builder-managed variables always win.
AMPLIHACK_NONINTERACTIVE,AMPLIHACK_HOME,AMPLIHACK_AGENT_BINARY,AMPLIHACK_ASSET_RESOLVER,AMPLIHACK_GRAPH_DB_PATH, pager-safe defaults, Python sanitization, and theCLAUDECODEremoval cannot be overridden by recipe context. - Correlation is immutable.
AMPLIHACK_RECIPE_RUN_IDreflects the real run identity even if a context key tried to collide with it (it is also blocked by theAMPLIHACK_prefix rule). PATH/HOMEare never clobbered. These are on the denylist, so a context key such aspath=/evilis dropped rather than replacing the processPATH.--set/--context-fileplaceholder delivery is unchanged. The existing argv- and temp-file-based delivery used for{{placeholder}}substitution is untouched; environment export is additive.
Nested and sub-recipe propagation¶
The CLI exports the context once, when it spawns the recipe-runner-rs
subprocess. std::process::Command starts that child with the parent's
environment plus the pairs added by command.envs(...), and never clears it, so
the exported context is guaranteed to reach recipe-runner-rs itself. This first
hop — CLI → recipe-runner-rs — is owned by code in this repository and is
directly testable.
From there, propagation down to individual steps relies on recipe-runner-rs
(an external binary, typically installed in ~/.cargo/bin) using default,
inheriting process spawning for its shell steps and nested sub-recipes — that is,
it does not clear or rewrite the environment before launching them. Under that
contract:
- the top-level recipe's bash steps see
$TASK_DESCRIPTION/$REPO_PATH; - a
type: recipesub-step's bash steps see them too; - a
sh -c '…'grandchild launched by a bash step still sees them.
Because this end-to-end path crosses an external binary, it is treated as a
verified contract, not an unchecked assumption. The nested sh -c canary in
the propagation tutorial — a
sub-recipe step whose bash command runs
sh -c 'set -u; echo "$TASK_DESCRIPTION"' — exercises the full chain
(CLI → recipe-runner-rs → bash step → grandchild shell) and must print the
value rather than abort with unbound variable. If a future recipe-runner-rs
cleared or rewrote the environment, that canary would fail first, surfacing the
regression at the seam where it occurs.
This is exactly the "parent context propagated to child" behavior that
multi-workstream campaigns require. Per-sub-recipe context overrides (the
step-level context: dict on a type: recipe step) are resolved inside the
recipe runner's {{placeholder}} layer and are out of scope for the CLI-level
environment export.
Skip logging¶
When a context entry is skipped, the exporter emits a WARN-level
trace event naming only the key, never the value,
so sensitive context never leaks into logs:
WARN recipe context key skipped for env export name=ISSUE TITLE reason=invalid_identifier
WARN recipe context key skipped for env export name=LD_PRELOAD reason=reserved_name
WARN recipe context key skipped for env export name=NOTES reason=value_contains_nul
WARN recipe context key skipped for env export name=HUGE_BLOB reason=aggregate_env_budget
Visibility (by design). Skip notices are
WARN-leveltracingevents emitted by the parentamplihackprocess — the same mechanism the rest of the recipe-run subsystem uses for diagnostics. The CLI initializes its subscriber withEnvFilter::from_default_env()and no default directive, so withRUST_LOGunset onlyERRORis shown and skip notices are suppressed. This is a deliberate decision: a skipped key is advisory (the run still succeeds), so it is surfaced on demand withRUST_LOG=warn amplihack recipe run …(orinfo/debug) rather than printed on every run. When a skipped key later causes anunbound variablefailure, the troubleshooting guide directs you to re-run withRUST_LOG=warnto see which key was dropped and why.Field rendering. The fields are recorded as
%-display values (name = %name,reason = %reason), which produces the unquotedname=…/reason=…rendering shown above. This matches the existing recipe-runtracing::warn!style incrates/amplihack-cli/src/commands/recipe/resolve.rs; named (non-%) fields would render quoted instead. The live subscriber also prefixes a timestamp that these examples omit for brevity.
Skip reasons:
| Reason | Meaning |
|---|---|
invalid_identifier |
Uppercased name is empty, starts with a digit, or contains characters outside [A-Z0-9_] |
reserved_name |
Name is on the denylist or begins with AMPLIHACK_ |
value_contains_nul |
Value contains a NUL byte and cannot be represented in the environment |
value_too_large |
Value exceeds the per-variable byte cap (kept below the kernel's MAX_ARG_STRLEN to avoid E2BIG); the value is still delivered via the recipe context file for {{placeholder}} substitution |
aggregate_env_budget |
A non-essential key did not fit within the aggregate environment budget; the value is still delivered via the recipe context file |
essential_env_exceeds_budget |
The essential keys alone exceed the budget; they are exported anyway (loud warning) and no non-essential keys are mirrored |
invalid_env_budget_override |
AMPLIHACK_CONTEXT_ENV_BUDGET_BYTES was set to a non-numeric/negative/overflowing value; it is ignored and the derived budget is used |
Skips are never fatal. A recipe with one un-exportable key still runs; only that
single key is omitted from the environment (its {{placeholder}} form, if used,
continues to work).
API: context_env_pairs and budget helpers¶
The transform is implemented as a pure, total function so it can be unit-tested
in isolation from process spawning. The budget arithmetic is factored into a
separate pure helper so it can be tested with injected values, independent of
the host's real ARG_MAX.
Location: crates/amplihack-cli/src/commands/recipe/run/execute.rs
/// Transform a recipe context map into the environment pairs to export to
/// `recipe-runner-rs` and its shell steps.
///
/// Each `(key, value)` becomes `(KEY, value)` where `KEY` is the ASCII-
/// uppercased key. Entries are skipped (with a name-only WARN) when the
/// uppercased name is not a valid shell identifier, is a reserved name, begins
/// with `AMPLIHACK_`, the value contains a NUL byte, or the value exceeds the
/// per-variable cap.
///
/// Surviving pairs are admitted against `budget` (total exported bytes):
/// essential keys are always exported; the remaining keys are filled
/// smallest-first until the budget is exhausted. Keys that do not fit are
/// dropped from the env mirror (name-only WARN, reason `aggregate_env_budget`)
/// but remain available via the recipe context file.
///
/// Total: invalid/over-budget entries are skipped, never fatal. Deterministic:
/// input is a sorted `BTreeMap` and fill order is smallest-first with a stable
/// name tie-break.
fn context_env_pairs(
context: &BTreeMap<String, String>,
budget: usize,
) -> Vec<(String, String)>;
| Property | Guarantee |
|---|---|
| Totality | Never panics, never returns Err; invalid/over-budget entries are dropped |
| Determinism | Essentials first; non-essentials smallest-first with a stable name tie-break |
| Purity | No I/O except name-only WARN tracing for skipped keys |
| Budget safety | Total exported bytes never exceed budget, except when the essential set alone exceeds it (essentials always win, with a loud warning) |
Budget helpers¶
/// Pure budget arithmetic. Deterministic and injectable for tests:
/// arg_max.saturating_sub(inherited_env_bytes + reservation)
/// Floors at 0 (saturating), so a huge inherited env yields "essentials only".
fn context_env_budget(
arg_max: usize,
inherited_env_bytes: usize,
reservation: usize,
) -> usize;
/// Probe the kernel's ARG_MAX. On Unix reads `sysconf(_SC_ARG_MAX)`, guarding
/// `<= 0` and implausibly small (`< 64 KB`) results before the cast; otherwise
/// (and on non-Unix) returns the 128 KB conservative fallback.
fn query_arg_max() -> usize;
/// Sum `name.len() + value.len() + ENV_ENTRY_OVERHEAD_BYTES` over
/// `std::env::vars_os()` with saturating arithmetic.
fn inherited_env_bytes() -> usize;
/// Caller-facing orchestrator. Honors the `AMPLIHACK_CONTEXT_ENV_BUDGET_BYTES`
/// override (strict `usize` parse; invalid → name-only WARN + derived budget;
/// `0` is valid) and otherwise returns the derived budget from the helpers
/// above.
fn resolve_context_env_budget() -> usize;
Documented constants (all SCREAMING_SNAKE, cited to issue #1023):
/// Per-envp-entry overhead: `=` + trailing NUL + one 64-bit pointer slot + slack.
const ENV_ENTRY_OVERHEAD_BYTES: usize = 16;
/// Conservative ARG_MAX used when sysconf is unavailable or implausible.
const ARG_MAX_FALLBACK_BYTES: usize = 131_072; // 128 KB
/// sysconf results below this are treated as implausible → use the fallback.
const ARG_MAX_MIN_PLAUSIBLE_BYTES: usize = 65_536; // 64 KB
/// Flat reservation for argv, RECIPE_VAR_*/AMPLIHACK_* additions, and margin.
const ENV_BUDGET_RESERVATION_BYTES: usize = 131_072; // 128 KB
/// Operator override for the aggregate budget.
const CONTEXT_ENV_BUDGET_OVERRIDE_ENV: &str = "AMPLIHACK_CONTEXT_ENV_BUDGET_BYTES";
The companion constant lists the reserved names:
/// Environment names that must never be set from recipe context because they
/// alter shell or dynamic-loader behavior, or are owned by the subprocess
/// environment builder.
const RESERVED_ENV_DENYLIST: &[&str];
The spawn seam consumes the helper at the lowest precedence. In
execute_recipe_via_rust (execute.rs), the call is inserted immediately after
pass_context(&mut command, context)? and before
env_builder.apply_to_command(&mut command), so the builder and correlation id
layer on top:
// after: let _context_file = pass_context(&mut command, context)?;
let budget = resolve_context_env_budget(); // adaptive or override
command.envs(context_env_pairs(context, budget)); // context, lowest priority
// … then env_builder.apply_to_command(&mut command); // AMPLIHACK_*, pager-safe, PATH/HOME
// … then command.env("AMPLIHACK_RECIPE_RUN_ID", correlation.run_id());
EnvBuilder::apply_to_command does not clear the environment — it only
env_removes its explicitly unset keys and then command.envs(...) its own
pairs — so context variables added first survive except where a builder-managed
name (or the correlation id) intentionally overrides them.
Examples¶
Top-level recipe reading the environment under set -u¶
name: env-aware-recipe
description: Reads context from the environment, not just {{placeholders}}
version: "1.0"
context:
task_description: ""
repo_path: "."
steps:
- id: announce
type: bash
command: |
set -euo pipefail
echo "task: $TASK_DESCRIPTION"
echo "repo: $REPO_PATH"
amplihack recipe run env-aware-recipe \
-c task_description="Add validation for empty display names" \
-c repo_path=.
Output:
The same step could equivalently use {{task_description}} in the command text;
both forms now resolve to the same value.
Nested sub-recipe inheriting the environment¶
parent.yaml:
name: parent
context:
task_description: ""
repo_path: "."
steps:
- id: call-child
type: recipe
recipe: child
child.yaml:
name: child
steps:
- id: read-inherited
type: bash
command: |
set -euo pipefail
# Even a grandchild shell sees the exported context.
sh -c 'set -u; echo "child sees: $TASK_DESCRIPTION at $REPO_PATH"'
Output:
A skipped key¶
The skip notice is a WARN-level event, so run with RUST_LOG=warn to see it
(it is suppressed under the default error-only filter):
RUST_LOG=warn amplihack recipe run env-aware-recipe \
-c task_description="ok" \
-c "issue title=has spaces" \
-c repo_path=.
issue title uppercases to ISSUE TITLE, which is not a valid identifier, so it
is skipped:
The recipe still runs; TASK_DESCRIPTION and REPO_PATH are exported normally.
A key dropped by the aggregate budget¶
A large but otherwise valid value is admitted to the runner (for
{{placeholder}} substitution) but may be trimmed from the environment mirror
when the aggregate budget is tight. Force a tiny budget with the operator
override to observe it:
RUST_LOG=warn AMPLIHACK_CONTEXT_ENV_BUDGET_BYTES=256 \
amplihack recipe run env-aware-recipe \
-c task_description="ok" \
-c repo_path=. \
-c huge_blob="$(head -c 20000 /dev/zero | tr '\0' x)"
task_description and repo_path are essential and always exported;
huge_blob does not fit the 256-byte budget, so it is dropped from the mirror:
{{huge_blob}} still substitutes correctly because the value is delivered via
the context file. To mirror everything regardless of the derived budget, raise
the override (or unset it to use the adaptive default).
Configuration¶
Context environment export has no recipe-level flags and is always active and additive. The one operator-facing knob is the aggregate-budget override:
| Setting | Effect |
|---|---|
AMPLIHACK_CONTEXT_ENV_BUDGET_BYTES (unset) |
Budget is derived adaptively from ARG_MAX and the inherited environment. This is the default and the recommended mode. |
AMPLIHACK_CONTEXT_ENV_BUDGET_BYTES=<usize> |
Pins the aggregate mirror budget to exactly that many bytes; 0 means "essentials only". Takes precedence over the derived budget. |
Additional notes:
- There is no opt-out of export itself. Recipes that do not read environment variables are unaffected because the extra variables are simply present and unused.
- The set of exported variables is determined by the merged recipe context. To
change what is exported, change the context (
context:block or-c/--contextflags); the budget only controls how many survive the mirror when space is tight. - The reserved-name denylist and the essential-key set are fixed in code; they are not user-configurable, by design, because they are safety/security controls.
Large context maps continue to use the existing --context-file spill path for
{{placeholder}} delivery (argv size protection); every value the mirror drops
is still delivered there. The aggregate budget guarantees the mirror never pushes
the inherited environment past ARG_MAX, so late shell steps no longer fail with
Argument list too long (os error 7).
Security model¶
The trust boundary is: untrusted content (issue bodies, task descriptions, third-party recipes) → environment name and value → inherited by every nested shell step via the process environment.
| Control | Description |
|---|---|
| V1 — Input validation (allowlist) | Names must match ^[A-Z_][A-Z0-9_]*$; values must not contain NUL. |
| V2 — Reserved denylist (primary) | Loader, shell-startup, IFS, path/identity, interpreter-option, and AMPLIHACK_-prefixed names are never exported. |
| V3 — Bounded budget arithmetic | ARG_MAX is probed with guarded casts (≤ 0 and < 64 KB rejected); all size sums use saturating arithmetic, so a crafted large value cannot wrap the budget into an over-export. |
| V4 — Filters precede budget | All five per-key filters run before budget classification. A denylisted, invalid, NUL-bearing, or oversized key can never re-enter the mirror through the essentials or budget path — even at budget = usize::MAX and even if renamed to look essential. |
| G2 — Precedence as a control | Context is applied first (lowest priority); the environment builder and correlation id are applied after, so context can never override security-relevant builder configuration. |
| Name-only logging | Skip warnings (including aggregate_env_budget, essential_env_exceeds_budget, and invalid_env_budget_override) log the key name and a static reason only — never the value or its length — to avoid leaking sensitive context. |
Note on threat shape: at the spawn seam, names and values are passed via
Command::env, which performs no shell evaluation. The residual risk is
therefore name clobbering (replacing a meaningful variable), not value
injection — and clobbering of dangerous names is exactly what the denylist
prevents.
Defense-in-depth note: prefixing every exported name (for example
AMPLIHACK_CTX_TASK_DESCRIPTION) would eliminate the entire name-clobber class.
Bare names are retained for ergonomics ($TASK_DESCRIPTION is what recipe
authors expect) and are safe only while the denylist remains exhaustive and
fully test-covered.
resolve-bundle-asset availability¶
smart-orchestrator's preflight requires the amplihack resolve-bundle-asset
subcommand. That subcommand is implemented and wired in the Rust CLI:
| Surface | Location |
|---|---|
| Subcommand definition | crates/amplihack-cli/src/cli_commands.rs (ResolveBundleAsset, #[command(name = "resolve-bundle-asset")]) |
| Dispatch | crates/amplihack-cli/src/commands/mod.rs (Commands::ResolveBundleAsset) |
| Implementation | crates/amplihack-cli/src/resolve_bundle_asset/ |
| Standalone binary | bins/amplihack-asset-resolver/ (same single-argument interface) |
A cargo build -p amplihack-cli therefore exposes resolve-bundle-asset.
Reports of a "missing" subcommand trace to a stale installed Python
amplihack earlier on PATH, not to the Rust binary. Confirm which binary is
in use:
If the resolved binary is the legacy Python entry point, reinstall or reorder
PATH so the Rust amplihack is selected. No code change in this area is
required to unblock multi-workstream execution; this section exists to document
the verification. See
resolve-bundle-asset command reference.
Related¶
- Recipe Executor Environment — Subprocess launch, shell-step and agent-step environment injection
- Environment Variables — All variables read or injected by amplihack, including context-derived names
- Tutorial: Propagate Recipe Context to Bash Steps — Hands-on walkthrough for top-level, nested, and skipped keys
- Troubleshoot Recipe Execution — Diagnosing
TASK_DESCRIPTION: unbound variableand related failures - resolve-bundle-asset Command Reference — Native bundle asset resolver
- Recipe Run Correlation Reference —
AMPLIHACK_RECIPE_RUN_IDand pointer events