fix(server): compile caller fragments under restricted mode at append scope - #1199
Conversation
… 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>
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>
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
left a comment
There was a problem hiding this comment.
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 noexport { … }exports all of its locally-declared top-level sources; declaringexport { customers }… keeps imported/internal helpers out." IfextendModelexposes exports rather than fullModelDef.contents, thenrun: 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 syntheticinternal://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 intoextendModel, a package with##! experimental.sql_functionsgetssql_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 thesql_*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-65tells 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 isimport { order_items, products } from '../storefront.malloy'(:44,:83,:133), and step 3's filter declarations aregiven:. 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:198compiles 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.mdlines 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>
|
The append-scope gate is the change. What is still not in the diff, and has to land before this matches the behavior:
The skills, the namespace and |
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
left a comment
There was a problem hiding this comment.
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 byimportand byimport { x } from, and both are classified and refused. - Both skills are corrected.
given:and##!are named in every doc surface, andapi-doc.yaml,authorize.mdand 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, sincefileandpackagescope already return the samefile:///…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>
|
Both findings are fixed in HIGH: the gate and the real compile parse different textFixed, 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 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 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 onThis necessarily catches every non-parsing fragment, not only hostile ones. A bare 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. MED: the 503 that meant two thingsDropped, taking your first option. You are right that it was wrong in both directions: 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. LOWThe 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 TestingWritten 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.
Numbers: the four compile specs together are 53 pass / 0 fail; 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 -- |
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
left a comment
There was a problem hiding this comment.
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 aquery:,-> {…}after aquery:orrun:, a, s2 is duckdb.sql(…)comma continuation, andinclude {…}. - 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
importform 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.mdand the release note, but not in the MCP tool description (COMPILE_DESCRIPTIONincompile_tool.ts) or in thescopedescription inapi-doc.yaml.AGENTS.md:233still 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>
|
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
The comment you flagged as over-claiming now separates the two readings: the concatenation is ruled out, the fragment-only 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 Verified at this head: VersionsBoth 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
|
… 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>
The reported finding
From a multi-tenant security review, rated HIGH:
Reproduced
Compiled hostile text through the real
Environment.compileSourceat appendscope 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:
The
DESCRIBEis quoted back verbatim, which is also the proof that compile-timeschema resolution executes.
A schema oracle.
ssnresolves against the CSV's real header;no_such_columnreturns
'no_such_column' is not defined.read_csvacceptshttps://, so SSRF rides the identical path.A second vector the review did not name
URL_READERdoes a barefs.readFilewith no containment, so append-scopeimport "../../outside.malloy"compiled cleanly: it read, compiled and madequeryable a Malloy file outside the package tree, including whatever connection
that file declares.
importat append scope is an independent arbitrary-file-readvector, and a DuckDB sandbox flag cannot close it. It has its own test.
What this changes
assertNoRestrictedConstructs()in a newcompile_restriction.ts, invoked forscope === "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 thereal compile, so positions and problem sets for legitimate text are unchanged. A
non-
MalloyErrorthrow is rethrown rather than treated as a pass, because aninfrastructure 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 thesql_*family are classified insidecomputeExpression(fs)and need a resolvedFieldSpace, 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
Modelrather thanaccepting
Model | undefined, which makes that a type error instead of aconvention 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()throwsMalloyErroronany base-model problem, including
failed-to-fetch-table-schema-- which is whata 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.
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
appendis restricted. Its text is a fragment checked against a curatedmodel, inherits that model's namespace, and has no legitimate reason to declare
its own data roots.
fileandpackageare untouched. There the source IS the model file, andimport/connection.table(...)/connection.sql(...)are how any modeldeclares what it reads. Restricting them would make an ordinary package
un-authorable -- the repo's own examples define sources with
duckdb.sql(...)atfile root -- and would break the dashboard write path, which compiles at
fileand 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-dashboardstold an agent to compile a not-yet-saved dashboard at thedefault scope. A dashboard file opens with an
import, so the documented happypath failed twice: on the import, and on the model path that does not exist yet.
It now points at
filescope for the new-file case, with the reason.malloy-modelingpresentedappendas the way to add a new definition, whilesearch_database_schemahands the agent asource: x is conn.table(...)line tostart from -- which append refuses. It now says so and names the scopes that
accept it.
AGENTS.mdanddocs/ai-agents.mdwere already updated for the refusal; thiscarries 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/noThrowOnErrorand forwards a fixedfield list;
extendModeldrops it too. Verified empirically: the flag has noeffect there and the hostile text compiles fine. Only
loadRestrictedQueryand adirect
Malloy.compilehonour it. So the obvious implementation of this fixlooks applied and does nothing.
loadRestrictedQueryitself was not usable here: it compiles a query, while thedocumented append fragment forms (
source: check is X extend {...},query: check is ...) are statements with no runnable query, so routing appendthrough 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.tsalready states about gates: a rejecter that matchestext can be walked past in a spelling it never heard of.
Testing
compile_restriction.spec.ts, 16 cases: the oracle forms above, theimporttraversal, 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.tssubmitted amodel opening with
importat append scope as a control for an unrelated"Cannot redefine" collision. Since append-scope
importis precisely thetraversal vector proved above, the control drops its
importline rather than thegate making an exception -- the test's actual subject is unchanged and still
asserted.
Gates on the merged tree:
build,typecheckandlintall exit 0;test:serveris 3934 pass, 3 skip, 0 fail across 179 files.Known gaps, not addressed here
Raised in review and worth naming rather than leaving implied:
sql_*,given:and##!are untested.sql_*matters most, because it shares theFieldSpace-dependent classification path with the construct that motivated
requiring a resolved base model.
api-doc.yamlis not in this diff. Thescopedescription still reads asthough 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.
dependency outage is at least no longer indistinguishable from bad input by
status code, but a counter with a reason label would separate
restricted_constructfrombase_model_load_failedin the data.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.
which distinguishes present from absent within the package tree (bounded by
assertSafeRelativeModelPath). Much narrower than the oracle being closed, butnew, and not pinned by a test.