v1.6 — Pentest Remediation
Document ID: RM-PT-001
File Path: docs/18-roadmap/v1.6-pentest-remediation.md
Version: 2.0.0
Status: Complete (2026-07-28)
Owner: Engineering (Workflow Runtime / Tool Sandbox / Generative UI)
Last Updated: 2026-07-28
1. Purpose
Section titled “1. Purpose”Remediate the findings of an internal red-team / abuse-resistance assessment (2026-07-27) — an authorized engagement against the platform’s own CLI/workflow execution, tool sandbox, multi-agent orchestration, UI-frame protocol, secrets/KMS/audit, and server auth floor.
Overall verdict: STRONG. 541 security-invariant tests passed (0 failed); 20 dynamic CLI abuse cases were run against the real binary, 18/20 failing closed as designed; 0 Critical and 0 High findings. This milestone covers the 4 items that were not a clean PASS. All four are now done:
| Finding | Severity | Status |
|---|---|---|
RES-601 — for_each fan-out had no aggregate per-run cost/token ceiling | Medium | Done |
SBX-305 — privileged shell/fs_write/code_execute available under --local with no explicit per-run opt-in | Medium | Done |
GUI-501 — ui_present’s tool schema did not teach the model the frame protocol | Medium (product gap) | Done |
| VAL-401 — agent manifest’s unknown-field tolerance was undocumented | Low | Done |
Everything else assessed (16 further cases) was a verified PASS with no action item.
Like v1.4 and v1.5, this milestone has no PRD, deliberately: the findings are the requirements, and none changes product scope or needs an architectural decision. Requirement IDs are defined in §3 below.
Reproduction detail is deliberately omitted from this public document. The assessment’s own inputs and step-by-step reproduction commands are held internally. Each ticket below states the defect, the fix, and the tests that now prevent regression — which is what a reader auditing this repo needs — without publishing a working recipe against older releases. Operators running a pre-0.3.1 build should upgrade; see §4.
2. Tickets
Section titled “2. Tickets”RES-601 [P1] — for_each fan-out had no aggregate cost/token ceiling — DONE
Section titled “RES-601 [P1] — for_each fan-out had no aggregate cost/token ceiling — DONE”Problem. for_each’s max_items (default 1000) bounds item count only. Each
item can itself be a full agent activity running its own model + tool loop, and
nothing capped the total tokens/cost across one for_each’s items. The server-side
per-project budget (tenancy::admit_run/record_run_usage, SRV-202/203) does not
close this either: it is a daily rate keyed by X-Wovyr-Project, not a
per-execution ceiling, and it does not apply at all to CLI --local runs (no
AgentResolver::admit hook exists on that path). So a single workflow submission —
local or remote, and even one landing entirely inside one day’s server-side budget —
could fan out into an unbounded number of billable model calls within one execution.
The assessment confirmed this concretely with a large fan-out of agent-spawning items
running to completion.
Change (landed 2026-07-28). for_each/map accept two optional aggregate
budgets, inputs.max_total_cost_usd and inputs.max_total_tokens
(crates/wovyr-workflow/src/definition.rs), validated fail-closed at definition load
the same way max_items/max_concurrent are — 0, negative, or non-finite is a load
error, never a silent “unlimited”.
Enforcement (crates/wovyr-workflow/src/engine.rs) accumulates usage as each item
lands, before the next is launched — the only point where stopping saves anything.
Crossing a ceiling stops launching further items and fails the activity closed;
instances already in flight still finish and commit durably, preserving WFL-301/302’s
per-item guarantee. Omitting both fields is behavior-identical to before.
The engine cannot obtain usage itself — wovyr-workflow deliberately does not depend
on the LLM gateway, keeping the core DAG/checkpoint engine free of the provider stack.
So PlatformActivityExecutor reports it: ai and agent outputs now carry a reserved
__usage key (wovyr_workflow::USAGE_OUTPUT_KEY, {cost_usd, total_tokens}) that
ActivityUsage::from_output reads back. Additive — ${activity.message} references
are unaffected — and activities that report nothing contribute zero, so tool,
function, and human steps need no changes.
Proven by four tests in crates/wovyr-workflow/tests/engine.rs: a 60-item fan-out
with a cost cap only three items’ worth can cross fails closed and is asserted to
have actually executed fewer than 10 of the 60 (the property that saves money, not
merely the error message); the token ceiling trips independently at cost_usd: 0.0, so
a cost-only implementation would not pass; omitting both budgets still runs and joins
all 60 items; and a 0/negative/non-finite budget is rejected at load.
Also documented: workflow-dsl §13
now specifies the real for_each surface (§13’s previous loop: {while, until, foreach} block described syntax that never existed in code), with §13.1 covering the
ceilings and §13.2 stating plainly, in a table, that a for_each ceiling is the only
per-execution budget that applies under --local.
Files. crates/wovyr-workflow/src/definition.rs,
crates/wovyr-workflow/src/engine.rs, crates/wovyr-workflow/src/lib.rs,
crates/wovyr-runtime/src/lib.rs, crates/wovyr-workflow/tests/engine.rs,
docs/03-workflow-engine/workflow-dsl.md.
Size. M. Depends on: none.
SBX-305 [P1] — privileged native builtins had no explicit per-run opt-in under --local — DONE
Section titled “SBX-305 [P1] — privileged native builtins had no explicit per-run opt-in under --local — DONE”Problem. wovyr agents run --local and wovyr workflows run --local
unconditionally called ToolRegistry::with_privileged_builtins(), registering
shell/fs_write/code_execute with no operator signal beyond having chosen
--local — the code treated “ran with --local” as the acknowledgement itself. That
is materially weaker than SEC-404’s confinement-floor logic, which requires an explicit
acknowledgement (or a real sandbox) before a hosted run gets unsandboxed native
access. The assessment demonstrated the consequence: a real model, driven only by an
agent manifest listing the shell tool, read a host file outside the run’s working
directory, with nothing stopping it beyond a WARN log line.
On a genuinely single-operator trusted workstation this is the documented, accepted
design (SEC-301). The gap was that nothing distinguished that case from --local
being invoked on a shared, CI, or multi-tenant host, where the identical command
silently granted full host access to whatever the model decided to do.
Change (landed 2026-07-28). Both paths now require an explicit signal —
--allow-privileged-tools, or WOVYR_LOCAL_PRIVILEGED=1 for a whole session (which is
also what the workflows approve/signal/tick resume paths honor, since they take
no flag of their own). Absent it, a --local run gets only the safe builtins
(echo/fs_read/http_get) — the same set the hosted server defaults to.
Fail-closed, not fail-quiet: a manifest or definition that names a privileged tool is
rejected before anything runs, with an error naming the flag. Without that check the run
would still fail (resolve_tools rejects a tool missing from the registry) but with a
bare “unknown tool” message that reads like a typo and gives no hint an opt-in exists.
The workflow-side check inspects for_each/map bodies too, since a fan-out’s per-item
activity template is where a privileged tool would otherwise hide — and treats
function like tool, because PlatformActivityExecutor dispatches both through
ToolRegistry::execute.
Enabling the flag does not weaken SEC-404: an enabled shell call still goes through
the same native confinement floor and still logs the same loud warning.
Proven by six tests in apps/wovyr-cli/tests/privileged_tools_gate.rs, which drive
the real wovyr binary against a scratch HOME (the cross_process_kms.rs
pattern) rather than an in-process reimplementation: the deny path asserts the error
names both the gate and the flag; the flag and the env var each satisfy it; a
non-privileged manifest is unaffected (the gate must not become a blanket “no tools
locally” regression); and the workflow path is covered for both a direct tool
activity and a shell hidden inside a for_each body. Verified live against the real
CLI in addition to the automated tests.
Files. apps/wovyr-cli/src/main.rs, apps/wovyr-cli/src/workflow.rs,
crates/wovyr-workflow/src/lib.rs (exports is_for_each),
apps/wovyr-cli/tests/privileged_tools_gate.rs,
docs/07-tool-runtime/security-isolation.md (new §5.2).
Size. S. Depends on: none.
GUI-501 [P1] — real models could not emit protocol-valid UiFrames — DONE
Section titled “GUI-501 [P1] — real models could not emit protocol-valid UiFrames — DONE”Problem. UiPresentTool::input_schema() declared its frame parameter as a bare
{"type": "object"} with only a prose description — no JSON Schema constraining it to
the real UiFrame/UiNode component vocabulary. The assessment’s adversarial test
against a real model confirmed the direct consequence: every generated frame used
non-protocol node types or invalid shapes, and every one failed UiFrame::from_value’s
fail-closed parse. The trust layer’s behavior was exactly correct — reject, never
silently coerce — but as shipped, no real model could drive ui_present at all, which
is the generative-UI runtime’s core “agent renders UI” path
(PRD-005/v1.2).
Done (2026-07-27). UiPresentTool::input_schema()
(crates/wovyr-tools/src/ui_present.rs) now derives the frame property from
schemars::schema_for!(wovyr_ui::UiFrame) (UiFrame already derived JsonSchema; no
wovyr-ui change needed) instead of the hand-written stub. schemars hoists the
recursive UiNode vocabulary into a $defs map at its own schema root and points to it
via local $refs; since those resolve by JSON Pointer against the whole document root
regardless of nesting depth, ui_frame_tool_schema() hoists $defs a second time, up
to the tool’s own schema root, so the refs keep resolving once the frame schema is
embedded under properties.frame. Also added ToolMetadata::strict/with_strict
(crates/wovyr-tools/src/tool.rs) and wired wovyr-agent’s resolve_tools to forward
it into ToolSpec::strict (previously hardcoded false for every tool) — ui_present’s
metadata now opts in, so a resolved OpenAI/Anthropic provider applies PRV-202/203’s real
schema-constrained/strict tool-calling to it specifically, not just a schema shown in
the prompt. No change to UiFrame::from_value’s parsing/validation or to
wovyr-ui-guard’s trust layer.
Proven by four tests in ui_present.rs: the derived schema hoists $defs and
requires action on the button variant (replacing the old stub’s bare
{"type": "object"}); a small hand-rolled JSON-Schema-subset evaluator (scoped to
exactly the keywords this schema emits — not a general validator, to avoid a new
dependency) confirms a real frame validates and the assessment’s concrete invalid shape
does not; and metadata().strict is asserted true. No live real-model re-run was done
(no key available in that environment) — the “reaches the model, not just exists in
Rust” criterion is satisfied via the existing, already CI-proven path: resolve_tools
feeds ToolSpec.parameters/.strict directly into wovyr-provider’s
OpenAI/Anthropic wire translation (openai.rs/anthropic.rs’s
strict_tool_emits_strict_flag_and_normalized_* tests), which is exactly the code path
this fix now exercises for ui_present.
Files. crates/wovyr-tools/src/ui_present.rs, crates/wovyr-tools/src/tool.rs,
crates/wovyr-agent/src/runtime.rs.
Size. M. Depends on: none.
VAL-401 [P2] — agent manifest’s unknown-field tolerance was undocumented — DONE
Section titled “VAL-401 [P2] — agent manifest’s unknown-field tolerance was undocumented — DONE”Problem. AgentDefinition deliberately omits #[serde(deny_unknown_fields)] so
richer manifests from the full spec still load — a real, intentional design decision.
The assessment confirmed the consequence: a manifest carrying an attacker-added field
parses and runs with that field silently ignored. Every other YAML DSL in this codebase
(UiFrame/UiNode, McpConnection, UiPolicy, the workflow DSL) rejects unknown
fields fail-closed. The agent manifest is the one exception, and that asymmetry was not
stated anywhere a reader auditing “does X reject unknown fields?” would find without
reading source.
Done (2026-07-28). No behavior change — the tolerance is intentional and existing
manifests/tests rely on it. docs/04-agent-framework/agent-definition.md gained a new
§5.1 stating the tolerance and its rationale, with a table contrasting it against
the four deny-by-default DSLs, and — the part that actually matters for a reader — a
statement that the manifest is therefore not a place to detect tampering, pointing
at the surfaces where authorization is genuinely enforced (SEC-303’s deny-all registry
default, SBX-305’s local opt-in, the per-connection MCP allow-list).
Files. docs/04-agent-framework/agent-definition.md.
Size. S. Depends on: none.
3. Verification
Section titled “3. Verification”Each ticket’s tests above are the regression requirement (house convention: a fix ships
with a test that fails against pre-fix code), and all of them run in the ordinary
cargo test --workspace — none is capability-gated on external infrastructure. Full
workspace cargo build / cargo test / cargo clippy -D warnings / cargo fmt --check are green as of 2026-07-28.
No ticket in this milestone required re-running the full assessment.
4. Operator guidance
Section titled “4. Operator guidance”The two Medium findings both concerned local CLI execution, not the hosted server’s network-facing surface:
- If you run
wovyr agents run --localorwovyr workflows run --localon a shared, CI, or multi-tenant host, upgrade to a build containing SBX-305 and do not setWOVYR_LOCAL_PRIVILEGED=1in a shared environment. On such hosts, prefer trust classification so tool calls select a container/gVisor backend — see security-isolation §3 and §5.2. - If you run
for_each/mapfan-outs whose per-item body performs model work, setmax_total_cost_usdand/ormax_total_tokens. Note that no per-execution budget applies otherwise, and that no project-level quota applies to--localruns at all — see workflow-dsl §13.2.