Skip to content

feat!: opt-in resolved-value caps and evaluation-budget plumbing - #399

Merged
mwiebe merged 9 commits into
OpenJobDescription:mainfrom
mwiebe:feat/resolved-value-caller-limits
Sep 17, 2026
Merged

mwiebe merged 9 commits into
OpenJobDescription:mainfrom
mwiebe:feat/resolved-value-caller-limits

Conversation

@mwiebe

@mwiebe mwiebe commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

What was the problem/requirement? (What/Why)

A template argument like "{{ 'A' * 10000000 }}" expands to a 10 MB
string when the job runs. The spec deliberately sets no size limit
on action commands (§5.1), arguments (§5.2), or embedded-file contents
(§6.1.2), so such a template passes openjd check and job submission,
then degrades the worker host — an opaque spawn failure (E2BIG), or a
giant file on disk.

Two knobs were missing: a way for a caller to opt into limits on
these fields (a default-on cap would reject spec-valid templates), and
a way to configure the expression evaluation budgets — the engine's
existing 100 MB / 10M-operation bounds were hard-wired at every call
site.

What was the solution? (How)

Both knobs join the CallerLimits policy struct in openjd-model
(all default None = nothing beyond the spec) and are enforced at all
three processing stages:

  • Template validation: each capped field is checked against a
    guaranteed lower bound on the length of every string it could
    resolve to — literal text plus already-computable expression parts,
    with unknowns (e.g. worker-host variables) counting as zero. A bound
    over the cap means no run could ever conform, so check rejects it
    with a normal field-path error. Fully-static values get exact checks,
    per-element for lists that expand into multiple arguments.
  • Job creation: the same checks with real parameter values bound.
  • Session runtime (authoritative — a worker may run a job that
    never went through this client's validation): the final command,
    each final argv entry, and each embedded-file body are checked just
    before use, configured via a new SessionLimits on SessionConfig.

The budgets ride the same plumbing: FormatStringOptions gains
with_memory_limit/with_operation_limit, honored everywhere, and
validate_expressions now takes the same options object as resolution
— so validation can't check a different configuration than resolution
uses.

The openjd CLI enables the argument cap by default, at the host OS's
single-argument maximum (Linux 131,072; Windows 32,767; otherwise
1 MiB): the 10 MB example now fails at check. Library defaults stay
uncapped. Note the CLI cap describes the checking machine — a Windows
worker's smaller limit is still enforced by its own runtime.

Also fixed: decode_environment_template took no CallerLimits at
all, so environment templates bypassed caller policy at validation. It
now matches decode_job_template, and the JS/WASM bindings forward
the caller's limits instead of dropping them.

How was this change tested?

  • ~40 new tests across the four crates, asserting full error messages
    per the repo standard: lower-bound rejections (mixed known/unknown
    segments, list flattening per element), at/under-cap and no-cap
    controls, budget violations at validation and run time, the
    environment-template plumbing, and the CLI default (a 2,000,000-char
    argument fails check on every platform).
  • cargo test --workspace green, except two pre-existing
    openjd-sessions Windows cross-user failures that reproduce
    identically on origin/main (dev-machine environment, unrelated).
  • Full conformance suite on Windows — the tightest CLI cap:
    1139 passed, 0 failed. Clippy (-D warnings) and rustfmt clean.

Was this change documented?

Yes — all public items have doc comments, and the spec documents for
all five affected crates (specs/expr/, specs/model/,
specs/sessions/, specs/cli/, specs/openjd-for-js/) are updated in
the same commit: new API surfaces, the per-field constraint table, and
the CLI's default-cap policy.

Is this a breaking change?

Yes (pre-1.0 minor bumps; feat! with a BREAKING CHANGE footer):

  • openjd-expr: validate_expressions(symtab, lib, target_type) →
    validate_expressions(symtab, &options); move the library and target
    type onto FormatStringOptions, ideally sharing the options used for
    resolution.
  • openjd-model: decode_environment_template takes a third
    &CallerLimits argument; CallerLimits has four new fields (use
    ..Default::default()).
  • openjd-sessions: SessionConfig has a new limits field
    (limits: Default::default() for the old behavior).

Does this change impact security?

Hardening: enforceable, configurable bounds against resource
exhaustion from expression-generated values, plus closing the
environment-template limits bypass. No new files, processes, or
permissions; the checks don't allocate beyond what (already
budget-bounded) evaluation produces.


By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@mwiebe
mwiebe requested a review from a team as a code owner September 15, 2026 23:35
Comment thread crates/openjd-model/src/types.rs Outdated
Comment thread crates/openjd-cli/src/common.rs Outdated
Comment thread crates/openjd-sessions/src/limits.rs Outdated
@mwiebe
mwiebe force-pushed the feat/resolved-value-caller-limits branch from 97ddc37 to e680f82 Compare September 15, 2026 23:55
@leongdl

leongdl commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Reviewed at 97ddc37 against the earlier rounds on #373, #383 and #397. Everything below was run on the branch, not inferred; the probe outputs are verbatim. cargo clippy --all-features --all-targets --workspace -- -D warnings and cargo test --workspace are green here on macOS/aarch64.

None of these is a regression against main: before this PR there was no cap at all and the budgets were fixed at the spec defaults. All four are the same shape, which is that the protection is narrower than the doc comments, the spec files and the PR description say it is.

Three prior review conclusions land correctly and are worth naming. The duplicate-error bug from #383 (TextListItem reporting one violation twice) is pre-empted by the return after the bound error in ArgListItem. The list-flatten distinction is right: gating the whole-string bound on resolved_type == STRING is necessary because a list distributes its characters across argv entries, and I looked for a reachable gap in that gate and did not find one, since an unresolved root gives a zero bound anyway and a concrete root is handled by the resolved_value branch. And decode_environment_template taking CallerLimits closes a real bypass.

Spec grounding

None of the four is a conformance finding. Each measures the code against this PR's own claims: the new doc comments, the in-repo specs/ files, the PR description and the commit message. I have separately checked the premises they inherit against the canonical openjd-specifications wiki, and all four hold:

Premise the findings rest on Canonical source Verdict
§5.1/§5.2 set no maximum on command/args, and the OS imposes its own 2023-09-Template-Schemas.md §5.1 <CommandString>, §5.2 <ArgString> Confirmed, near-verbatim: "There is no maximum string length imposed by this specification. Note that the specific operating system that the command is run on will impose its own maximum length."
§6.1.2 sets no limit on embedded data §6.1.2 <DataString> Confirmed: "No length limit is imposed by this specification."
The budgets are spec-sanctioned configurable knobs at 100 MB and 10 M 2026-02-Expression-Language.md Design Constraints 2 and 3, §1.3.9, §1.3.10 Confirmed, and the spec names "a" * 10000000 as the motivating case
Three processing stages Template Schemas §7.4 Confirmed, and it also confirms command/args/data are @fmtstring[host], resolved at task execution

What that means per finding, since the four sit at different distances from the spec:

  • Finding 1 cannot be a conformance issue. §5.1/§5.2/§6.1.2 set no maximum, so the cap is caller policy and its only oracle is this PR's own claim, which is what I measured against.
  • Finding 2 is furthest from the spec. Every length limit in the Template Schemas is stated in characters, twenty of them, which is why feat!: enforce resolved-value constraints at template validation  #383's unification on characters was right for spec limits. These two caps are not spec limits, so they have no spec-mandated unit, and the byte and UTF-16 figures come from OS documentation rather than from OpenJD. I have not verified those OS numbers from primary sources, so treat the strnlen_user off-by-one in particular as unconfirmed.
  • Finding 3 has a spec hook, not only an internal one. Expression Language Design Constraint 2 says implementations "accept a configurable memory limit ... and track the memory size of live values during evaluation", and §7.4 puts job creation in scope as a stage that resolves format strings. openjd-rs accepts the limit on FormatStringOptions but does not honour a caller-supplied one there. Whether that breaches "accept a configurable limit" is arguable, since evaluation is still bounded at the default, so I am not claiming a conformance failure. §7.4 also independently confirms the nuance below: the two caps genuinely have no job-creation stage, because those fields resolve on the worker.
  • Finding 4 has no spec counterpart at all. Neither spec document contains any notion of a lower bound on a resolved length. StaticResolution and min_resolved_string_len are openjd-rs constructs introduced in feat(expr)!: return StaticResolution from FormatString::validate_expressions #373, so the PR's prose is the only available oracle.

Unrelated to these four, and noted only because I was in the spec: §5.1 sets "Minimum length: 1 character" on <CommandString> and §6.1.2 the same on <DataString>. The #383 review found that minimum is not enforced on resolved values, and it was answered by softening the doc rather than adding the check. That one would be a conformance finding. I have not run it down and am not claiming it is broken.

1. The args and data caps are bypassable by writing the value literally

validate_fs_with returns before validate_expressions when fs.is_literal() (format_strings.rs:842), so a value with no interpolation is never measured against the new caps. What bounds a literal at validation is MAX_FORMAT_STRING_LEN, 1 MiB, not the caller's cap:

literal args[0] of    131073 chars, cap 131072 => ACCEPTED
literal args[0] of   1000000 chars, cap 131072 => ACCEPTED
literal args[0] of   1048576 chars, cap 131072 => ACCEPTED
literal args[0] of   1048577 chars, cap 131072 => REJECTED: Format string length (1048577 bytes) exceeds maximum allowed size (1048576 bytes)

literal command of      1024 chars, cap 131072 => ACCEPTED
literal command of      1025 chars, cap 131072 => REJECTED: exceeds 1024 characters.

literal data of      4097 chars, cap 4096 => ACCEPTED
literal data of   1000000 chars, cap 4096 => ACCEPTED

Per field:

Field Ceiling on a literal Overshoot vs the CLI default Why
args[*] 1,048,576 chars 8x on Linux (131,072), 32x on Windows (32,767) no spec length limit on an argument
embedded data 1,048,576 chars unbounded relative to a caller's own cap, 256x for a 4 KiB policy §6.1.2 sets no limit
command 1,024 chars none in practice EffectiveLimits::max_command_len (structure.rs:465) is tighter than any plausible caller cap

So command is safe by accident and args/data are not.

Whether this reads as a bypass or as a late failure depends on who executes the job. CallerLimits is caller policy applied at decode, so for a consumer whose executor is not openjd-sessions with SessionLimits populated (the Python openjd-sessions, which has no equivalent, or a Rust worker that leaves SessionLimits::default()), validation is the only gate that exists and writing the value literally removes it. Through the CLI, where both are set, it degrades to a late failure instead:

validation: literal 200-char arg, cap 100 => ACCEPTED
validation: expr    200-char arg, cap 100 => REJECTED: resolves to at least 200 characters, exceeding the maximum of 100.
runtime:    literal 200-char arg, cap 100 => Err("Failed to resolve args[0]: resolved value is 200 characters, exceeding the maximum of 100.")

Two things compound it. The 1 MiB ceiling is per field and nothing caps the argv count, so many literal arguments multiply it. And the ordering is backwards: {{ 'A' * 10000000 }} is the deliberate construction and is caught, while a plain long string needs no expression at all and is not. The check only ever fires on values containing {{ }}.

This was predicted in the #373 review, on specs/expr/format-string.md:

validate_fs returns early when fs.is_literal(), before calling validate_expressions at all. A purely literal format string that exceeds a length limit will not produce a min_resolved_len for the enforcing code to check. Whatever enforces the limit needs to handle the literal case separately, or that early return has to go.

The answer then was "This is planned for future PRs", and this is that PR. It also falsifies specs/cli/check.md as written, "the CLI surfaces it at check time whenever an action command/args value is guaranteed to exceed it", since a literal is the one value that is guaranteed.

Suggested fix: check fs.raw().chars().count() against the constraint before the early return, or hoist the constraint check above it. Same literal-versus-interpolated asymmetry class that #383 fixed twice, for the hardcoded 2048 env-var value and for STRING range literal empties.

2. Three units are conflated: scalars compared against byte and UTF-16 limits

Both gates compare chars().count() (format_strings.rs, runner/mod.rs:534, embedded_files.rs:519). OS_MAX_ARG_LEN is documented as "The maximum character length of a single process argument" and then sourced from limits that are neither: Linux MAX_ARG_STRLEN is 131,072 bytes, the Windows CreateProcess limit is 32,767 UTF-16 code units, macOS ARG_MAX is 1 MiB bytes.

validation: 100 CJK chars (300 UTF-8 bytes), cap 100 => ACCEPTED
validation: control, 101 ASCII chars,        cap 100 => REJECTED
runtime:    100 CJK chars (300 UTF-8 bytes), cap 100 => Ok

Under the CLI default a 131,072-character CJK argument is 393,216 bytes. It passes check, passes the run-time cap, and execve still returns E2BIG, which is the failure this PR sets out to make legible. On Windows, 32,767 astral-plane characters are 65,534 UTF-16 units. The error direction is acceptance, never rejection, so no valid job breaks.

#383 spent four rounds unifying these comparisons on characters and that was right, because the spec limits are stated in characters. These two are not spec limits, they are OS limits in bytes, so inheriting the convention inverts its purpose: on the spec limits characters avoided false rejection, here they produce false acceptance of exactly the input the cap exists to stop.

Suggested fix: compare s.len() for the POSIX cap and s.encode_utf16().count() on Windows, or keep characters and derive the constant conservatively (divide by 4 and by 2). Either way name the unit in the field docs.

Related, on the constants themselves. The Linux kernel measures with strnlen_user, which counts the terminating NUL, so the largest argument that actually passes is 131,071 bytes and a 131,072-byte argument satisfies the cap and still gets E2BIG. Worth checking rather than taking from me, since I have not run Linux here. On Windows 32,767 bounds the entire CreateProcess command line including the image name, separators and quoting, so it is not a single-argument maximum, and a per-argument cap set to the total leaves the real failure, several arguments summing past it, unconstrained. ARG_MAX on macOS is likewise the total for argv plus environ. Nothing here caps the argv count or the total, which is what E2BIG is usually about, so the docs should say which half of the threat the cap covers.

3. The "job creation" stage does not exist for any of the four new fields

No file under crates/openjd-model/src/job/ is touched. create_job never calls validate_format_strings (only validate_v2023_09/mod.rs:198 and :260 do), it builds its own FormatStringOptions at create_job/mod.rs:77, ranges.rs (eight sites) and instantiate.rs:59/:186 without reading ctx.caller_limits, and JobTemplate::default_validation_context() (job_template.rs:90) returns a context with default limits. The CLI's own run shows it: execution.rs:135-136 builds revision_ctx from the profile alone and hands that to create_job.

The budgets fail exactly where a budget matters, once real values are bound:

template validated under a 1000-byte eval budget
create_job with N=100000 under the 1000-byte budget => ACCEPTED
control, same expression fully static at validation  => REJECTED: Expression memory usage (100136 bytes) exceeded limit (1000 bytes)

{{ ('A' * Param.N)[0] }} in a task-parameter range costs nothing at validation, because Param.N is unresolved and the multiplication never allocates. At job creation with N = 100000 it allocates 100 times the caller's budget and is accepted. A submitting service that lowers max_eval_memory_bytes to protect its own process gets the 100 MB default during parameter-space expansion, which is where the allocation actually happens.

Claims in this PR that this falsifies:

  • CallerLimits::max_resolved_arg_len: "Enforced at template validation and job creation on the guaranteed lower bound"
  • CallerLimits::max_eval_memory_bytes: "template validation, job creation, and the session runtime all evaluate under the same budget"
  • specs/model/validation.md: "These bound each segment's evaluation identically at validation, job creation, and run time"
  • specs/expr/format-string.md: "a caller that lowers a budget fails at static validation, before any resolution runs"
  • the PR description: "Job creation: the same checks with real parameter values bound"
  • the commit message: "applied at check, summary, run decode/creation"

Suggested fix in two halves. The budgets are worth plumbing into create_job, since that is where the exposure is. The two caps have no job-creation stage available, because command/args/data are @fmtstring[host] and resolve only at the session, so for those the honest story is two stages. Either way, default_validation_context() silently dropping caller limits is a trap for the next field that needs them.

4. The lower bound is per segment, so one concatenation defeats the headline case

{{ 'A' * 10000000 }}                              => REJECTED: resolves to at least 10000000 characters, exceeding the maximum of 100.
{{ Session.WorkingDirectory + 'A' * 10000000 }}   => ACCEPTED
{{ Session.WorkingDirectory }}{{ 'A' * 200 }}     => REJECTED: resolves to at least 201 characters, exceeding the maximum of 100.
{{ Param.X + 'A' * 200 }}                         => ACCEPTED

validate_expressions accumulates per segment, and a segment whose root value is unresolved contributes 0 no matter how much of it is statically known (format_string.rs:335-341). Two segments work, which is what arg_bound_with_unresolved_part_fails pins; one segment doing the same concatenation does not.

The run-time check still catches it, so this is not a hole. But the PR describes the bound as "literal text plus already-computable expression parts, with unknowns (e.g. worker-host variables) counting as zero", which reads as sub-expression granularity, and concatenating inside a single interpolation is the idiomatic form.

Suggested fix: correct the description. A real sub-expression bound means carrying a minimum length through Unresolved in the evaluator and deserves its own issue rather than being folded in here.

Suggested dispositions

# Severity Fix
1 Blocking. A caller-facing cap that a naive template defeats, measured 8x to 256x over policy One check before the is_literal() early return, plus a test
3 Blocking as a documentation claim. Six places assert an enforcement stage that does not exist Docs at minimum; plumbing the budgets into create_job is the substantive fix
2 Should fix. Leaves the original E2BIG intact for non-ASCII at every gate One-line unit change per gate; behaviour change, so it needs tests
4 Docs only Correct the description of the bound; file an issue if a sub-expression bound is wanted

Test coverage gaps

  1. No literal-value case at any gate (finding 1). If the early return is fixed, that fix needs one.
  2. No non-ASCII case at any gate (finding 2), so the unit is unpinned in either direction.
  3. Nothing asserts the budgets at job creation (finding 3), because they are not there.
  4. Nothing pins the single-segment concatenation behaviour (finding 4), so it could change silently either way.
  5. check has CLI tests; summary, run's decode, and the create_session SessionLimits mirror have none.

Smaller items

  • The CLI's policy is encoded twice in production code: common::caller_limits() sets the model-side cap, and run/execution.rs:291-298 independently hand-builds SessionLimits { max_resolved_arg_len: Some(OS_MAX_ARG_LEN), .. }. Add a data cap or a budget to caller_limits() and the session keeps the old policy silently. openjd-sessions already depends on openjd-model, so From<CallerLimits> for SessionLimits removes the drift. The mirror constant in cli_tests.rs is a reasonable compromise for a binary crate.
  • Budget violations are reported as parse failures: Failed to parse interpolation expression at [0, 18]. Expression memory usage (100136 bytes) exceeded limit (1000 bytes). Pre-existing wording, but this PR routes a resource error through it and lowered_memory_budget_fails_static_blowup_at_validation now pins "Failed to parse" for it.
  • validate_expressions lost a compile-time guarantee. The library moved from &FunctionLibrary to FormatStringOptions, where it defaults to None and the evaluator falls back to FunctionLibrary::for_profile(&ExprProfile::current()). A future call site that forgets .with_library() compiles and validates against the current profile instead of the scope's, which is a silent scope escalation rather than an error. Both present call sites pass it.
  • path_format is plumbed but unused. validate_expressions now honours opts.path_format, but FsEval::options() and the sessions fs_options() both leave it at PathFormat::host() while create_job resolves template-scope fields with PathFormat::Posix. So expr: Have the path() constructor return unresolved[path] when validating for the host context #377's divergence is untouched, and the restated claim that "validation observes exactly the values resolution will produce" still does not hold for template-scope fields on a Windows host. FsEval is already split into template_ev/host_ev, which is the natural seam if the fix goes that way rather than via expr: Have the path() constructor return unresolved[path] when validating for the host context #377's unresolved[path] approach.
  • The cross-gate target invariant is unpinned. target_type() returning None for the two new variants encodes "resolution passes no target type", which is true today in resolve_action_args and embedded-file materialization and verified only by reading them. feat!: enforce resolved-value constraints at template validation  #383's review removed the targeted flag precisely because a gate-1/gate-3 target mismatch is the failure mode this design guards against. A test resolving one value through both paths and asserting agreement would pin it.
  • No CLI override. openjd check is used as a conformance oracle and now returns host-dependent verdicts on spec-valid templates with no way to opt out, and by finding 2 it rejects the ASCII form while accepting the non-ASCII equivalent of the same length. A --max-resolved-arg-len <N|none> would help.
  • SessionConfig still has no Default, so seven test files change for one added field. #[non_exhaustive] plus a builder, or a Default impl, would stop this recurring at every new field.
  • Not this PR's defect: the expect("unresolved[T] always has exactly one parameter") panic raised in the feat(expr)!: return StaticResolution from FormatString::validate_expressions #373 review is still on this path, tracked as Make ExprType constructors fallible, with a maximum nesting depth #328.

What I did not verify

The Windows and Linux argument limits themselves, the conformance run (1139/1139 claimed on Windows), the two pre-existing Windows cross-user failures, and the strnlen_user off-by-one in finding 2.

Comment thread crates/openjd-sessions/src/embedded_files.rs
Adds opt-in caller caps for the resolved-value fields the spec
deliberately leaves uncapped, plus configurable expression evaluation
budgets, enforced at all three processing stages (template validation,
job creation, run time).

- openjd-expr: FormatStringOptions gains with_memory_limit /
  with_operation_limit (the Expression Language spec's memory-bounded
  evaluation lever), honored by resolve_with / resolve_string_with.
  FormatString::validate_expressions now takes the same
  FormatStringOptions as resolution, so validation observes exactly the
  resolution the caller will perform — target type and budgets included.

- openjd-model: CallerLimits gains max_resolved_arg_len /
  max_resolved_data_len (opt-in caps on action command/args and
  embedded-file data; §5.1/§5.2/§6.1.2 set no spec maximum, so the
  defaults are None) and max_eval_memory_bytes / max_eval_operations.
  Template validation (pass 8) enforces the caps on the guaranteed
  lower bound of every possible resolution and evaluates every
  format-string expression under the budgets.
  decode_environment_template now takes CallerLimits, closing the gap
  where environment templates bypassed caller policy at decode.

- openjd-sessions: new SessionLimits (SessionConfig.limits) mirrors the
  four fields. resolve_action_args enforces the arg cap on the resolved
  command and every final argv entry (after null-skip/list-flatten);
  embedded-file materialization enforces the data cap; every
  format-string evaluation in the runtime honors the budgets. The
  runtime is the authoritative enforcement point — its checks hold for
  jobs that never passed through this client's validation.

- openjd-cli: opinionated default max_resolved_arg_len = the host OS's
  single-argument maximum (Linux 131072 MAX_ARG_STRLEN, Windows 32767
  command-line limit, otherwise 1 MiB ARG_MAX), applied at check,
  summary, run decode/creation, and mirrored into the session. Library
  defaults stay None. Conformance suite passes with the cap active
  (1139/1139 on Windows, the tightest cap).

BREAKING CHANGE: FormatString::validate_expressions signature changed
to (symtab, &FormatStringOptions); decode_environment_template takes a
CallerLimits argument; CallerLimits and SessionConfig gained fields
(struct-literal construction must add them or use ..Default::default()).

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Review follow-up: the CallerLimits docs claimed the resolved-value caps
and evaluation budgets are enforced at job creation, but create_job
applies neither — it carries command/args/data forward as unresolved
host-scope format strings (nothing to measure), and its own resolution
sites (job name, ranges, parameter spaces) evaluate under the spec
default budgets. Correct the field docs and the matching spec passages
to say what actually happens: caps and budgets apply at template
validation and, when mirrored into SessionLimits, at run time.
Evaluating carried-forward fields at job creation is possible future
work.

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Review follow-up: validate_fs_with returned early for literal format
strings before the constraint ran, so a purely-literal args element or
embedded-file data blob over an opted-in cap passed validation and only
failed at run time — the raw-text passes length-check neither field
(command alone was incidentally covered by the 1024 raw-text cap). A
literal is the one case where the resolved value is known exactly, so
the literal path now checks the raw length against ResolvedString /
ArgListItem caps directly; the spec-mandated constraints keep the early
return, since the raw-text passes already cover their literal cases and
re-checking would double-report.

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Review follow-up: let-binding evaluation bypassed the caller's
evaluation budgets on every path — pass-8 validation
(validate_let_bindings and the step-level range-scope bindings)
evaluated with the library but no limits, and
openjd_model::evaluate_let_bindings, which the session runtime calls
for env/step/wrap-env bindings, built its EvalBuilder the same way. A
caller who lowered max_eval_memory_bytes specifically to bound
expression blowups still evaluated let: [x = 'a' * 50000000] under the
100 MB default at validation and again on the worker.

evaluate_let_bindings now takes the memory/operation budgets (None =
spec defaults), all session-runtime callers pass SessionLimits'
budgets, and the two validation-side sites evaluate through the same
FsEval budgets as format strings. Session gains a test-only limits
setter so the runtime wiring is testable end to end.

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
@mwiebe
mwiebe force-pushed the feat/resolved-value-caller-limits branch from 7e9b7cb to 5a5901b Compare September 16, 2026 04:49
…ters

Review follow-up: OS_MAX_ARG_LEN encoded per-OS figures that are not
measured in the units the checks count. The caps compare
chars().count(), but Linux MAX_ARG_STRLEN (131072) is bytes and the
Windows command-line limit (32767) is UTF-16 code units — so 131072
non-ASCII characters passed check on Linux and still drew E2BIG at
execve, and astral-plane characters under-counted 2x on Windows. The
constant was not a sound proxy for the OS limits it cited.

