Skip to content

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


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:

FindingSeverityStatus
RES-601 — for_each fan-out had no aggregate per-run cost/token ceilingMediumDone
SBX-305 — privileged shell/fs_write/code_execute available under --local with no explicit per-run opt-inMediumDone
GUI-501 — ui_present’s tool schema did not teach the model the frame protocolMedium (product gap)Done
VAL-401 — agent manifest’s unknown-field tolerance was undocumentedLowDone

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.


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 --localDONE

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.


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.


The two Medium findings both concerned local CLI execution, not the hosted server’s network-facing surface:

  • If you run wovyr agents run --local or wovyr workflows run --local on a shared, CI, or multi-tenant host, upgrade to a build containing SBX-305 and do not set WOVYR_LOCAL_PRIVILEGED=1 in 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/map fan-outs whose per-item body performs model work, set max_total_cost_usd and/or max_total_tokens. Note that no per-execution budget applies otherwise, and that no project-level quota applies to --local runs at all — see workflow-dsl §13.2.