Skip to content

P1: trust boundary: decide what gets scrubbed, gated, escaped, and confined before untrusted text reaches a shell or a page #615

Description

@macanderson

What decision this asks for

Each item below is a single ruling: how much authority do we let a model-driven subprocess, an untrusted server, or an untrusted string keep once it crosses a boundary? The fixes are already written or fully specified — what is missing is the call, because on every one of these surfaces tightening breaks a legitimate workflow (a Django dev server that needs DJANGO_SECRET_KEY, a monorepo that legitimately declares ../ members, an SVG attribute value that happens to contain word:) and loosening reopens the hole.

Decide strict-vs-permissive per surface, once, and let the doc wording follow from the choice. Two items are load-bearing and should be answered first: item 1 (run_tests interpolates a model-supplied filter straight into bash -c — a real security finding) and items 8–10 (the env-scrub suffix policy, where two of the proposals contradict each other about whether to widen at all).

Spot-checked against origin/main at the time of filing: the run_tests filter interpolation (stella-tools/src/project.rs:236,257), the SVG references_external //-substring test (stella-media/src/svg.rs:344-346), the is_sensitive_env_name suffix list (_API_KEY/_TOKEN/_PASSWORD/_SECRET only — STRIPE_SECRET_KEY still passes), expand_member_pattern's unconfined root.join(base) (stella-tools/src/scripts.rs:1115), the "rs" => recognized_dir || name.contains("_test.") witness arm (stella-pipeline/src/witness.rs:509), the .ok().flatten() manifest call sites, and the absence of headless_scope_bypass from the settings overlay take! list all reproduce as described.

Items

Shell / subprocess authority

Environment scrub policy (these three overlap on one const and two of them contradict each other — answer as one ruling)

Untrusted text reaching a page

Serve / observatory HTTP posture (one dial: how strict is the private sidecar?)

Gates that silently do nothing

  • Surface the swallowed StorageManifest::load error. All four call sites discard the parse error with .ok().flatten(), so a single TOML typo silently drops every layer declaration, boundary, intent sentence and redirect, and the pre-write gate stops enforcing configured boundaries with no message anywhere. Deferred because both offered routes are closed: the Fix disclaims the first (adding a field to the public IndexStats struct is a public API change), and the second is unavailable (tracing is not a workspace dependency and appears nowhere in stella-graph, so a tracing::warn! means adding a dependency to satisfy a cleanup ticket). stella-graph/src/manifest.rs:101, call sites store.rs:167, store.rs:200, graph.rs:379, lib.rs:86 — severity medium. (audit Batched audit cleanup: area:graph — 15 items #563, deferred by perf(stella-graph): bound the audit's unbounded loops, scans, and walks #600) — half done. The graph.rs / lib.rs call sites landed in perf/reliability/accuracy: eight audit-backlog fixes, reconciled onto main's new durability contract #703: assembly stays best-effort, but the reason now rides on StorageSnapshot::manifest_error and is reported by stella storage and at gate seeding. The two store.rs call sites (now store.rs:228 and store.rs:261) are still .ok().flatten(), so this row stays open.
  • Add take!(headless_scope_bypass) to the settings overlay macro list, together with a restore_project_headless_bypass gate. The field is confirmed absent from the overlay list, so the setting is genuinely dead — but applying half of it opens a repo-controlled scope-review bypass, and choosing the gate (trust.hooks vs authority.project_prompts_allowed) is a security-policy decision. stella-cli/src/settings.rs:434 — severity medium. (audit Batched audit cleanup: area:cli — 30 items #557, deferred by chore(stella-cli): apply the area:cli audit batch (14 of 30 items) #597)
  • Invert prove_process_free_surface's deny-list into an allowlist — assert registry.schemas() is a subset of an explicit process-free allowlist so any newly added tool fails closed by default. The landed PR only replaced the bogus deny-list names and pinned them with a test. Deferred because the inversion changes the proof's failure mode for every future tool and is a maintainer's call. stella-cli/src/enterprise_telemetry.rs:411, stella-cli/src/enterprise_telemetry_tests.rs — severity medium. (audit Batched audit cleanup: area:cli — 30 items #557, deferred by chore(stella-cli): apply the area:cli audit batch (14 of 30 items) #597)
  • Tighten is_witness_test_path. It accepts any .rs file whose name contains _test. regardless of directory, so src/backdoor_test.rs passes as a witness artifact even though cargo can only run integration tests from tests/. validate_witness_invocation's required cargo test --test <stem> then fails forever, the flip never happens, the run burns its full revision budget, and a judge may still PASS on the diff — so the stray file is adopted into the user's real tree. The fix requires recognized_dir outright for the "rs" arm, dropping the bare name.contains("_test.") fallback. Deferred because it tightens an acceptance predicate (a behavior change) and the existing test table needs a new case; what counts as an acceptable witness artifact is a safety-relevant contract. stella-pipeline/src/witness.rs:509 — severity medium. (audit Batched audit cleanup: area:pipeline — 15 items #567, deferred by test(pipeline): name the work-item test modules for what they pin #594) — landed in fix(security): scrub ambient authority, close the SVG data: gap, confine workspace members #695: the "rs" arm now requires recognized_dir and the bare name.contains("_test.") fallback is gone; other languages keep their filename forms (their runners genuinely collect by filename), pinned by a per-language shape table, and witness_prompt now tells the author role where Rust tests must live.

Why these are together

Every item is the same shape of decision made on a different surface: something untrusted — a model-supplied string, a repo-supplied config value, an agent-chosen label, an inherited environment variable, a caller on loopback — is about to cross into a shell, a subprocess environment, a filesystem walk, a DOM, or an acceptance gate, and someone has to rule on how much authority it keeps. The engineering is done or specified in each case; what blocks them is that the strict answer breaks a real workflow and the permissive answer leaves the hole open. Batching them lets the posture be set coherently in one pass instead of drifting per-PR, and the env-scrub items in particular cannot be resolved independently because two of them propose contradictory widths on the same constant.

Deferred during the #546 audit remediation. Put Closes #<this issue> in the PR description and as a commit trailer when it lands.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1Important — next in linearea:toolsstella-tools — built-in tools incl. verify_done

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions