Skip to content

v0.31.0

Choose a tag to compare

@mortenoh mortenoh released this 03 Nov 06:53
· 6 commits to main since this release
v0.31.0
b7fa582

A climate connection holds a credential in a sealed field, never in its headers. It gains credential, credential_header, credential_scheme and credential_query, one of the last two naming how it is sent. headers refuses a credential-named entry, including X-Proxy-Key, which the docs used as the example; a stored connection with one fails when it is opened and has to be rewritten. A credential a proxy quotes back is scrubbed from the health report and from block failures.

A base_url holds only where a server is. climate and chap refuse a user, a password and a credential-named query parameter. The rules now come from dirigent_common, as in dirigent itself.

Requires dirigent 0.31.0.

The dirigent 0.31.0 notes follow.

Released 2026-11-02. Every package in the workspace moves to 0.31.0 together, and so do
dirigent-dhis2, dirigent-contrib, dirigent-fhir and dirigent-integration.

Before upgrading. The baseline migration changed (pipeline_versions.shape,
step_attempts.remote_cancel_since), so recreate the database or add the two columns by hand. A
document or connection holding a credential in a header, a query parameter or a URL is refused and
moves to the connection's sealed credential: a stored document fails at its next run, a stored
connection when it is opened.

A secret a service echoes does not reach a step's output. A Location on an unfollowed redirect or a body quoting the request put a connection's token in the attempt's output and every step that read it. The engine now walks what a block returns before it is stored or handed on, off the event loop, and replaces every spelling of every secret the attempt opened, inside strings and dict keys, leaving types and shape alone. A mixed secret, such as a token or a generated password, is replaced wherever it occurs in that output. A secret that could be ordinary data is left out of this scrub in all its spellings, so a letters-only or digits-only secret a service echoes, such as DHIS2's demo district, or one under four characters, stays in the output in plain text; logs and failures still scrub it. What a block writes straight to storage, a capture, a remote handle and a source's poll are not covered.

A credential does not sit in a header map, a query map or a URL. http.request, webhook.post, http.ready, the http and webhook connections, an S3 endpoint_url, and a sql, rabbitmq, git or docker URL each wrote one somewhere stored as written. headers refuses a name such as Authorization, Cookie or X-Api-Key; query, and a parameter written in url or path, refuses api_key, access_token, client_secret and their kin, while page_token, nextToken, continuation_token, sort_key and a bare key pass; a URL refuses a user and a password. Each is refused at apply, literal or reference alike, and again when the step starts for what a reference resolved to, and no refusal repeats the value. The http and webhook connections gain a sealed credential, sent in credential_header after credential_scheme, or as the query parameter credential_query, and a request hook keeps either on the connection's own origin. This breaks a document or a stored connection that wrote one: a stored document fails at its next run, a stored connection when it is opened, and either moves to credential. A URL an earlier step's output supplies is exempt from the parameter refusal, decided from the unresolved config: url: ${steps.x.output.link} runs with its ?token=, shown as *** in the attempt's stored config, logs and failures, while a parameter the document writes, such as ?token=${steps.x.output.t}, or anything through ${params...} is refused. The HTTP client's own INFO line, which writes each request's whole URL, is floored at WARNING in the process log. A sql or rabbitmq URL and an ssh git remote keep their user, and an https git remote that carried one, which used to be stripped, is refused. kobo-submissions-to-csv now names a kobo connection and has lost its token and server parameters. In the packs, the climate, chap, fhir and dhis2 base_url refuse a user, a password, a query or a fragment, fhir.search refuses a credential-named parameter, and climate gains credential, credential_header, credential_scheme and credential_query.

A name bound outside a program reads as its own colour in the editor. jq's $name and FHIRPath's %name and $this were a pale violet close to the text colour. They now wear a stronger violet in every palette, dark and light.

playground.generate and playground.arrive are bounded, and run off the event loop. json, pystr_format and their kin reached names outside the provider allowlist, the class-wide seed among them; provider arguments and field counts had no bound, and generation held the worker's loop past a step's timeout. Every dispatch now goes through the allowlist. One generation makes at most 100,000 provider calls and 16 MiB of records in 30 seconds (playground.generation_past_its_bound), and an argument saying how much to make asks for at most 10,000 (playground.provider_refused).

A source's curl line quotes a method that is not a bare word. The UI's copyable line left the method unquoted, relying on the server's list of methods.

A listing reads a shape stored at apply, and the schema changed. pipeline_versions gains shape, written when a version is stored, so GET /pipelines, GET /pipelines/{code} and the MCP tools that call them no longer revalidate each document, every template in it included, on the event loop on every read. The baseline migration is edited in place, so dg db upgrade does not carry the column: recreate the database, or add shape by hand with each version's step shape, as an existing row with an empty list draws no miniature.

An open MCP stream does not keep its session past the idle time. An open GET stream counted as a request in flight, so mcp_session_idle never closed its session. The stream is now ended after that long, and the session counts from there. A client that still wants one reconnects, which authenticates its token again; the SDK's client does.

A program a pack nests is held as a top-level one is. A ${...} in a template, a compose file, FHIRPath, CQL, jq or a shell string under a list, an object or a named definition used to be spliced into the program's text, since only top-level fields were read. Those four media types now refuse it wherever a config holds them, at apply and when the step starts. A nested jq program binds it as a variable, and a nested shell string takes it from the environment. A reference standing where a schema holds programs under it is refused (document.step_reference_for_a_program, reference.for_a_program). No block in this repository, dirigent-fhir, dirigent-contrib or dirigent-dhis2 nests one, so nothing changes for them. dirigent_plugin gains media_places_of and MediaPlace.

An MCP token is offered only the tools its role may call. plan_document was offered to a viewer and check_connection to an operator, and the API refused both. Each role now has a server of its own: a viewer's leaves out what writes and plan_document, whose dry run is an operator's route, and an operator's leaves out check_connection. DG_MCP_READ_ONLY=1 offers what a viewer is offered, so it no longer offers plan_document. mcp_max_sessions now counts each of the three roles apart. In dirigent_mcp, build() takes offers in place of read_only, and Caller carries offers in place of may_operate.

dg answers a bad settings file, setting or server URL with a record. A malformed dirigent.yaml read as settings and a .env setting pydantic refuses end in cli.settings_refused, which names the field and leaves out the value, and every profile refusal is now a cli.profile_refused record rather than Typer's panel, a URL holding a control character included. dg reads its settings before any command runs, so a bad one refuses every command but --version and a bare --help.

One MCP token cannot hold every session. A new setting, mcp_max_sessions_per_token (32), bounds what one token may hold within its role's mcp_max_sessions, so one token cannot lock its role's other callers out. A session past it is answered 503.

A signature timestamp no date can hold is refused. A 12-digit X-Dirigent-Timestamp past the year 9999 answered an unauthenticated caller 500 with a logged traceback. It is now refused like any other bad timestamp, source.signature_timestamp.

One address cannot take every playground stream. One anonymous client could hold all 32 seats for up to 95 seconds and answer every other caller 503. An address now holds at most 8, an IPv6 address counting as its /64.

dg removes bidirectional controls before a terminal shows a value. U+202A to U+202E and U+2066 to U+2069 in server or checkout text could reorder what a terminal displays around them.

A standalone MCP server reads Host strictly. On a loopback bind, localhost:evil and [::1]evil.example were read as loopback, and a request with no Host passed. All are now refused 421 (mcp.host_not_loopback).

A standalone MCP server answers 502 when its instance cannot say whose a token is. A 5xx, an answer that is not dirigent's, or an instance that is not there answered a bare 500 with an unhandled exception. They now answer mcp.instance_unavailable, 502.

dg pipeline new refuses a starter that is no document. A malformed starter from the instance ended in a traceback, and now ends in a refusal record (cli.source_refused).

format: uri-template costs time in step with a caller's text. The library's check was
quadratic on a run of { and held the interpreter's lock, so 80,000 characters stopped a worker's
event loop for 2.4 seconds past a 200 ms validate.schema timeout. It is now matched by the same
engine as pattern, accepting what the library accepts, and stops at the bound. Every other
standard format, and a format a pack contributes, is handed no text longer than 8192 characters; a
longer text fails the format. base64, humane-duration and dirigent's other own formats are not
limited, since they were measured linear.

What a poll or an attempt raises past its block is scrubbed, a stored value too deep still reads, and a NUL in a list of text is refused. A polled source's last_error and the worker's log line for an error nothing recorded carried str(error) as it was; both now lose every secret the poll or the attempt opened. A row stored past what pydantic walks answers a read with error_params, output or fields cut at the bound rather than a 500. A NUL in a list[str] body field such as POST /pipelines/$prune's keep, which PostgreSQL refuses with a 500, is refused 422. Engine.run_unit now raises UnitRaised.

A secret a waiting block quotes is scrubbed. A running probe's or a sensor's message was kept on the attempt and logged as it was written, unlike a failure's. It is now scrubbed the same way.

A cancel reaches a daemon only the worker can, and the schema changed. In the shipped stack $cancel ran on the server, which has no route to the docker.* daemon, so a docker.run container kept running after its attempt was marked cancelled. An attempt cancelled without its remote agreeing now stays marked in step_attempts.remote_cancel_since, and a worker the run's tags allow tells the remote on its sweep, for ten minutes. The baseline migration is edited in place, so dg db upgrade does not carry the column: recreate the database, or add it by hand.

A checkout's files cannot write terminal escapes through dg. The text of a
.dirigent/profiles.yaml reached the terminal raw through a profile's refusal, so a repository
could clear the screen, set the title or write the clipboard of whoever ran dg in it. A profile's
refusal now has every control character removed, and a profile name that is not valid is one of
those refusals rather than an uncaught error. A malformed dirigent.yaml, and a document that is
not text, are now refused with a record (cli.source_refused) rather than a traceback.

Login counts an IPv6 address by its /64. One host is usually handed a whole /64, so keying on the full address did not bound what it could spend on Argon2.

An array's subscript in a shell command is held to an integer, whatever the command declares.
A ${...} in a[${v}]=1, a[${v}]+=1 or ${a[${v}]} used to take any value when declare -A a
appeared anywhere in the text, including in a branch not taken, a function, a subshell, before an
unset, or on an array already indexed, where bash still evaluates the subscript and a caller's
$(cmd) ran. The reference now has to be an integer in every such place. A command keying an
associative array on a caller's text is refused when the step starts, and takes the value through
argv, which involves no shell.

A secret is scrubbed percent-encoded and JSON-escaped as well. A connection secret written into an error as part of a URL, or inside a JSON body, read differently from the secret and passed through. Both spellings now count.

One polled source cannot stop the claim for the rest. A sensor source with no cadence of its own, whose sensor is not installed on the worker, raised while it sat at its max_in_flight bound, and every tick on that worker failed until its runs settled, so no polled source on it was looked at. It now waits out a lease and the others are claimed.

A reply's content_type must be a media type, and not one a browser runs as a script. A value with a line break was accepted at apply and then failed the reply with an unhandled protocol error. A script type such as text/javascript was served on the UI's origin, where the reply's sandbox does not govern a page loading it in a <script>. Both are refused at apply (source.reply_bad_content_type), and an object body_from names that is stored as one of them is sent as application/octet-stream. A document with a reply of a script type has to change it.

A read_only SQLite connection cannot write its file. PRAGMA query_only left two pragmas able
to: PRAGMA journal_mode = WAL rewrote the database header, and PRAGMA wal_checkpoint moved a
write-ahead log into the file. A read_only connection now opens its file with mode=ro as well.
A file it names that does not exist is refused rather than created empty.

The security page names what it left out. A trap action, mapfile -C and PROMPT_COMMAND hand a value on as eval does; a provider secret shared between two sources lets a captured call be taken once at each; and a viewer may call POST /api/v1/schedules/$preview.

dg source call refuses a path that would change the host. It joined the instance's url_path onto the base URL as text, so @other:port/x sent the call and its body to another host. A path that does not start with / is now refused (cli.source_path_refused).

A stored template past a deeper stack does not break a listing. A template's compile depends on how deep the stack is, so one a read could not compile raised RecursionError from every listing of pipelines and get_pipeline. That pipeline is now listed without its step shapes, as one that no longer validates is.

The playground answers rather than fails on what a caller may send. A status of 100 to 199 dropped the connection and logged a traceback, and a JSON body about 900 levels deep answered 500, both to an anonymous caller. status now starts at 200, refused 422 below it, and a JSON body nested past the 128 levels an endpoint takes is reflected as text (body_kind: text).

A reply and the playground refuse what lighttpd and LiteSpeed act on. x-sendfile2, x-lighttpd-* and x-litespeed-* join x-accel-* and x-sendfile in what a reply and the playground's response-headers refuse.