Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ jobs:
curl -sf http://localhost:8000/workloads | grep -q 'workload_id'
curl -sf http://localhost:8000/findings | grep -q 'refuted'
curl -sf http://localhost:8000/ | grep -q '<title>DELPHI'
# The container must not run as root, and the SPA catch-all must not serve
# anything outside dist. Percent-encoded traversal previously read arbitrary
# container files; Render's edge rejected it, `docker run` did not.
test "$(docker exec delphi id -u)" != "0"
for probe in '/..%2f..%2frequirements-serve.txt' '/%2e%2e/%2e%2e/%2e%2e/etc/passwd'; do
body=$(curl -s --path-as-is "http://localhost:8000$probe")
echo "$body" | grep -q 'root:' && { echo "TRAVERSAL: $probe"; exit 1; }
echo "$body" | grep -q 'Runtime-only dependencies' && { echo "TRAVERSAL: $probe"; exit 1; }
done
curl -sf -X POST http://localhost:8000/diagnostic/score \
-H 'Content-Type: application/json' \
-d "$(python3 -c 'import json,math;print(json.dumps({"values":[60+25*math.sin(i/45.8) for i in range(2000)],"step_seconds":300}))')" \
Expand Down
6 changes: 6 additions & 0 deletions Dockerfile.web
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ ENV PYTHONPATH=/app/src \
DELPHI_SNAPSHOT_MODE=demo \
PORT=8000

# Drop root. Nothing here needs it: the app reads a baked snapshot and writes nothing. A
# read-only API running as uid 0 turns any file-disclosure bug into a read of the whole
# container, which is exactly what the SPA traversal bug would have done.
RUN useradd --create-home --uid 10001 delphi && chown -R delphi:delphi /app
USER delphi

EXPOSE 8000

# Render injects $PORT; default to 8000 for local runs of the exact deploy image.
Expand Down
104 changes: 104 additions & 0 deletions docs/EVAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -1196,3 +1196,107 @@ would mean something.
forecastability literature.
- **The one measure that showed signal does not survive multiple-comparison correction**, and
is reported as a lead rather than promoted into the product.

---

# Full-codebase audit (2026-08-09)

The 2026-08-03 audit covered M0–M3 and the 2026-08-08 one covered the control and product
layers. This pass covered what neither did: the queueing core, the adaptive controllers, the
Pareto machinery, the price client, and the deployed container. Four defects, one of them a
live security hole.

## 1. The budget pacer was steering on a frozen capacity trace

`adaptive.py` carried its **own copy** of the actuation-delay rule, so C5/C6 could reconstruct
what their past requests would have served and pace against it. When the clock-restart bug
was fixed in `apply_actuation_delay` on 2026-08-08, that copy was left behind — still
restarting the clock, still freezing.

Measured on a plan that moves every step, which is exactly what C5/C6 emit: the mirror
reported a flat 13 replicas for 40 steps while the simulator provisioned 13 → 24. **36 of 40
steps disagreed**, so the PI loop spent every run correcting against violations that never
happened.

Fixed by extracting `ActuationTracker`, with `apply_actuation_delay` implemented on top of it
so the batch and incremental forms *cannot* diverge. A test asserts they agree over 200
random plans, and another fails if the lag rule is re-implemented anywhere outside
`simulator.py` — this being the second time a copy of it drifted.

**Effect on published results: small and no conclusion moves.** Re-running the frontier
changes only C5/C6 rows: cost 66.6 → 66.5, churn 180 → 176, violation rates shifting by
around 0.001–0.008 in both directions. Q4 is bit-for-bit unchanged at 9 better / 3 tie / 6
worse of 18, and the "wins below 19:1, loses above" pattern holds. The GPU frontier verdict
is unchanged at 0 of 8.

## 2. Path traversal in the deployed dashboard

The SPA catch-all joined the request path onto the dist directory and served whatever it
found. Percent-encoded traversal survives URL normalisation, reaches the handler intact, and
read arbitrary files off the container:

```
/..%2f..%2frequirements-serve.txt -> served the file
/%2e%2e/%2e%2e/%2e%2e/etc/hostname -> served the file
```

Verified against the real deploy image under uvicorn, not merely in a test client. **The
container also ran as root**, so this was an arbitrary read of everything in it. No
credentials exist to steal — the project is keyless by design — but the source, the
dependency manifest and every system file were readable.