Replace it with DEFAULT_MAX_ARG_LEN = 32K characters on every platform,
documented as an opinionated reasonable default rather than an encoding
of any OS's limit: it is far beyond any reasonable single argument,
at most 128 KiB of UTF-8 (a char is at most 4 bytes, so it cannot
exceed Linux's per-string byte limit), and approximately the Windows
command-line capacity. The OS itself and the worker's run-time
enforcement remain authoritative. The CallerLimits field docs now state
the character-vs-bytes/UTF-16 units mismatch so library callers pick
their own values with encoding headroom.

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Review follow-up: resolve_string_with concatenated every segment into
an unbounded String — only each individual segment's evaluation was
memory-bounded, and the per-segment limit does not compose (1000
segments of 'A' * 99999999 fit in a 1 MB format string yet concatenate
to gigabytes). validate_expressions already caps its accumulation for
exactly this reason, but the run-time resolution paths (command, args,
env values, embedded-file data) had no equivalent ceiling: a worker
could OOM materializing the string before any resolved-length cap
fired.

The concatenation buffer is now charged against the same memory limit
as segment evaluation, checked after each expression segment renders:
resolution fails with the standard MemoryLimitExceeded error instead of
materializing past the budget, bounding peak memory at roughly twice
the limit. This protects every resolution path under the 100 MB default
— no opt-in required — and scales down with a lowered
max_eval_memory_bytes. Literal text is never charged on its own: it
involves no evaluation, is already bounded by MAX_FORMAT_STRING_LEN,
and validation does not charge it either, so purely-literal strings
resolve under any budget.

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Review follow-up: the step-level let bindings that the parameterSpace
block pre-evaluates into the range scope still built a raw EvalBuilder
with the library but no limits — the one evaluation site in pass 8 left
outside FsEval after the let-binding budgets landed. Ordering makes it
the worst one to miss: within a step the parameterSpace block runs
before the step-level let pass, so for a step declaring both, the
unbudgeted evaluation performed the full allocation before the budgeted
re-evaluation ever fired, making the budget bypassable for exactly the
input it is meant to bound.

Route it through FsEval::budgeted like every other evaluation in the
pass, with a test pinning the pre-pass path (a step with both a
parameterSpace and a let blowup fails under a lowered budget, passes
under defaults).

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
Comment thread crates/openjd-expr/src/format_string.rs Outdated
Review follow-up on the resolution-buffer bound: resolve_string_with
charged the whole accumulated buffer — literals included — while
validate_expressions never charges literals, so the two stages
disagreed. A literal-heavy field with any interpolation (the embedded
file data shape: a large literal script body plus one small
interpolation) passed openjd check under a lowered
max_eval_memory_bytes and then drew MemoryLimitExceeded on the worker,
breaking the documented invariant that validation observes exactly what
resolution produces. Literal text needs no budget of its own: it
involves no evaluation and MAX_FORMAT_STRING_LEN (1 MB) already bounds
it.

Both stages now charge the same quantity — the accumulated bytes
rendered by concrete expression segments:

- resolve_string_with tracks a charged counter (expression-rendered
  bytes only) against the memory limit, still closing the composition
  gap the bound exists for;
- validate_expressions applies the identical charge, so a fully static
  string that resolution rejects on a budget now fails at check too.
  Unresolved segments contribute 0, so validation only ever
  under-counts host-dependent content and the run-time check remains
  authoritative for it.

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
…bound semantics

Follow-ups from the full-branch review (written at 97ddc37; its findings
1-3 were addressed by the intervening commits):

- The CLI's policy was encoded twice: common::caller_limits() for the
  model-side stages and a hand-built SessionLimits in create_session.
  SessionLimits now implements From<&CallerLimits>, mirroring the four
  run-time-relevant fields, and create_session derives its limits from
  the same caller_limits() value — adding a cap or budget to the policy
  can no longer silently skip the session.

- Tests pin the semantics the review found unpinned: the caps count
  Unicode scalar values (100 three-byte CJK characters satisfy a
  100-character cap, 101 fail it — at validation, on the literal path,
  and at run time), and the validation bound is per segment (a single
  expression concatenating a huge static part onto an unresolved part
  contributes zero and defers to the run-time check, while the same
  content as two segments is rejected — the documented granularity
  limitation, now pinned so it cannot change silently).

- check.md notes the cap bounds each single argument, not the argv
  count or total (ARG_MAX-style budgets are possible future work).

Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>

@leongdl leongdl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@mwiebe
mwiebe merged commit 89b6165 into OpenJobDescription:main Sep 17, 2026
22 checks passed
@mwiebe
mwiebe deleted the feat/resolved-value-caller-limits branch September 17, 2026 17:46
@github-actions github-actions Bot mentioned this pull request Sep 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants