Problem
CliProfile.batteries_included() discovers project configuration by walking from the current
directory to the filesystem root and loading the first .base-cli.yaml it finds
(_conventional_project_discovery(), lib/python/base_cli/profile.py:264-277):
current = cwd.expanduser().resolve()
for directory in (current, *current.parents):
candidate = directory / config_name
if candidate.is_file():
return ProjectInfo(root=directory, manifest=candidate, name=directory.name)
There is no ownership check, no permission check, no project boundary (.git, a marker file), and
no depth limit. The discovered file then supplies validated framework settings — environment,
log_level, keep_temp — plus arbitrary consumer configuration, through
BatteriesIncludedConfigLoader.load().
For the operations/CI audience this is the exposed case: a CLI run inside an untrusted PR checkout,
a shared build directory, a multi-tenant agent workspace, or anywhere under a world-writable
ancestor will silently adopt configuration that the invoking user does not control and cannot see.
keep_temp: true alone means diagnostics an operator expected to be erased are retained on disk.
Scope note, stated fairly: the default CliProfile.generic() discovers no project and is not
affected. This is specific to the opt-in convenience profile — which is precisely the one
docs/local-config.md steers new adopters toward.
Verified evidence
Reviewed 2026-09-30 at a58ec109349fa3f3d03eae5b0de078b39ea361a2 (macOS, Python 3.14.6).
A world-writable ancestor (mode 0777) with a 0666 .base-cli.yaml, and a cwd three levels below it:
world-writable ancestor: /var/folders/.../shared-u_kr2eao mode=0o777
cwd for the run : /var/folders/.../shared-u_kr2eao/team/repo/subdir
environment = 'attacker-controlled'
keep_temp = True
debug = True
config = {'injected_setting': 'pwned'}
provenance = {'keep_temp': 'project', 'log_level': 'project',
'environment': 'project', 'injected_setting': 'project'}
discovered manifest = /private/var/folders/.../shared-u_kr2eao/.base-cli.yaml
run result: 0
The layering machinery behaves correctly and provenance attributes every key to project, which is
good — the value is attributable after the fact. What is missing is a gate before the file is trusted.
Related: load_yaml_file() uses yaml.safe_load() (correct — no arbitrary object construction) but
imposes no size or alias-expansion limit, so a repository-controlled config file is also an
unbounded-expansion vector.
Proposal
- Add a trust gate before a discovered project config is loaded, on by default for
batteries_included:
- refuse a config file, or any ancestor directory on the discovery path, that is writable by
group or other, or not owned by the invoking user or root (POSIX); document the Windows
equivalent or state that it is not enforced;
- report the refusal as an actionable
ConfigurationError naming the offending path, not a
silent skip.
- Bound discovery: stop at a project boundary marker and/or a maximum ancestor depth, and never
cross a filesystem boundary. Make the boundary configurable.
- Provide an explicit opt-out (
trust_discovered_config=False / ...=True) for CI that knowingly
runs under shared ownership, so the decision is recorded in code.
- Bound
load_yaml_file() input size and reject alias-expansion blowup.
- Document the trust boundary in
docs/security-threat-model.md and docs/local-config.md.
Acceptance criteria
- Ancestor discovery refuses group/other-writable config files and paths by default, with a clear error.
- Discovery depth and boundary behaviour are documented and testable.
- The opt-out exists, is explicit, and is documented as a security decision.
CliProfile.generic() behaviour is unchanged.
docs/security-threat-model.md contains an entry for repository-controlled configuration.
Non-goals
- Do not add signing or an allowlist of config files.
- Do not change the merge precedence or provenance model.
Problem
CliProfile.batteries_included()discovers project configuration by walking from the currentdirectory to the filesystem root and loading the first
.base-cli.yamlit finds(
_conventional_project_discovery(),lib/python/base_cli/profile.py:264-277):There is no ownership check, no permission check, no project boundary (
.git, a marker file), andno depth limit. The discovered file then supplies validated framework settings —
environment,log_level,keep_temp— plus arbitrary consumer configuration, throughBatteriesIncludedConfigLoader.load().For the operations/CI audience this is the exposed case: a CLI run inside an untrusted PR checkout,
a shared build directory, a multi-tenant agent workspace, or anywhere under a world-writable
ancestor will silently adopt configuration that the invoking user does not control and cannot see.
keep_temp: truealone means diagnostics an operator expected to be erased are retained on disk.Scope note, stated fairly: the default
CliProfile.generic()discovers no project and is notaffected. This is specific to the opt-in convenience profile — which is precisely the one
docs/local-config.mdsteers new adopters toward.Verified evidence
Reviewed 2026-09-30 at
a58ec109349fa3f3d03eae5b0de078b39ea361a2(macOS, Python 3.14.6).A world-writable ancestor (mode 0777) with a 0666
.base-cli.yaml, and a cwd three levels below it:The layering machinery behaves correctly and provenance attributes every key to
project, which isgood — the value is attributable after the fact. What is missing is a gate before the file is trusted.
Related:
load_yaml_file()usesyaml.safe_load()(correct — no arbitrary object construction) butimposes no size or alias-expansion limit, so a repository-controlled config file is also an
unbounded-expansion vector.
Proposal
batteries_included:group or other, or not owned by the invoking user or root (POSIX); document the Windows
equivalent or state that it is not enforced;
ConfigurationErrornaming the offending path, not asilent skip.
cross a filesystem boundary. Make the boundary configurable.
trust_discovered_config=False/...=True) for CI that knowinglyruns under shared ownership, so the decision is recorded in code.
load_yaml_file()input size and reject alias-expansion blowup.docs/security-threat-model.mdanddocs/local-config.md.Acceptance criteria
CliProfile.generic()behaviour is unchanged.docs/security-threat-model.mdcontains an entry for repository-controlled configuration.Non-goals