Repository navigation
fix!: bind operational secrets explicitly - #1269
Merged
Merged
Conversation
doublewhy
force-pushed
the
965-compose-environment-grants
branch
3 times, most recently
from
October 9, 2026 13:12
ec3ff96 to
1a9e11f
Compare
9 of 11 tasks
doublewhy
force-pushed
the
965-compose-environment-grants
branch
3 times, most recently
from
October 11, 2026 08:56
a4e0866 to
c3587d7
Compare
doublewhy
marked this pull request as ready for review
October 11, 2026 11:21
doublewhy
force-pushed
the
965-compose-environment-grants
branch
2 times, most recently
from
October 11, 2026 20:26
af92d3a to
6fc6706
Compare
A base-container node's declared environment now comes only from its authored value, the generated output its value_from names, an operator grant in aptl.json (deployment.environment_grants) for that pack, node and variable, or the admitted pack's identity-bound startup adapter. A value-less operator_secret, redacted or secret_fixture variable needs a grant or an adapter value; any other value-less declaration is delivered empty, as RAES projects it. A same-named variable in APTL's own process environment or in .env is no longer read on its own. The backend preflight binds every base-container node before any mutation: a missing or empty source, or a value Docker's env file cannot carry, stops the start. Grant sources in the process environment are read once, there. A reused container is recreated when a bound value changed or when its aptl.environment.names label names a variable that is no longer bound. Unused grants for the admitted pack are logged. Logs and errors name each source, never its value. The credential-source models move to aptl.core._credential_sources, which aptl.core.config re-exports. BREAKING CHANGE: a base-container variable that used to be filled from an exported shell variable or a same-named .env key now needs an environment grant in aptl.json, and a value-less plain or unknown variable is delivered empty instead of being omitted. Part of #965
The spec-lowering test now declares a value-less variable with RAES's default classification, unknown. It keeps its declared name and is neither sourced nor generated, so it takes no grant and is delivered empty. The granted-preflight test now builds its backend from aptl.json through load_config and get_backend, for both providers. Dropping the grant hand-off in get_backend fails it: the SSH backend receives its grants only through use_environment_grants.
The binding check runs in realize()'s backend preflight, after earlier lab-start steps such as Suricata volume seeding. The deployment guide now says it stops the start before realization creates or changes any scenario network, volume or container. It also says that a value-less variable that is not operator_secret, redacted or secret_fixture takes no grant. The BaseContainerSpec comment on environment_sourced now lists redacted, which that field already includes.
The grants section now says what a base-container node is: a node that declares runtime desired state and has no node image of its own, so APTL starts it from a base image. It links to Node Realization Routes. A refusal for a missing or empty source names the node and the variable. It names the pack only when no grant or adapter value applies. When a grant's or adapter's source holds no value, it names that source instead.
The realize() refusal test now also covers a value_from variable. It is refused in the backend preflight, after zero Docker commands, when no generated artifact binds it to the node, and also when an artifact binds it only to another node. A same-named process variable does not fill it. Removing the preflight's consumer filter fails the second case. Delivering a missing generated output as empty fails both, and realize() then goes on to build an image.
A generated Compose service took each value-less operator_secret or
secret_fixture variable through a ${NAME} reference. Compose resolves such
a reference from its own process environment first, which inherited all of
APTL's environment, and every service and Compose file of the project
shares that one scope.
The backend preflight now binds those variables on every generated Compose
service as it does on base-container nodes: from an operator grant for that
pack, node and variable, or from the admitted pack's own startup adapter,
before any mutation. A missing or empty source, or a value a single-quoted
Compose env-file line cannot carry, stops the start. Each service receives
its values through its own owner-only env file under
.aptl/realization/sourced-environment/, which a generated override
attaches to that service alone, so a value never reaches another service
or APTL's own Compose files. Files a later request does not use are
removed. Logs name each service's sources, never a value, and unused-grant
warnings now cover Compose services.
The generated environment map keeps authored values only, with $ escaped
as $$, and derives its rules from the same declaration reading as the
binding code. Every docker compose command runs with the Docker client's
own variables and the APTL_*, BUILDKIT_* and BUILDX_* settings only. The
observability preflight reads its Grafana credential from .env only.
BREAKING CHANGE: a docker compose process no longer inherits APTL's
environment, so a shell variable no longer overrides a .env value such as
GRAFANA_ADMIN_PASSWORD or INDEXER_PASSWORD. A value-less secret on a
generated Compose service needs a grant or a startup-adapter value.
Part of #965
The generated Compose model wrote each authored command, entrypoint,
environment value, mount path and host address as is. Compose
interpolates ${NAME} in every string of a Compose file from the bound
project .env and the kept client environment. An authored
${INDEXER_PASSWORD} in a command therefore reached the container as the
.env value without any grant, and ${HTTPS_PROXY} as the client's proxy
URL with any credentials it holds.
The generated base model and its port and stateful overrides now write
each $ as $$, which Compose reads as one literal $. The pre-start model
check reads the uninterpolated model back the same way before it
compares it with the realization.
The stop-before-mutation test now also covers a granted or adapter value
that ends in an odd number of backslashes, which a single-quoted Compose
env-file line cannot carry. The Compose CLI readback tests no longer
carry the integration mark, so CI runs them wherever the CLI exists. The
Compose client environment keeps PROGRAMFILES, where the Docker CLI
looks for its Windows plugins.
The content override bound each image-node content placement at its
authored destination, an inline text under its authored file name and
each file of a pack directory under its own name, all written as is.
None of those excludes $: the literal-destination lowering only strips
the leading slash, the RAES Content model accepts $ in path and
destination, and pack member names allow it. Compose therefore bound a
content path of /etc/${INDEXER_PASSWORD}/app.conf at the .env value's
path with no grant, and a pack file named ${GRAFANA_ADMIN_PASSWORD}.ini
under the Grafana credential.
The content and image overrides and the Kali capture model are now
written through compose_literal, like the base model and its port and
stateful overrides. An image reference is built from a pack source name
or artifact id, which APTL's image-name check does not restrict, and the
capture model binds the SSH key outputs at the paths the pack declares,
which RAES allows to hold $. The pre-start model check already reads
the whole uninterpolated model back through compose_readback.
Part of #965
env-packs' gates admit a root docker-compose.yml as an ordinary declared
pack member. The Compose consumers take that file as a project-tree
scenario's own model and use it unchanged, so the environment-binding
preflight skipped its services and Compose filled its ${NAME} references
from .env with no grant. env_pack_bundle() now refuses such a pack after
env-packs' gates pass, so a pack's Compose model is always the one
generated from its realization. A Compose file elsewhere in a pack, such
as a reference deployment under build/, still stages.
The capture-model test now also reads the model back through
docker compose config with the .env and proxy canaries.
A project-tree scenario's own docker-compose.yml is used unchanged. Say that its references resolve from .env and from the variables the docker compose process keeps, such as the APTL_* settings, so it can pass any .env value to whatever image the scenario picks. Say that its services take no grant or startup-adapter value, and that a generated-artifact output still reaches each service that declares it through the artifact's own env file, which the stateful override attaches whether or not the base model is generated.
The project-tree paragraph said that grant and startup-adapter values apply only to the scenario's base-container nodes, which reads as if a project-tree scenario's base-container nodes could take one. None of its nodes can: a project-tree bundle has no pack identity, so EnvironmentBindingContext.grant_for matches no grant and select_scenario_startup selects no adapter plan. Say that neither the file's services nor its base-container nodes take such a value, and that a value-less operator_secret, redacted or secret_fixture variable on a base-container node therefore stops aptl lab start, as test_a_grant_binds_only_the_pack_it_names[project-tree] pins and as the grant paragraph further down already says.
3 tasks
A value-less secret_fixture takes its value from an operator grant or the admitted pack's startup adapter (#965). The post-start readback read that value off the container, and RAES disclosed it as an unsalted SHA-256 commitment over the variable name and the value. The commitment reached the runtime snapshot and the run record, where a guessed value could be checked against it. For the released TechVault pack, these were the six Wazuh INDEXER_*, API_* and DASHBOARD_* credentials on wazuh-manager and wazuh-dashboard. The readback now never reads such a variable and leaves it out of the disclosed environment. The start's binding log names its source. A value-less operator_secret or redacted variable is still disclosed by presence only, and a value that the scenario authors keeps RAES's commitment. Open environment authority, which TechVault uses, passes the readback gate as before. Exact authority rejects such a variable after the start, as it did when the commitment contradicted the declared empty value; #1332 tracks that.
doublewhy
force-pushed
the
965-compose-environment-grants
branch
from
October 12, 2026 06:34
6fc6706 to
75fa8fb
Compare
…se-environment-grants
…se-environment-grants
9 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR completes #965 on its own. It now carries #1262's five commits (the generic half), which #1262 opened separately, so #1262 is closed in favor of this PR. On
dev, a declared variable can be filled by name from APTL's own process environment or the project.env, and a same-named shell variable can override an authored fixture. A matching name is not authority to read a credential, so each declared variable now comes from exactly one source that names its consumer:generated: an artifact output, only for a variable declared withvalue_from, and only from an artifact bound to this node.authored: the scenario's value, delivered exactly. A declaration without a value is delivered empty.grant:<kind>:<variable>: for a value-lessoperator_secret,redactedorsecret_fixture, a grant inaptl.json(deployment.environment_grants) for this pack, node and variable. The grant names one process-environment variable or one.envkey.startup-adapter:project-env-file:<variable>: failing a grant, a name that the admitted pack's own startup adapter writes to.env.A missing or empty source stops
aptl lab startin the backend preflight, before realization creates or changes any scenario network, volume or container. Logs, errors, argv and run evidence name each source, never a value or a digest of one.Generic path: base-container nodes (1130ec8..f04ed25, formerly #1262)
_environment_bindings.py(new) does the binding._realization_preflightinrealize()runs it first. It reads each grant source once, and warns about a grant for the admitted pack that matches no value-less secret._credential_sources.py(new) holdsEnvironmentGrantand the source models.ProcessEnvironmentCredentialSourcemoves there fromconfig.py, which keeps that file under Sonar's length limit, andaptl.core.configstill exports it. The config provenance projection is an allowlist, so grants stay out of run provenance.get_backend()passes the grants to both Compose backends.BaseContainerSpecgainsenvironment_sourcedandenvironment_generated._compose_base_substrate.pywrites the bindings through fix: write generated environment files safely #1256's writer. When a node binds at least one variable, its container gets anaptl.environment.nameslabel. A container is reused only when that label lists exactly the bound names and itsConfig.Envholds each bound value.Compose path: generated services (57a1c02..d9feadf)
A generated Compose service took each value-less
operator_secretorsecret_fixturethrough a${NAME}reference, which Compose resolves, like bareenvironmentandbuild.argsentries, from its own process environment before the--env-filethat APTL binds (#874). APTL ran everydocker composecommand with its whole environment, and one invocation shares that environment across every service and Compose file of the project._compose_environment.py(new) holds the Compose side, including the generic preflight mixin. The preflight binds every Compose service that APTL generates, before any mutation, with the same rules. A value that a single-quoted Compose env-file line (fix: write generated environment files safely #1256) cannot carry, one that contains a single quote or ends in an odd number of backslashes, stops the start like a missing source..aptl/realization/sourced-environment/<sha256(service)[:20]>.env, written through fix: write generated environment files safely #1256's no-follow writer, and a generatedcompose.override.ymlattaches it to that service alone. A value reaches only the service that declares it. Files that a request does not use are removed. A Compose file set assembled without the preflight refuses a service that needs a grant.Compose service db environment: DB_PASSWORD from grant:process-environment:LAB_DB_PASSWORD.docker_compose.py:_run,_run_streamingand_run_with_inputgivedocker composea bounded environment: the Docker client's own variables (toolchain, locale, TLS trust, temporary,XDG_*, credential-helper paths, Windows profile and system directories,DOCKER_*, Compose client options, proxies), theAPTL_*settings includingAPTL_HP_*, andBUILDKIT_*andBUILDX_*.COMPOSE_FILE,COMPOSE_PROFILESandCOMPOSE_PROJECT_NAMEstay out. No bound value is added, and a scenario variable namedHTTPS_PROXYleaves the client's proxy alone. The SSH backend keeps itsDOCKER_HOST.$from that text goes throughcompose_literal()in_compose_runtime_config.py, which writes each$as$$: the generated base model, its port, stateful, content and image overrides, and the Kali capture model. A command, entrypoint, environment value, mount path, content destination, pack file name, image reference or host address therefore reaches the container as written. The pre-start model check readsconfig --no-interpolateback throughcompose_readback()._compose_runtime_config.py:_environment_configderives fromdeclared_environment()and holds authored values only._compose_observability.py: the Grafana credential check reads.envonly.scenario_bundle.py:env_pack_bundle()refuses withEnvPackErroran env-pack whose root holdsdocker-compose.yml, which APTL used unchanged as the base model, so its services skipped the preflight; the released TechVault packs ship none. A project-tree scenario's owndocker-compose.yml, the development path, is used as written, and docs/deployment.md says to run only a trusted scenario from the project tree.Evidence: no value or digest of a granted or adapter value (75fa8fb)
After start,
_raes_runtime_environment_observation.pyread each value-lesssecret_fixtureoff the container, and RAES disclosed it as an unsalted SHA-256 commitment over the variable name and the value. That commitment reached the runtime snapshot and the REP-001 run record, where a guessed value could be checked against it offline. For the released TechVault pack, these were the six WazuhINDEXER_*,API_*andDASHBOARD_*credentials onwazuh-managerandwazuh-dashboard.observe_environmentnow never reads a value-lesssecret_fixturewhose value comes from a grant or the adapter (the names thatdeclared_environment()marks as sourced) and leaves it out of the disclosed environment. A value-lessoperator_secretorredactedvariable is still disclosed by presence only, and a value that the scenario authors keeps RAES's commitment. RAES's_PROTECTEDset is unchanged.TechVault's environment authority is open, so its readback gate passes as before. Under exact authority, RAES expects a value-less
secret_fixtureto be realized empty, so the gate rejects a grant or adapter value after the start. It already did so when the readback disclosed the commitment. #1332 tracks that case.docs
docs/deployment.mdgains a "Scenario Environment Grants" section: which variables take a grant, the grant format, the per-service env files and the values they cannot carry, the bounded Compose process environment, literal scenario and pack text, the project-tree path, and what logs and evidence record. The three new modules and the new test module have tracked-file inventory rows.How each done-when item of #965 is met
_environment_bindings.pyfor base containers,_compose_environment.pyand the boundeddocker composeenvironment for Compose services.value_fromfilter, and per-service env files.describe_bindings()logs, values reach Docker only through owner-only env files and never through argv, and the readback above.Breaking change for operators
A shell variable or same-named
.envkey no longer reaches a scenario node or Compose.operator_secret,redactedorsecret_fixtureneeds a grant or a value from the pack's own startup adapter. Otherwiseaptl lab startstops in the backend preflight, before realization creates or changes any scenario network, volume or container. Steps that run before realization, such as Suricata volume seeding, may already have run. The error names the node and the variable, and either the pack or the source that has no value..envvalue such asINDEXER_PASSWORDorGRAFANA_ADMIN_PASSWORDby exporting it beforeaptl lab start, set it in.envinstead.${NAME}in scenario or pack text of a generated Compose service now reaches the container as written. A pack that relied on Compose filling it must declare the variable and bind it.APTL_*settings still work from the environment.The released TechVault pack (0.1.1) and its participant-study variant declare no base-container environment. They bind all seven of their value-less Compose variables through their own adapter, so their start needs no grant. The only
$in their generated base model and its port and stateful overrides is the container-shell"$@"inmisp-redis's entrypoint, which Compose passed through before and still does. The title's!and theBREAKING CHANGE:footers of 1130ec8 and 57a1c02 mark this break. The squash message keeps the commit messages, so drop them if you don't want release-please to cut a major release.Verification
The branch is rebased onto dev 1abe440.
git range-diffshows all eleven earlier patches unchanged and no conflicts: 04c5133, 33037ef, bf15b7c, 8d8185c and 717cef6 (#1262's head) are now 1130ec8, a7db21a, d4343e4, 370d803 and f04ed25. 331782b, 06762d9, 389b9dc, a7ac44d, 6d18d92 and 6fc6706 are now 57a1c02, 5078bb4, 063190c, 0b9706c, 8f986a4 and d9feadf. 75fa8fb is new. Run from the worktree with Python 3.12 andrequirements/dev.txt, as CI installs it.tests/test_raes_runtime_observation.py::test_environment_sourced_secret_fixture_leaves_no_value_or_digest(open and exact). A container holds a canary in a value-lesssecret_fixturebeside an authoredplainvariable. The disclosure lists only the authored variable, and neither the canary nor the hex of RAES's commitment of it appears inruntime_snapshot_record_payload()of the snapshot. The open gate passes, and the exact gate rejects, as before.tests/test_compose_environment_bindings.py::test_the_released_techvault_readback_records_no_adapter_value_or_digest. The released TechVault realization, read back with adapter values in place, discloses none of the six Wazuh names, discloses MISP'sADMIN_KEYby presence with no commitment, and its remaining commitments are exactly the ones that RAES projects from the scenario's own values._raes_runtime_environment_observation.pyreset to d9feadf, both tests fail. The disclosure lists['FIXTURE', 'FOO'], and the TechVault readback carries a commitment forAPI_PASSWORDand six more. With the name assertion disabled, the commitment's hex is found in the run record payload.-k "not integration"and-n 8: 829 passed. They are the test modules this PR changes and every module that imports the observation, binding, Compose-environment, provisioner or repro code or the TechVault realization helpers.-m integrationontests/test_raes_runtime_observation.py,tests/test_compose_environment_bindings.pyandtests/test_techvault_static_gate.py(the RAES scenario validation gate's tests): 2 passed. The first two have no integration-marked tests, and none of the three has fuzz tests. The full suite was not run locally; CI's Python lanes run it.ruff check src/(the C901 gate) passed, and so didpre-commit run --fileswith both.pre-commit-config.yamland.pre-commit-ci.yamlon the four files 75fa8fb changes. Vale 3.15.2 ondocs/deployment.md: no alerts.mkdocs build --strictwas not run locally; CI's Docs job runs it.tests/test_raes_runtime_environment.py,tests/test_compose_base_substrate.py,tests/test_config.py): a grant binds nothing for another node, another pack or a project-tree run; planted process and.envvalues do not override authored ones; a missing binding is refused byrealize()after zero Docker commands; a changed source recreates the container; a source that has gone missing fails before any inspect or removal; no secret value reaches process argv.tests/test_compose_environment_bindings.py,tests/test_capture_apparatus.py,tests/test_scenario_bundle.py): same-namedGITHUB_TOKENandINDEXER_PASSWORDcanaries never reach adocker composeprocess; a grant fordblands only indb's 0600 env file; missing binding, wrong consumer, pack identity, an empty adapter value and values an env file cannot carry each stoprealize()after zero Docker commands with no value in the error; consecutive starts write the grant's current value; a reused backend never carries an earlier request's value; every generated file has only escaped$, checked throughdocker compose configwith.envand process canaries; a pack with a rootdocker-compose.ymlis refused.docker compose configrendered the canary command with the two.envvalues, the client proxy and anAPTL_*value..envvalues, and the file-set and capture tests found a live$.scenario_bundle.pyfrom before it, the root Compose file test fails (DID NOT RAISE EnvPackError)._realization_primitives.pyfrom before fix: write generated environment files safely #1256's fa630b7, the value that ends in a backslash passes the preflight and the start runs Docker commands.value_fromcases fail when the preflight's consumer filter is removed, or when a missing generated output is delivered empty.Python tests + coverage(6541 passed, 72 skipped, 241 deselected, 51 passed in the fuzz run, and 2 passed in the env-file readback step), DEP-008, the RAES scenario validation gate, Docs, the dependency and Trivy scans, and the SonarCloud quality gate with its new-issue step. CodeQL run 38213176754, PR title lint and GitGuardian pass as well; all 45 checks on the PR pass.Requirement UIDs: none (bug/maintenance).
Closes #965