Render's edge happened to reject the encoded forms with a 400, so the live site was not
exploitable. That is an accident of the CDN rather than a control, and anyone following the
README's `docker run` had no such cover.

Fixed by resolving the candidate and requiring it to stay inside dist — which also closes
symlink escapes and absolute-path injection — and by dropping to an unprivileged user in the
image. Both are now asserted in the deploy-image CI lane against the running container, so a
regression fails the build rather than the internet.

## 3. Hypervolume was measuring the wrong staircase

`dominated_hypervolume` swept from cost zero and credited each strip with the *dearer*
point's violation rate, when over that interval only the cheaper point is affordable. A
single point at cost 5 with a 0.2 violation rate against a reference corner at cost 10
returned 8.0 where the true dominated rectangle is 5 x 0.8 = **4.0**.

This is not merely an inflation: it can reverse a ranking. A frontier reaching a 0.05
violation rate at cost 9 scored *above* one sitting at 0.45 for cost 5, where the corrected
areas are 1.75 and 3.25 respectively.

The existing test compared two frontiers where the error cancelled, so it passed throughout.
It now checks areas computed by hand. **No published result used this function** — it is
reported nowhere in this document — so nothing downstream moves.

## 4. The two fidelities were documented as the same system

`_synthesize_arrivals` claimed its service-time construction kept the utilisation and queue
models "describing the same physical system". They do not: `utilisation_target` derates
capacity in the utilisation model but is not applied in the queue, so at a target of 0.8 the
queue runs at rho = 0.8 where the utilisation model reports a step exactly at its limit.

Defensible for two models answering different questions, and harmless in practice — every
published result uses the utilisation model, and the Erlang-C validation sets the target to
1.0 where the discrepancy vanishes — but the docstring asserted something false about the
code. Corrected to state the difference and why it does not contaminate anything.

## Verified sound

The ACI update rule reproduces the published BACC form exactly, including the sign of the
correction under miscoverage. `pareto_front` is a correct two-objective sweep.
`cost_at_matched_violation` reports unreachable targets as unreachable rather than
substituting the nearest point. `parse_price_items` skips rows with a missing or non-positive
price rather than defaulting them, which would otherwise drive the newsvendor ratio to buy
unbounded capacity. `cheapest_hourly` refuses to convert monthly reservations into hourly
prices. The OData filter is now escaped — not a live injection path, since the values come
from config, but an apostrophe would have silently returned the wrong SKU's price.

## Added to the negatives ledger

- **A duplicated implementation of the actuation lag drifted from its original**, for the
second time, and fed a control loop a capacity trace that never moved.
- **The deployed container was vulnerable to path traversal and ran as root.** It was
unexploitable in production only because of a CDN behaviour nobody had designed for.
- **A Pareto summary statistic could rank two frontiers backwards**, and its test passed
because the error cancelled between the two curves being compared.
26 changes: 22 additions & 4 deletions src/delphi/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,10 +281,28 @@ def evidence(limit: Annotated[int, Query(ge=1, le=200)] = 50) -> dict[str, objec
if _DIST.is_dir():
app.mount("/assets", StaticFiles(directory=_DIST / "assets"), name="assets")

_DIST_ROOT = _DIST.resolve()

@app.get("/{path:path}", include_in_schema=False)
def spa(path: str) -> FileResponse:
"""Serve the SPA shell for any unmatched path."""
candidate = _DIST / path
if path and candidate.is_file():
"""Serve the SPA shell for any unmatched path.

**The candidate is resolved and confined to the dist directory.** Without that,
``_DIST / path`` walks straight out of it: percent-encoded traversal such as
``/..%2f..%2frequirements-serve.txt`` or ``/%2e%2e/%2e%2e/etc/hostname`` survives
URL normalisation, reaches this handler intact, and served arbitrary files off the
container — as root, in the deploy image. Render's edge happened to reject those
requests with a 400, but an accident at the CDN is not a security control, and
anyone running the documented ``docker run`` locally had no such cover.

``resolve()`` also collapses symlinks, so a link inside ``dist`` cannot be used to
step outside it either. An absolute ``path`` would make ``/`` discard the root
entirely; the containment check catches that case too.
"""
index = _DIST_ROOT / "index.html"
if not path:
return FileResponse(index)
candidate = (_DIST_ROOT / path).resolve()
if candidate.is_file() and candidate.is_relative_to(_DIST_ROOT):
return FileResponse(candidate)
return FileResponse(_DIST / "index.html")
return FileResponse(index)
20 changes: 5 additions & 15 deletions src/delphi/control/adaptive.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

from delphi.control.controllers import ControlContext, PercentileRecommender, _replicas_for
from delphi.control.newsvendor import CostRatio, interpolate_quantile
from delphi.control.simulator import clamp_plan
from delphi.control.simulator import ActuationTracker, clamp_plan
from delphi.forecast.calibration import AdaptiveConformalCalibrator
from delphi.forecast.contracts import QuantileForecaster

Expand Down Expand Up @@ -127,10 +127,9 @@ def _clamped(replicas: int) -> int:
# raw forecast value emitted for each step, so its residual can be scored once the
# true demand for that step becomes observable
raw_for_step: dict[int, float] = {}
# incremental mirror of the simulator's actuation delay, over our own plan
current = int(requested[0])
pending_target: int | None = None
pending_at = 0
# incremental mirror of the simulator's actuation delay, over our own plan. Shares the
# simulator's implementation rather than restating it, so the two cannot drift.
tracker = ActuationTracker(profile, initial=int(requested[0]))
served: dict[int, int] = {}
settled = context.start_step

Expand Down Expand Up @@ -182,16 +181,7 @@ def _clamped(replicas: int) -> int:
raw_for_step[target_step] = raw

# advance our mirror of the platform's actuation lag one step
asked = int(requested[target_step])
in_flight = pending_target if pending_target is not None else current
if asked != in_flight:
lag = profile.startup_steps if asked > current else profile.teardown_steps
pending_target = asked
pending_at = target_step + max(lag, 0)
if pending_target is not None and target_step >= pending_at:
current = pending_target
pending_target = None
served[target_step] = current
served[target_step] = tracker.advance(target_step, int(requested[target_step]))

step += block

Expand Down
85 changes: 63 additions & 22 deletions src/delphi/control/simulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,52 @@ def summary(self) -> dict[str, float]:
}


class ActuationTracker:
"""Incremental form of the actuation delay: one step in, one serving count out.

This exists so there is exactly **one** implementation of the lag. A controller that
paces itself against its own past decisions has to reconstruct what was actually
serving, and the obvious way to do that is to re-derive the rule — which is what C5/C6
did, and which meant that fixing the clock-restart bug in `apply_actuation_delay` left a
stale copy in `adaptive.py` silently feeding the PI loop a capacity trace that froze at
the initial replica count. A shared object cannot drift from itself.
"""

def __init__(self, profile: CapacityProfile, *, initial: int) -> None:
self._profile = profile
self._current = int(initial)
self._pending: int | None = None
self._pending_at = 0

@property
def current(self) -> int:
"""What is serving right now, after everything already landed."""
return self._current

def advance(self, step: int, requested: int) -> int:
"""Register the request for ``step`` and return what is serving at ``step``.

Superseding retargets the change in flight and keeps its original landing step:
pods already starting do not begin again because the desired count moved.
"""
target = int(requested)
if self._pending is None:
if target != self._current:
lag = (
self._profile.startup_steps
if target > self._current
else self._profile.teardown_steps
)
self._pending = target
self._pending_at = step + max(lag, 0)
elif target != self._pending:
self._pending = target
if self._pending is not None and step >= self._pending_at:
self._current = self._pending
self._pending = None
return self._current


def apply_actuation_delay(requested: IntArray, profile: CapacityProfile) -> IntArray:
"""Turn a requested trajectory into the capacity that is actually serving.

Expand All @@ -164,26 +210,10 @@ def apply_actuation_delay(requested: IntArray, profile: CapacityProfile) -> IntA
unrequestable state that silently penalises exactly the smooth, forecast-driven
controllers this project exists to evaluate.
"""
steps = len(requested)
provisioned = np.empty(steps, dtype=np.int64)
current = int(requested[0])
pending_target: int | None = None
pending_at = 0

for step in range(steps):
target = int(requested[step])
if pending_target is None:
if target != current:
lag = profile.startup_steps if target > current else profile.teardown_steps
pending_target = target
pending_at = step + max(lag, 0)
elif target != pending_target:
# Retarget the in-flight change, keeping its original landing step.
pending_target = target
if pending_target is not None and step >= pending_at:
current = pending_target
pending_target = None
provisioned[step] = current
tracker = ActuationTracker(profile, initial=int(requested[0]))
provisioned = np.empty(len(requested), dtype=np.int64)
for step in range(len(requested)):
provisioned[step] = tracker.advance(step, int(requested[step]))
return provisioned


Expand Down Expand Up @@ -216,8 +246,19 @@ def _synthesize_arrivals(

Arrivals are placed uniformly at random inside their step, which reproduces a Poisson
process conditioned on the per-step count. Service times are exponential with a mean
implied by ``capacity_per_replica`` so that one replica serves exactly that many units
per step on average — keeping the two fidelities describing the same physical system.
implied by ``capacity_per_replica``, so one replica serves exactly that many units per
step on average.

**The two fidelities are not the same physical system, and this docstring used to claim
they were.** ``utilisation_target`` derates capacity in the utilisation model — a
replica there absorbs ``capacity_per_replica * utilisation_target`` per step — but the
queue model applies no such derate, because a queue expresses congestion through waiting
time rather than through a headroom ceiling. At a target of 0.8 the queue therefore runs
at rho = 0.8 where the utilisation model reports a step exactly at its limit. That is
defensible for two models answering different questions, but it means their absolute
numbers are not comparable and must never share a results table. Every published result
in `docs/EVAL.md` uses the utilisation model; the queue exists to validate the simulator
against Erlang-C, where ``utilisation_target`` is set to 1.0 and the discrepancy is nil.
"""
counts = np.rint(np.maximum(demand, 0.0)).astype(np.int64)
total = int(counts.sum())
Expand Down
18 changes: 16 additions & 2 deletions src/delphi/data/prices.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,25 @@ def parse_price_items(payload: dict[str, Any], *, retrieved_at: datetime) -> lis
return prices


def _odata_literal(value: str) -> str:
"""Quote a string for an OData filter, escaping embedded single quotes by doubling.

These values come from config rather than from a request, so this is not a live
injection path — but an unescaped apostrophe would silently malform the query and
return the wrong SKU's price, which then propagates into the newsvendor ratio as a
plausible-looking number. Cheaper to escape than to debug.
"""
return "'" + value.replace("'", "''") + "'"


def build_filter(*, region: str, service_name: str, sku_contains: str | None = None) -> str:
"""Compose the OData ``$filter`` the API expects."""
clauses = [f"armRegionName eq '{region}'", f"serviceName eq '{service_name}'"]
clauses = [
f"armRegionName eq {_odata_literal(region)}",
f"serviceName eq {_odata_literal(service_name)}",
]
if sku_contains:
clauses.append(f"contains(skuName, '{sku_contains}')")
clauses.append(f"contains(skuName, {_odata_literal(sku_contains)})")
return " and ".join(clauses)


Expand Down
25 changes: 16 additions & 9 deletions src/delphi/evaluation/frontier.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from collections.abc import Sequence
from dataclasses import dataclass
from itertools import pairwise

import numpy as np
import numpy.typing as npt
Expand Down Expand Up @@ -122,19 +123,25 @@ def dominated_hypervolume(
A scalar summary for when one is genuinely needed. Larger is better. It is reported
only alongside its reference point, because a hypervolume without its reference is
meaningless and trivially manipulable.

The staircase runs *forward* from each point: over the cost interval between one
frontier point and the next, the best violation rate available is the **cheaper**
point's, because the dearer one cannot be afforded yet. An earlier version credited each
strip with the dearer point's rate and started the sweep at cost zero, which double
counted a single-point frontier and could reverse the ranking of two curves.
"""
front = pareto_front(points)
front = [
point
for point in pareto_front(points)
if point.cost < cost_reference and point.violation_rate < violation_reference
]
if not front:
return 0.0
area = 0.0
previous_cost = 0.0
for point in front:
if point.cost >= cost_reference or point.violation_rate >= violation_reference:
continue
width = point.cost - previous_cost
area += width * (violation_reference - point.violation_rate)
previous_cost = point.cost
area += (cost_reference - previous_cost) * (violation_reference - front[-1].violation_rate)
for current, following in pairwise(front):
area += (following.cost - current.cost) * (violation_reference - current.violation_rate)
last = front[-1]
area += (cost_reference - last.cost) * (violation_reference - last.violation_rate)
return float(max(area, 0.0))


Expand Down
Loading
Loading