Skip to content

fix(server): compile caller fragments under restricted mode at append scope - #1199

Merged
sagarswamirao merged 28 commits into
mainfrom
sagark/security-review-f2
Sep 24, 2026
Merged

sagarswamirao merged 28 commits into
mainfrom
sagark/security-review-f2

Conversation

@sagarswamirao

@sagarswamirao sagarswamirao commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

The reported finding

From a multi-tenant security review, rated HIGH:

/compile and malloy_compile compile caller source unrestricted. The /query
path uses loadRestrictedQuery, which rejects .table(), .sql(), import
and given:. The compile path does not. Malloy resolves SQL/table schemas at
compile time by running a DESCRIBE against the connection, so compiling
run: duckdb.sql("SELECT * FROM read_csv('/etc/secret')") executes against the
sandbox: a file-existence / column-name oracle, plus SSRF via
read_csv('https://...'). It returns no row data, so this is disclosure and
SSRF rather than bulk exfiltration.

Reproduced

Compiled hostile text through the real Environment.compileSource at append
scope against a real DuckDB package. Three signals:

The file is read. run: duckdb.sql("SELECT * FROM read_csv('<tmp>/secret.csv')") -> { group_by: ssn }
returns problems: [] -- a clean compile, so the header resolved.

A file-existence oracle. The same text against a path that does not exist:

IO Error: No files found that match the pattern ".../nope.csv"
LINE 1: DESCRIBE SELECT * FROM (SELECT * FROM read_csv('...

The DESCRIBE is quoted back verbatim, which is also the proof that compile-time
schema resolution executes.

A schema oracle. ssn resolves against the CSV's real header; no_such_column
returns 'no_such_column' is not defined.

read_csv accepts https://, so SSRF rides the identical path.

A second vector the review did not name

URL_READER does a bare fs.readFile with no containment, so append-scope
import "../../outside.malloy" compiled cleanly: it read, compiled and made
queryable a Malloy file outside the package tree, including whatever connection
that file declares. import at append scope is an independent arbitrary-file-read
vector, and a DuckDB sandbox flag cannot close it. It has its own test.

What this changes

assertNoRestrictedConstructs() in a new compile_restriction.ts, invoked for
scope === "append" only, before the compile.

It runs a separate restricted compile and acts only on
code: 'restricted-construct-forbidden'; every other diagnostic is left to the
real compile, so positions and problem sets for legitimate text are unchanged. A
non-MalloyError throw is rethrown rather than treated as a pass, because an
infrastructure failure must not open the gate. The base model is loaded without
the caller's text, so a caller cannot widen the namespace it is judged against.

An earlier revision fell back to no base model when the named one could not
be loaded. That was a fail-open, not a safe default: name!type(...) and the
sql_* family are classified inside computeExpression(fs) and need a resolved
FieldSpace, so with no base model they are never classified and the gate returns
clean on text it exists to refuse. The gate now requires a Model rather than
accepting Model | undefined, which makes that a type error instead of a
convention the next caller can break.

Refusals raise CompileRefusedError, the existing house type, which maps to 400.

Failing to load the base model is three different answers

Requiring a base model means deciding what happens when it cannot be loaded, and
refusing uniformly would have been wrong. getModel() throws MalloyError on
any base-model problem, including failed-to-fetch-table-schema -- which is what
a warehouse being unreachable looks like. A 4xx tells a client it sent something
bad and should not retry, so an outage would have been reported as the caller's
fault, with a deliberately detail-free body that could not tell them otherwise.

Failure Answer Why
Schema could not be fetched 503 A dependency is unreachable. The package scope already maps its analogous failure this way.
Base model does not compile 400, with the problems The author's to fix, and the diagnostics describe a model the caller already has, so they disclose nothing new.
Missing file, permission, anything else 400, detail-free The caller named a model this server cannot load.

The detail stays server-side in every case: the model path is absolute inside the
container, so returning it would answer "does this file exist, and is it
readable" for any path a caller names -- the same shape of oracle this gate
exists to close.

Scope restricted, and scopes deliberately left alone

append is restricted. Its text is a fragment checked against a curated
model, inherits that model's namespace, and has no legitimate reason to declare
its own data roots.

file and package are untouched. There the source IS the model file, and
import / connection.table(...) / connection.sql(...) are how any model
declares what it reads. Restricting them would make an ordinary package
un-authorable -- the repo's own examples define sources with duckdb.sql(...) at
file root -- and would break the dashboard write path, which compiles at file
and would start refusing every dashboard containing a source definition.

Two skills instructed the refused pattern

The skills are what an agent loads, so they decide whether the workflow works,
and both pointed at the scope this now refuses.

malloy-dashboards told an agent to compile a not-yet-saved dashboard at the
default scope. A dashboard file opens with an import, so the documented happy
path failed twice: on the import, and on the model path that does not exist yet.
It now points at file scope for the new-file case, with the reason.

malloy-modeling presented append as the way to add a new definition, while
search_database_schema hands the agent a source: x is conn.table(...) line to
start from -- which append refuses. It now says so and names the scopes that
accept it.

AGENTS.md and docs/ai-agents.md were already updated for the refusal; this
carries the same correction into the two copies agents actually read.

One thing worth knowing for any future change here

runtime.loadModel(url, { restrictedMode: true }) silently drops the flag.
It destructures only refreshSchemaCache/noThrowOnError and forwards a fixed
field list; extendModel drops it too. Verified empirically: the flag has no
effect there and the hostile text compiles fine. Only loadRestrictedQuery and a
direct Malloy.compile honour it. So the obvious implementation of this fix
looks applied and does nothing.

loadRestrictedQuery itself was not usable here: it compiles a query, while the
documented append fragment forms (source: check is X extend {...},
query: check is ...) are statements with no runnable query, so routing append
through it would break the primary authoring workflow. Hence
Malloy.compile({ restrictedMode: true, model, method: "extendModel" }).

The restriction is the compiler's own classification rather than a pattern match,
for the reason authorize.ts already states about gates: a rejecter that matches
text can be walked past in a spelling it never heard of.

Testing

compile_restriction.spec.ts, 16 cases: the oracle forms above, the import
traversal, legitimate append text that must still compile, and the unrestricted
scopes.

Verified load-bearing by reverting the gate: 8 fail / 7 pass. The 7 that still
pass are exactly the legitimate-authoring and unrestricted-scope cases, which
should pass either way. Restored: 16/16.

One real regression surfaced and is fixed: compile_scopes.spec.ts submitted a
model opening with import at append scope as a control for an unrelated
"Cannot redefine" collision. Since append-scope import is precisely the
traversal vector proved above, the control drops its import line rather than the
gate making an exception -- the test's actual subject is unchanged and still
asserted.

Gates on the merged tree: build, typecheck and lint all exit 0;
test:server is 3934 pass, 3 skip, 0 fail across 179 files.

Known gaps, not addressed here

Raised in review and worth naming rather than leaving implied:

  • The suite exercises 4 of the 7 restricted rules. sql_*, given: and
    ##! are untested. sql_* matters most, because it shares the
    FieldSpace-dependent classification path with the construct that motivated
    requiring a resolved base model.
  • api-doc.yaml is not in this diff. The scope description still reads as
    though an invalid enum value is the only 400 it can produce, and a previously
    200 request class now returns 400. External clients generate from that spec.
  • Neither refusal path increments a metric. With the 503 split above a
    dependency outage is at least no longer indistinguishable from bad input by
    status code, but a counter with a reason label would separate
    restricted_construct from base_model_load_failed in the data.
  • The gate adds a second parse and translate inside the per-package mutex.
    Schema round trips are not doubled, since the schema cache lives on the shared
    connection, so this is CPU and lock-hold rather than backend load. Loading the
    base model once and reusing it for both compiles would recover most of it.
  • A missing model path now returns 400 where a present one returns 200,
    which distinguishes present from absent within the package tree (bounded by
    assertSafeRelativeModelPath). Much narrower than the oracle being closed, but
    new, and not pinned by a test.

… scope

Malloy resolves a source's schema at compile time, so /compile and the
compile_model MCP tool reached the connection, the filesystem and the network
without running a query. Against DuckDB's external access that made an
unrestricted compile an oracle: read_csv('<path>') distinguished an existing
file from a missing one by its error text, resolved the columns of whatever it
read, and an https:// path issued the request. A caller-supplied import
escaped the package directory the same way, because the URL reader resolves an
import path against the filesystem with no containment of its own.

Compile caller text at scope "append" under Malloy's restricted mode, the same
containment the query path already applies via loadRestrictedQuery. Scope
"append" is the only scope whose source is a fragment checked against a curated
model, so it has no legitimate need to declare its own data roots.

Scopes "file" and "package" are deliberately left unrestricted: there the
source IS the model file, and import plus connection.table(...) /
connection.sql(...) are how any model declares what it reads. Restricting them
would make an ordinary package un-authorable and would break the dashboard
write path, which compiles at "file".

The gate is a separate restricted compile that acts only on
restricted-construct rejections; every other diagnostic is left to the
unrestricted compile, so positions and problem sets an author sees are
unchanged.

Signed-off-by: Sagar Swami Rao Kulkarni <sagarswamirao@gmail.com>
…w-f2

Signed-off-by: Sagar Swami Rao Kulkarni <sagarswamirao@gmail.com>
Comment thread packages/server/src/service/environment.ts Outdated
Falling back to no base model lowered the gate for two of the seven restricted
constructs. Five are refused on sight, but name!type(...) and the sql_* family
are classified inside getExpression(fs), which needs a resolved FieldSpace: with
no base model a fragment referencing a base source never resolves it, so the
expression is never evaluated, the construct is never classified, and the
unrestricted compile then runs it for real.

A base model that will not load is an infrastructure failure rather than an
empty namespace, and the caller's own text is not what failed, so the request is
refused instead. assertNoRestrictedConstructs already makes this argument about
its own catch.

Two cases added: the raw-SQL function call itself, and the same fragment with an
unloadable base model, which is the one that passed before.

Signed-off-by: Sagar Swami Rao Kulkarni <sagarswamirao@gmail.com>
Comment thread packages/server/src/service/environment.ts
Comment thread packages/server/src/service/environment.ts Outdated
Comment thread packages/server/src/service/environment.ts
The module header claimed this was worth closing at the compiler rather than
only at the sandbox, which reads as a containment boundary. It is not one:
`scope` is a request-body field the caller chooses and the three scopes carry
no authorization difference, so a caller refused at append can ask for file.
That predates this module -- file and package are ungated by design, since there
the text IS the model -- but the framing should not claim more than it holds.

The refusal message also named the scope that would accept the text. A 400 is
where someone probing gets told which field to change, so it now points at the
documentation instead, and the spec asserts the scope is not named.

Signed-off-by: Sagar Swami Rao Kulkarni <sagarswamirao@gmail.com>
…n disk

The fail-closed refusal returned the absolute container path of the model and
the loader's own message in a 400 body. Varying the model name turned it into a
package-tree existence and readability oracle -- the same shape of disclosure
this gate exists to close, and the one case the spec's no-leak assertion did not
cover. The detail moves to the server log; the caller gets the model name they
supplied.

The gate's parameter was Model | undefined. Several constructs it refuses are
classified inside getExpression(fs) and need a resolved FieldSpace, so a caller
handing it no model gets a clean pass on text that should be refused. Requiring
a model makes that a type error rather than a convention.

Two comments described behaviour this change had already falsified: the
read-fallback said a missing model proceeds with empty content, and the MCP
tool said a modelPath typo yields a normal result.

Signed-off-by: Sagar Swami Rao Kulkarni <sagarswamirao@gmail.com>

@housejester housejester left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Read the whole change plus the six-comment thread above. The fix is well shaped: using the compiler's own restricted-construct-forbidden classification rather than a byte match, running the gate as a separate compile so ordinary diagnostics keep their positions and wording, rethrowing a non-MalloyError instead of treating it as a pass, typing model: Model rather than Model | undefined so the FieldSpace hole can't silently come back, and the revert check (8 fail / 7 pass, and the 7 are exactly the ones that should pass either way). The SCOPE, STATED HONESTLY header is the right landing for the containment question.

Everything below is about the edges of the new namespace, and about workflows the gate refuses that this repo's own skills still instruct agents to perform. I had no Node/Bun runtime available, so nothing here was executed — the two namespace items are confirmation requests with the test that settles each, not claims.

What this changes about append

append stops being "your text concatenated into the model file and compiled once" and becomes "your fragment, type-checked under restricted mode against the model's compiled published surface, then concatenated and compiled." Two namespaces where there was one, plus a precondition:

before after
namespace judging the caller's text the model file's text gate: the model's compiled Model; real compile: still the file text
legal construct set anything Malloy accepts minus the seven restricted constructs
modelPath that does not exist 200, empty namespace 400
modelPath that exists but does not compile 200 + the model's own diagnostics 400, reason withheld

The load-bearing invariant is no longer "a base model exists" — it is namespace parity: the gate's namespace must be a superset of what the real compile resolves.


Worth acting on

[HIGH — fail-open, same shape as the one fixed in 1af0b37d] The gate judges the fragment against baseModel via method: "extendModel"; the real compile judges it against modelContent + "\n" + source. Wherever the gate's namespace or compiler context is narrower, an enclosing reference fails to resolve, ExprFunc.getExpression(fs) is never reached, name!type(...) / sql_* are never classified, restrictedRejections() comes back empty, and the unrestricted compile runs the construct. Two narrowings I can point at from this repo:

  • export { … }. docs/discovery-and-access.md:37 — "A model with no export { … } exports all of its locally-declared top-level sources; declaring export { customers } … keeps imported/internal helpers out." If extendModel exposes exports rather than full ModelDef.contents, then run: internal_helper -> { group_by: v is read_csv!string('/etc/passwd') } is unresolvable in the gate and resolvable in the concatenated file — your exact payload, reached through a non-exported name instead of an absent model.
  • ##! compiler flags. The gate's synthetic internal:// document has no ##! line; the concatenated file does. Five bundled example models open with one (examples/storefront/givens.malloy:29, examples/governed-analytics/orders.malloy:11), so a package enabling an experiment is the normal case here rather than a corner. If flags don't carry into extendModel, a package with ##! experimental.sql_functions gets sql_number(...) rejected inside the gate as experiment-not-enabled — no restricted code, gate passes — and accepted by the real compile. That is the same backstop you identified as carrying the sql_* half, being relied on from the other side.

Could you confirm whether Malloy.compile({ model, method: "extendModel" }) carries the full ModelDef.contents and the source model's compiler flags? Either way it is worth pinning rather than reasoning about, since it is a malloy-side property this gate now depends on: two cases in compile_restriction.spec.ts — a base model with export { base_source } plus a second non-exported top-level source, and a base model opening with ##! experimental.sql_functions — each carrying a read_csv!string(...) / sql_number(...) payload asserted refused.

[MED-HIGH — two documented workflows now return 400, and the skills still teach them] skills/ is symlinked into .claude/skills/, so these are what an agent in this repo reads:

  • skills/malloy-dashboards/SKILL.md:56-65 tells an agent to compile a new dashboard "at the path the file will have," and says outright: "(The third scope, append, is the default and is what a not-yet-saved file gets.)" That request now fails three independent ways — the path does not exist yet, step 4's first line is import { order_items, products } from '../storefront.malloy' (:44, :83, :133), and step 3's filter declarations are given:. All three compiled before; the test "refuses an import that would borrow another model's surface" is itself the proof the import leg used to work. The dashboard write path is correctly protected (dashboard.controller.ts:198 compiles at "file") — it is the documented pre-save check that breaks.
  • skills/malloy-modeling/SKILL.md:152 — "Adding a new definition or query: the default (scope: "append")". A new source rooted in a table is the most common new definition in the modeling loop and is now refused. The escape hatch is right, but it currently exists only in the AGENTS.md / docs/ai-agents.md lines this PR adds.

Recommend updating both in this PR: dashboards step 5 to send a not-yet-saved file to "file", and malloy-modeling step 1 to carve out "a new definition that declares its own data root or import". compile_scopes.spec.ts having to drop its import line is the same signal arriving from inside the suite.

[MED — the debug loop loses its diagnostics, and the 400 says nothing] Separate from the missing-path case you weighed in the 19:41 comment and settled via option 3: environment.ts:973-996 also collapses "the file exists and does not compile" into that same reason-free refusal. That is the case append is most useful for — a package whose model is broken (get_status stale: true, the malloy-debug loop) previously got the model's compile errors back as ordinary diagnostics and now gets could not be loaded to check it against, with the cause server-side only. The withholding in f1e857be is right for a caller-named path, but a path that resolves to a real package file discloses nothing new — scope: "package" with no source already returns every file's diagnostics, including files hidden from discovery. Recommend splitting the two: file absent ⇒ refuse opaquely as now; file present but getModel() threw ⇒ return the base model's problems as diagnostics, which is what append did before. The diskTarget?.isFile() check at environment.ts:700 is the existing pattern for the distinction.

Smaller notes

[MED — the new docs under-enumerate what is refused] AGENTS.md:207, docs/ai-agents.md:75 and COMPILE_DESCRIPTION all name import, connection.table(...), connection.sql(...) and "raw-SQL function forms" — three of the seven in your own enumeration. given: and ##! are refused and documented nowhere, so an author declaring a given at append hits a refusal no doc surface predicted. Two untouched surfaces: api-doc.yaml:5624, the scope enum description that AGENTS.md §7 points unattended agents at as the complete spec, still describes append as plain concatenation with no mention of the refusals or the new 400; and docs/authorize.md:430 — "/compile still compiles unrestricted… Closing this (restricted compilation on /compile, as on /query) is tracked as a follow-up" — is the one doc stating this exact posture, and this PR is the follow-up it names. Worth updating to what actually shipped: append refused, file/package still unrestricted and caller-selectable, keep /compile behind the trusted tier.

[MED — no release note] RELEASE_NOTES.md asks for an ## [Unreleased] — title section "in the PR that changes the behaviour" for breaking changes and migration steps. A 200→400 on the default scope of a public endpoint, for text that compiled yesterday, is one. Cheaper to write here than to backfill.

[LOW] logger.error at environment.ts:983 fires on a caller typo in modelPath — an unauthenticated 400 emitting ERROR-level logs at whatever rate a caller likes; warn matches what it is.

[LOW] On the redundant base-model compile you already logged as a follow-up: one thing to fold into it when you get there — it sits inside the per-package mutex, which is mutually exclusive with installPackage's swap and deletePackage's retire, and it reintroduces the read-1/read-2 asymmetry inverted (read 1 succeeds, a transient failure on read 2 now 400s a request that would have served). Compiling from the modelContent already read at environment.ts:645 removes both along with the extra compile.


Top three before merge: confirm extendModel namespace/flag parity or pin it; update the two skills that currently instruct agents into a guaranteed 400; split missing-file from does-not-compile so the debug loop keeps its diagnostics.

Reviewed with Claude Code — session codename Bashful Vole.

…d correct the skills

The base-model catch refused uniformly, so a warehouse being unreachable became
a caller-fault 400 telling the client not to retry. getModel() throws MalloyError
on any base-model problem, including failed-to-fetch-table-schema, and the catch
did not look at which.

Three outcomes now, matching what each failure actually is: a schema fetch that
could not reach its source answers 503, the same class the package scope already
gives its analogous failure; a base model that does not compile answers 400 with
the problems attached, since they describe a model the caller already has and
disclose nothing they cannot read; anything else keeps the detail-free refusal.

Two skills instructed the pattern the gate refuses. The dashboards skill told an
agent to compile a not-yet-saved dashboard at append scope, but a dashboard file
opens with an import, so the documented happy path failed on the import and
again on the path that does not exist yet. The modeling skill presented append
as the way to add a new definition while search_database_schema hands back a
connection.table(...) line, which append refuses. Both now point at file or
package scope for those cases. These are the copies agents load, so they decide
whether the workflow works.

Also corrects the method name in three comments: computeExpression, not
getExpression.

Signed-off-by: Sagar Swami Rao Kulkarni <sagarswamirao@gmail.com>
skills/*.md are compiled into a tracked asset that a spec pins in sync, so
editing a skill without regenerating fails the bundle check on every platform.
The two scope corrections in the previous commit needed this.

Signed-off-by: Sagar Swami Rao Kulkarni <sagarswamirao@gmail.com>
…ends on

The gate judges a fragment against the base model's COMPILED form via
extendModel, while the real compile judges it against the file text. Anywhere
the gate's namespace is narrower, the enclosing reference does not resolve, the
construct inside it is never classified, and the unrestricted compile runs it --
the same shape as the no-base-model hole, reached through a name the gate cannot
see.

Two narrowings were raised as possible: a source left out of an export list, and
a package whose model opens with a compiler flag. Neither exists. initModelDef
seeds from every entry in ModelDef.contents rather than from exports, recording
exported only as metadata, and the translator pushes the base model's ## flags
into compilerFlagSrc before the AST step. Both are malloy's properties rather
than this module's, which is why they are pinned here instead of reasoned about:
compiling the gate against no base model turns both new cases red, alongside the
two existing FieldSpace-dependent ones.

Signed-off-by: Sagar Swami Rao Kulkarni <sagarswamirao@gmail.com>
@sagarswamirao

Copy link
Copy Markdown
Collaborator Author

The append-scope gate is the change. compile_model and POST /compile default to append, and that text is a fragment against a published model, the same class of caller text loadRestrictedQuery already refuses. file and package staying on runtime.loadModel is the rest of the rule: that text is the model file. This is not a half implementation of "restrict every scope."

What is still not in the diff, and has to land before this matches the behavior:

  1. api-doc.yaml (scope on the compile request, around the default: append description). It still says append is "the historical behavior" of concatenating and compiling, and that the only 400 is an invalid enum value. Append now refuses import, connection.table(...), connection.sql(...), given:, ##!, and the raw-SQL function forms with restricted-construct-forbidden, and a base model that cannot be schema-fetched is a 503. Clients generate from this spec.

  2. docs/authorize.md still says /compile compiles unrestricted and that restricted compilation on /compile is a follow-up (the note near the end of the page, and the "false lock does not cover /compile" callout above it). This PR is that follow-up for append only. The page should say append is restricted, and that file and package are still unrestricted and caller-selectable.

  3. RELEASE_NOTES.md. A 200 to 400 on the default scope of a public endpoint, for text that compiled before (import, connection.table(...), a new source rooted in a table), needs an Unreleased note and the migration: those constructs belong at file or package.

  4. A refusal counter. Neither refusal path increments a metric, so a restricted-construct 400 is indistinguishable from a bad fragment in the data. One counter with a reason (restricted_construct versus base_model_load_failed) is enough.

The skills, the namespace and ##! parity tests, and returning a broken base model's own problems are already on the branch. The extra base-model compile inside the per-package mutex can stay a follow-up: compiling from the modelContent already read for the real compile removes it.

The skills package publishes separately, and CI requires its declared version to
be ahead of npm whenever published content changes. The two scope corrections
edit files that ship in its tarball, so without this the release job skips the
package silently and the edits never reach an agent.

Signed-off-by: Sagar Swami Rao Kulkarni <sagarswamirao@gmail.com>
…t pins

The scaffolder's published-content check watches packages/skills/package.json,
because the workspace it writes pins a skills version. Bumping skills changes
what this package scaffolds, so its own version has to move too or the release
job skips it and the new pin never ships.

Signed-off-by: Sagar Swami Rao Kulkarni <sagarswamirao@gmail.com>
… read them, and count them

The spec, the authorize page and the release notes all still described the
behaviour this PR replaced, and the two refusal paths emitted no metric.

api-doc.yaml is what external clients generate from, and its scope description
called append the historical concatenate-and-compile with an invalid enum value
as its only 400. It now names every refused class, including given: and ##!
which were documented nowhere, points at file and package as the unrestricted
alternatives, and records that a base model which cannot be schema-fetched
answers 503 rather than 400.

docs/authorize.md carried the one statement of this exact posture -- that
/compile compiles unrestricted and closing it is a follow-up. This PR is that
follow-up for append, so the entry now says which scope is restricted and why
the other two are not.

RELEASE_NOTES.md gets an Unreleased section: a 200 becoming a 400 on the default
scope of a public endpoint, for text that compiled before, is a migration its
readers need, and the two workflows it changes in practice are a not-yet-saved
dashboard and a new source rooted in a table.

publisher_compile_refusals_total separates restricted_construct from
base_model_load_failed. Without the label a dependency outage and a caller
sending forbidden text are one spike on the same endpoint, and the one that
needs paging looks like the one that does not. The base-model log also drops
from error to warn, since a caller typo reaches it and an unauthenticated 400
should not emit ERROR at a rate the caller picks.

Signed-off-by: Sagar Swami Rao Kulkarni <sagarswamirao@gmail.com>
The append-scope restriction refuses a `given:` declaration and a `##!`
compiler-flag annotation, but only the spec and the release notes said so.
AGENTS.md, docs/ai-agents.md, docs/authorize.md and the compile_model tool
description all stopped at the data-root constructs, so an agent reading the
tool description met these two refusals as a surprise 400.

The two are not symmetric, and the flat list obscured it. `##!` is refused
outright. `given:` refuses only a DECLARATION -- a fragment may still read the
model's own givens as `$NAME` -- so documenting it as "given: is refused" would
push an author to `file` scope for text that compiles fine at `append`.

Adds the pair to compile_restriction.spec.ts, which covered neither. The
`given:` case asserts both halves, because a test pinning only the refusal
leaves the `$NAME` allowance free to regress into a blanket ban that reads
identically from outside. It is judged against a model that enables the givens
experiment: against one without the flag the same fragment returns an ordinary
experiment-not-enabled diagnostic, which would pass a refusal-only assertion
while proving nothing about the gate.

Signed-off-by: Sagar Swami Rao Kulkarni <sagarswamirao@gmail.com>

@housejester housejester left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Follow-up pass at e560d55c, run this time with a runtime (bun 1.4.2): compile_restriction.spec.ts and compile_scopes.spec.ts pass 36/0 locally. Most of this morning's list is closed:

  • The export and ##! parity cases are pinned. I also checked names brought in by import and by import { x } from, and both are classified and refused.
  • Both skills are corrected.
  • given: and ##! are named in every doc surface, and api-doc.yaml, authorize.md and the release note are in.
  • The failure log is now warn, and the refusal counter is in.

Two things are left. The first reopens the namespace-parity invariant, on the parse side this time.

[HIGH, same fail-open family as 1af0b37d] The gate and the real compile parse different text

The gate compiles source on its own. The real compile runs ${modelContent}\n${source} (environment.ts:677). A fragment that is a syntax error on its own, but continues the model's last statement, gets no restricted-construct-forbidden from the gate, so the gate passes it and the unrestricted compile runs it.

Against the suite's own BASE_MODEL (which ends in ... extend { ... }), at scope: "append":

 extend { join_cross: s is duckdb.sql("SELECT * FROM read_csv('<path>')") }
run: base_source -> { group_by: s.ssn }
fragment result
<path> exists 200, problems: []
<path> missing 200, invalid-sql-source: Invalid SQL, IO Error: No files found that match the pattern "…/nope.csv", with the DESCRIBE echoed
group_by: s.not_a_col 200, 'not_a_col' is not defined
extend { dimension: v is read_csv!string('<path>') } + a run: 200, problems: []
control: the same duckdb.sql(...) as a standalone run: CompileRefusedError

So the file-existence and column oracles from the description come back in full at the default scope. The setup needs only a model whose last statement is a source definition, which is the usual case: most bundled models end in }, and examples/governed-analytics/internal.malloy ends in a bare duckdb.table(...).

On severity: this is no new exposure while file stays open to the caller, as the SCOPE header already says. But it breaks the premise the gate rests on, and it becomes a real hole the moment scope authorization lands and append is the only scope a read-only caller gets.

The outcome I'd want is that the gate judges the same unit that runs, and that a gate compile which stopped before classifying anything (here, a parse error) does not count as a pass. The mechanism is your call. One option: load the base model once, and run the real append compile as an unrestricted extendModel on it, offsetting diagnostic positions by the model's line count. That would also resolve the redundant-compile-inside-the-mutex follow-up. Either way, a spec case with the payload above would pin it.

[MED] The 503 split keys on a code that doesn't mean "unreachable"

In environment.ts:1024, failed-to-fetch-table-schema is also what a table that doesn't exist produces. With source: z is duckdb.table('no_such_tbl'), an append compile answers 503 "a problem reaching the data source", which tells the client to retry a permanent authoring error.

The reverse also happens: a conn.sql(...)-rooted model whose warehouse is down fails with invalid-sql-source, so that outage gets the 400.

Separately, the description says package scope "already maps its analogous failure this way", and it doesn't. Its 503 (:871) is for the compile worker pool being unavailable. At package and file scope the missing table comes back as an ordinary 200 diagnostic, which I checked.

I'd suggest one of these, and correcting that sentence either way:

  • drop the 503 branch and let these fall into the "does not compile" 400, or
  • classify on a real connectivity signal.

Smaller

  • [LOW-MED] On the broken-base-model case from this morning: the problems now arrive inside the 400's message string rather than as the structured diagnostics (model, line, character) append used to return. The debug loop gets the text but loses the coordinates. The absolute paths in that message are not a new disclosure, since file and package scope already return the same file:///… paths.
  • [LOW] The release note says a model "that does not exist, or that does not itself compile, answers 400 with the model's own problems". The does-not-exist case is deliberately opaque, so that sentence wants splitting.

Reviewed with Claude Code — session codename Spicy Dugong.

… 503 that meant two things

The gate reads the fragment by itself; the compile it guards runs
`${modelContent}\n${source}`. Text that is a syntax error ALONE but continues
the model's last statement once concatenated was therefore classified by
nothing, and the gate read that silence as approval. Against a model ending in
a source definition -- the usual case, and true of the shipped storefront and
governed-analytics packages -- ` extend { join_cross: s is duckdb.sql("...
read_csv(...)") }` plus a `run:` reached the filesystem through the DEFAULT
scope and came back with `problems: []`, restoring the file-existence and
column oracles the gate exists to close.

Handing the gate the concatenation does not work, and the comments say so
because it is the obvious thing to try next: `extendModel` judges text as an
extension of a model that already holds those declarations, so the model's own
text returns `Cannot redefine` for every source in it and the appended fragment
is never classified at all. The units stay different. What closes the hole is
the gate refusing when it could not PARSE what it was given: classification
walks a parsed tree, so unparsed text was never judged, and for a gate that
absence is not evidence of safety. An ordinary semantic error still passes
through as the caller's own diagnostic, because there the tree was walked.

One consequence beyond the security fix: every non-parsing fragment now answers
400, including a bare `view:`/`dimension:`/`measure:`, which previously came
back as a compile diagnostic. Both rejected it; the channel changed. The
refusal names what to send instead, and the fragment-techniques spec pins the
new contract.

Separately, the 503 on a base model that would not load keyed on
`failed-to-fetch-table-schema`, which a table that does not EXIST also
produces, so a permanent authoring error told the client to retry; a
`conn.sql(...)` model whose warehouse was genuinely down fails with
`invalid-sql-source` and took the 400 anyway. Wrong in both directions, and
Malloy publishes no code here meaning "unreachable", so these are one 400
carrying the model's own problems -- which is what `file` and `package` scope
already do with the same failure.

Verified against the SHIPPED packages rather than a fixture, because whether
the attack's precondition holds is a property of the models we ship: the new
integration spec loads examples/storefront and examples/governed-analytics into
a real environment, asserts the continuation is refused on both, that the
present-file and missing-file refusals are byte-identical and name neither the
path nor its columns, and that ordinary work still compiles AND returns real
rows from DuckDB. Disabling the parse-failure branch fails exactly those four
cases and leaves the five controls green.

Signed-off-by: Sagar Swami Rao Kulkarni <sagarswamirao@gmail.com>
@sagarswamirao

Copy link
Copy Markdown
Collaborator Author

Both findings are fixed in d3e1ca15. Thank you for the payload -- I reproduced it before changing anything, and it behaved exactly as you described: problems: [], at the default scope, against the shipped storefront package.

HIGH: the gate and the real compile parse different text

Fixed, but not by the mechanism you suggested, because that one does not work and it is worth recording why before someone else reaches for it.

Running the append compile as an unrestricted extendModel on the loaded base model -- or equivalently handing the existing gate the concatenation -- fails on the model's own declarations. extendModel judges text as an extension of a model that ALREADY holds them, so ${modelContent}\n${source} comes back as:

Cannot redefine 'customers'
Cannot redefine 'products'
Cannot redefine 'order_items'
'brand_suggest' is already defined, cannot redefine

and those abort the compile before anything in the appended fragment is classified. I tried it first, and the integration suite caught it: the continuation payload still passed, because the gate was now aborting even earlier than before. It is a second bypass wearing the shape of a fix.

So the two units stay different, and the gate instead refuses when it could not parse what it was given. Classification walks a parsed tree, so unparsed text was never judged -- and for a gate, that absence is not evidence of safety. It is keyed on Malloy's own syntax-error code (lang/parse-log.d.ts), not on message text. An ordinary semantic error still flows through as the caller's diagnostic, because there the tree WAS walked and the absence of a rejection is real evidence.

That covers the class rather than the instance: a continuation fragment is by definition a syntax error standing alone, whatever it continues and whatever it reaches for once concatenated.

One consequence you should weigh in on

This necessarily catches every non-parsing fragment, not only hostile ones. A bare view: / dimension: / measure: -- which docs/ai-agents.md already steers people away from -- now answers 400 with "send text that stands alone" instead of returning no viable alternative at input 'view:' as a diagnostic. Both rejected it before and after; the channel changed.

I think that is right, since the gate genuinely cannot check text it cannot parse, and the refusal says what to send instead. But it is a UX change beyond the security fix, so it is your call whether it wants softening. compile_fragment_techniques.spec.ts pins the new contract rather than having been deleted for being inconvenient.

MED: the 503 that meant two things

Dropped, taking your first option. You are right that it was wrong in both directions: failed-to-fetch-table-schema is also what a table that does not exist produces, so a permanent authoring error was telling the client to retry, and a conn.sql(...)-rooted model whose warehouse is genuinely down fails with invalid-sql-source and took the 400 anyway. Malloy publishes no code here that means "unreachable", so classifying on a real connectivity signal is not available today. These are now one 400 carrying the model's own problems.

You were also right that the description's claim about package scope was false -- its 503 is for the compile worker pool being unavailable, not for this. That sentence is gone rather than reworded.

LOW

The release note now splits the two cases: a model that does not COMPILE answers 400 with its own problems, and a model that does not EXIST answers 400 saying only that it could not be loaded, because naming the fault for a caller-supplied path would answer "does this file exist" for any path.

The structured-diagnostics point on the broken-base-model case is real and I have not changed it: those problems still arrive inside the 400's message rather than as model/line/character. Happy to take it in a follow-up if you want the coordinates back.

Testing

Written against the SHIPPED packages rather than a fixture, on the reasoning that whether the attack's precondition holds -- "the model's last statement can be continued" -- is a property of the models we actually ship. A fixture would be one I chose to be vulnerable. storefront.malloy ends in a query: block and internal.malloy in a bare duckdb.table(...), so both are covered and in different shapes.

compile_continuation.integration.spec.ts (new, 9 cases) loads both into a real environment and asserts:

  • the continuation payload is refused on both packages;
  • the present-file and missing-file refusals are byte-identical, and name neither the path nor the file's columns -- the oracle closed, not merely a refusal added;
  • the standalone raw-SQL form the gate always caught is still caught (the control that shows these cases test the new behaviour);
  • an ordinary fragment compiles clean and the query runs, returning real rows from DuckDB;
  • a misspelled field is still a diagnostic, so the parse-failure branch does not eat the authoring loop;
  • diagnostic positions still land past the model's own line count, which four doc surfaces promise.

Numbers: the four compile specs together are 53 pass / 0 fail; typecheck:server is clean. Disabling the parse-failure branch fails exactly the four continuation cases and leaves the five controls green.

On the wider suite: it sits at 82 failures here, and 79 of those are present on a clean tree in this worktree (broken test mocks -- storageManager.getDuckDbConnection is not a function and similar -- in theme, materialization and MCP). Of the three that appeared alongside my change, two (dashboard tile normalization > stays linear, a 15s wall-clock assertion, and a 4th case in an already-failing concurrency E2E) reproduce with my changes stashed, so they are not mine. The third is the bare-view: contract update above.

Three files conflicted, and two of them needed a decision rather than a
resolution, because main's `#(authorize)` / `#(access_filter)` split changed
facts this branch had written down.

RELEASE_NOTES.md: both sides added an `[Unreleased]` section at the same
anchor. Both are kept, this branch's first, since they are separate entries in
one release rather than alternatives.

docs/authorize.md: this branch warned that the `false` lock covers `/…/query`
but not `/…/compile`. Main makes the lock truth-evaluated on every route, so
that warning is now false and main's text replaces it; only the note that the
append-scope restriction is a separate axis survives, reworded. In the other
direction, main's known-limitation bullet still read "`/compile` raw SQL is not
gated ... tracked as a follow-up" -- this branch IS that follow-up, so that
bullet now describes the restriction and the continuation refusal instead.

docs/ai-agents.md: this branch's paragraph is a superset of main's line, so it
is kept whole.

Two things the merge broke that a conflict marker does not show. The generated
`packages/server/src/api.ts` predates main's `accessFilter` field, so
`model.ts` stopped typechecking until it was regenerated -- it is gitignored,
so nothing in the diff says so. And `skills_bundle.json` drifted against the 39
skills main rewrote; regenerated, since the sync spec fails on a stale one.

Verified with the project's own invocation rather than a bare `bun test`, which
uses a 5s hook budget the package install exceeds: `typecheck:server` clean,
and the four compile specs 53 pass / 0 fail under `--timeout 100000`.

Signed-off-by: Sagar Swami Rao Kulkarni <sagarswamirao@gmail.com>
…w-f2

Signed-off-by: Sagar Swami Rao Kulkarni <sagarswamirao@gmail.com>

@housejester housejester left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving at d7081098. d3e1ca15 closes both findings, and the merge after it touches nothing on the compile path. The four compile specs pass 53/0 locally at this head.

Continuation bypass. Refusing on the gate's own syntax-error covers the whole class, not just the payload I sent. A continuation has to start with a token that no top-level statement can start with, so it can't parse on its own. I checked that by running it rather than reasoning about it:

  • Refused: my original payloads, plus extend {…} after a bare source, + {…} refinement after a query:, -> {…} after a query: or run:, a , s2 is duckdb.sql(…) comma continuation, and include {…}.
  • Lexer-level failures (an unterminated string, stray bytes) also arrive as syntax-error, so they are refused too.
  • A semantic error followed by a raw-SQL statement is still classified and refused with the raw-SQL message, so the pass-through path opens nothing.
  • Ordinary fragments and names brought in by either import form are unchanged.

One correction on my side. What I suggested last round was extending the loaded model with the fragment alone, run unrestricted as the real compile, not handing extendModel the concatenation. My wording made the other reading easy, so that's on me. Nothing needs to change, because the parse-failure refusal is simpler and sufficient. The new code comments do present "the obvious alternative" as ruled out, so a later reader may take that as covering the fragment-only form too.

503. Dropping it was right. A missing table now answers 400 with the model's problems, and I confirmed that.

Softening the bare view: refusal: I'd leave it as is. It was never valid at append, the refusal says what to send instead, and docs/ai-agents.md already teaches wrapping it in query:. If it's ever worth softening, the gate could return its own parse errors as ordinary diagnostics and never run the unrestricted compile. That keeps the gate failing closed and brings back the old response shape, but the positions would need offsetting by the model's line count. That's a follow-up, not something for this PR.

Non-blocking follow-ups:

  • The "must parse on its own" rule is in docs/ai-agents.md and the release note, but not in the MCP tool description (COMPILE_DESCRIPTION in compile_tool.ts) or in the scope description in api-doc.yaml. AGENTS.md:233 still tells agents to send "a view body you are drafting… on its own", which is now a 400.
  • Structured diagnostics for a broken base model, which you've already offered to do.
  • The redundant base-model compile inside the per-package mutex.

Reviewed with Claude Code — session codename Spicy Dugong.

… that told agents otherwise

The parse-failure refusal landed in docs/ai-agents.md and the release note but
not on the two surfaces a client reads programmatically, and AGENTS.md was
worse than silent: it told agents to send "a view body you are drafting ... on
its own", which is now a 400. The MCP tool description and api-doc.yaml's
`scope` now carry the rule, and all three name the shapes that work -- a
top-level `query:` for a view body, a throwaway `extend` for a field.

api-doc.yaml also still described the 503 for a base model whose table schema
could not be fetched. That branch is gone: the code no longer splits on
`failed-to-fetch-table-schema`, which a table that does not exist produces too.
The spec now says what the code does -- a model that does not compile answers
400 with its own problems, and one that does not exist answers 400 saying only
that it could not be loaded.

The gate's own comment presented the concatenation as the ruled-out
alternative, which reads as ruling out the fragment-only form as well. It does
not: running the real append compile as an unrestricted extendModel of the
loaded base model stays a live option, and would also drop the second compile
from inside the per-package mutex, at the cost of offsetting diagnostic
positions. Recorded with that cost so the door stays visibly open.

Signed-off-by: Sagar Swami Rao Kulkarni <sagarswamirao@gmail.com>
…sagark/security-review-f2

Signed-off-by: Sagar Swami Rao Kulkarni <sagarswamirao@gmail.com>
…d format the new specs

Both packages sat at exactly their published npm versions, and the release
workflow publishes each one only when the local version is AHEAD of npm -- a
package whose version is unchanged is skipped, and a skipped job reports
success. This branch changes `malloy-dashboards` and `malloy-modeling`, so
without the bump that content would have shipped in no release while CI stayed
green.

`create-malloy-package` moves with it because it declares
`"@malloy-publisher/skills": "workspace:*"`: a skills change alters what the
scaffolder hands a new workspace, so publishing one without the other ships new
skills behind a stale scaffolder.

Also runs prettier over the two specs added on this branch; it reformatted those
and nothing else.

Signed-off-by: Sagar Swami Rao Kulkarni <sagarswamirao@gmail.com>
@sagarswamirao

Copy link
Copy Markdown
Collaborator Author

Thanks for the approval, and for running the class rather than the payload -- the refinement and comma-continuation cases are ones I had not tried.

Taking the correction: you did say the fragment alone, extended onto the loaded model, and I read it as the concatenation. That is on me as much as the wording; the two differ materially and I should have asked rather than inferred.

Two of your three follow-ups are in 141096f5, because one of them was worse than a gap.

AGENTS.md:233 was instructing agents into the 400. "Send that fragment on its own" is the sentence, in the file agents read to learn this tool. It now carries the rule and the two shapes that work -- a top-level query: for a view body, a throwaway extend for a field. COMPILE_DESCRIPTION and api-doc.yaml's scope say the same.

api-doc.yaml also still documented the 503. Not something you flagged -- I found it while adding the stand-alone rule, and it is a spec-level claim about a branch this PR deleted. It now says what the code does: a model that does not compile answers 400 with its own problems, one that does not exist answers 400 saying only that it could not be loaded.

The comment you flagged as over-claiming now separates the two readings: the concatenation is ruled out, the fragment-only extendModel is recorded as live, with its cost (offsetting positions by the model's line count) and its other payoff (dropping the second compile from inside the mutex) written next to it.

Left for follow-ups, as you suggested: structured diagnostics for a broken base model, and the mutex compile -- which the note now points at, since they are the same change.

On the bare view: refusal, agreed, leaving it. Your alternative (gate returns its own parse errors as diagnostics, never runs the unrestricted compile) is a better shape than softening the refusal, and it is the same offsetting problem as the fragment-only form, so the three follow-ups collapse into roughly one piece of work.

Verified at this head: typecheck:server clean, four compile specs 53 pass / 0 fail under the project's --timeout 100000. Note your merge landed while I was pushing; I merged rather than forced, and the release stamp put your #(access_filter) section at [0.6.0] with the append-scope note still [Unreleased] above it, which is the right split.

Versions

Both independent packages sat at exactly their published npm versions -- skills 0.1.22, create-malloy-package 0.0.18 -- and the release workflow publishes each only when the local version is AHEAD of npm, skipping quietly otherwise. This branch changes malloy-dashboards and malloy-modeling, so that content would have shipped in no release with CI green. Bumped to 0.1.23 and 0.0.19; the scaffolder moves with skills because it declares workspace:* on it. Lockstep server/sdk/app stay at 0.6.0, which the release stamps.

bunx prettier './packages/**/*.{ts,tsx}' --write reformatted the two specs this branch adds and nothing else.

… budget

Adding the stand-alone rule pushed COMPILE_DESCRIPTION to 2209 characters
against a 2150 budget, failing the protocol spec. That budget is not arbitrary:
it sits ~120 characters below 2271, the only length ever observed truncating in
a client, so raising it spends margin someone left deliberately.

The spec's own note says what to do instead -- cut reference material, keep the
contract rules ahead of it -- so the append bullet's three stacked refusal
clauses become one enumerated list. No rule is dropped: data roots, given:,
##! flags and the stand-alone requirement are all still named, with the two
shapes that work. 2035 characters now, which is below where this branch found
it (2101) rather than merely under the cap.

Signed-off-by: Sagar Swami Rao Kulkarni <sagarswamirao@gmail.com>
Four conflicts. Two were mechanical, two were a decision.

Both independent package versions: main is ahead of this branch on each, and
npm now carries exactly the versions this branch introduced -- skills 0.1.23 and
create-malloy-package 0.0.19 both published. Main's 0.1.24 / 0.0.20 are the
ones still ahead of the registry, so they win. Keeping this branch's numbers
would have left them level with npm, which is the case the release workflow
skips silently while reporting success.

RELEASE_NOTES.md: main added two `[Unreleased]` sections of its own. All three
are kept, this branch's first, since they are separate entries in one release.

skills_bundle.json is generated, so it was regenerated rather than hand-merged:
81 entries over 40 skills, one more skill than this branch had.

`packages/server/src/api.ts` is gitignored and generated from the spec main
changed, so the merge alone leaves it stale and typecheck fails on a file the
diff does not show. Regenerated.

Verified with the project's own commands: `typecheck:server` clean, and
`test:unit` 4251 pass / 0 fail over 186 files. That last one matters here
because main's index.malloy change (#1206) rewrites the governed-analytics
package this branch's continuation spec loads and queries.

Signed-off-by: Sagar Swami Rao Kulkarni <sagarswamirao@gmail.com>
@sagarswamirao
sagarswamirao enabled auto-merge (squash) September 24, 2026 04:48
@sagarswamirao
sagarswamirao merged commit 0c3601c into main Sep 24, 2026
20 checks passed
@sagarswamirao
sagarswamirao deleted the sagark/security-review-f2 branch September 24, 2026 05:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants