Skip to content

Releases: awslabs/tolap

Release list

TOLAP 1.2.0

Choose a tag to compare

@phspies phspies released this 29 Sep 14:27
60c6b32

Per-identity tool gating, and security fixes to the SQL pre-check and to how field and
row rules resolve qualified names. Upgrade every wrapper before you write toolRules:
versions up to and including 1.1.0 do not enforce them.

The schema stays at v1.0. Every addition is an optional property, so a v1.0 policy is
still a valid v1.0 policy.

Added

  • objectRules.toolRules (allowedTools, hiddenTools): per-identity MCP tool gating,
    enforced by every MCP wrapper entry point that takes a tool name whenever a policy carries
    it. The write pre-checks take an optional tool name (tool_name= / { toolName } /
    toolName:); when it is passed, the tool gate runs before the write checks, without
    requiring canQuery. A write without it, and a few query, field and endpoint helpers that
    take no tool name, apply no tool rules (canonical spec §16). No code change is needed to adopt it; a policy without
    toolRules is decided exactly as before. Merged like endpointRules and covered by the
    context signature. Upgrade wrappers before authoring toolRules: released SDK versions up
    to and including 1.1.0 do not enforce toolRules; enforcement ships in the next release
    (threat model R-9).
  • filter_tools / filterTools / FilterTools on the signed-context MCP wrappers, for
    tools/list handlers. They list nothing for a policy that grants no read or write
    permission, and drop null and non-string entries.
  • Console editor for tool rules.

A tool can declare its result already enforced (#33).
A tool whose data layer already ran the result pipeline, such as an ORM adapter, used to get
it run a second time by execute_with_enforcement / executeWithEnforcement /
ExecuteWithEnforcementAsync. hash masking is not idempotent, so every hashed field came
back hashed twice. Such a tool now returns a typed marker bound to the signed context:
EnforcedResult.for_context(data, context) in Python, EnforcedResult.forContext(data, context)
in TypeScript, and EnforcedResult.For(data, context) in .NET.

  • Only execute_with_enforcement / executeWithEnforcement / ExecuteWithEnforcementAsync
    honour a marker. The SQL path, the write path and the public post-execution method
    (post_execute / postExecute / PostExecuteResult) called directly unwrap it and run the
    full pipeline.
  • The marker is honoured only when signature enforcement is on, the context's signature
    verifies (re-checked after the tool runs), and the marker names that exact signature,
    compared in constant time. Any other marker (bound to another context, tampered, empty, or
    nested inside the data) is logged without the signatures, unwrapped, and its data runs the
    full pipeline, which is what happened before this change.
  • An honoured marker skips masking and the size ceiling. A row filter still runs unless its
    field is hidden, projected out or masked. deniedTags always runs, reading only the tag
    keys that are not masked; allowedTags runs unless a tag key is transformed. The relevance
    floor reads the first present, unmasked score key in precedence order, even a hidden or
    projected-out one the tool left in. A record with none is kept only when a score key is
    masked, or hidden or projected out and absent, and is otherwise dropped. A higher-precedence
    score key that was removed or masked never keeps a record whose visible score is low: with
    score hidden, a record kept on a high raw score is dropped when its visible similarity
    is low, and with score masked and similarity 0.1 visible, the record is dropped. This is
    an intentional fail-closed over-drop. A key that is both hidden and masked counts as hidden,
    for the floor and for deniedTags. Hidden-field removal, allowed-field projection
    and maxResults still run.
    Pre-execution checks are unchanged.
  • A marker is bound to a context, not to one call: it is honoured on every call made with that
    context until the context expires. It is a claim by the tool code, not proof that the
    pipeline ran, so return one only when the data layer really ran it. SQL pushdown alone does
    not qualify.
  • A record with lookalike keys (data, contextSignature) is ordinary data. There is no
    flag or argument that switches enforcement off. In TypeScript the honour check uses a
    private-field brand, so a Proxy or an Object.create(EnforcedResult.prototype) object is
    not honoured. A marker from a second copy of the package is recognized by its
    Symbol.for("tolap.EnforcedResult") brand and unwrapped, never honoured. In Python the
    signature is kept out of the marker's repr.
  • The result pipeline (apply_result_pipeline / applyResultPipeline /
    ApplyResultPipeline) and the registry wrappers never honour a marker. They unwrap
    markers nested in lists and string-keyed maps and enforce the contents, so a marker can
    never carry records past the hidden-field strip or through allowUnenforceableShapes
    whole. Other containers (for example a Python set, a TypeScript Map, a .NET
    ExpandoObject) are not searched.
  • New helper: apply_idempotent_result_steps / applyIdempotentResultSteps /
    EnforcementEngine.ApplyIdempotentResultSteps.
  • Shared fixture: fixtures/enforcement/already-enforced-results.json.
    The guides describe it under "A result your data layer already enforced", and the rules
    are in canonical-enforcement-spec.md §4.

Fixed

The SQL pre-checks validate every table a query references. The SQL prepare paths
(prepare_sql_query in Python, prepareSqlQuery in TypeScript core and in the TypeScript and
.NET wrappers) now resolve every table a query reads, including joined, comma-joined and derived
tables, and check each one with validateAccess. Column references are resolved through the
alias map to the table they belong to, and the field rules are applied to that table
(connector-spec §5). A bare column in a query over several
tables is accepted only when an unqualified allowedFields entry or * permits it, because the
table it belongs to cannot be known without the schema. A construct the check does not resolve
is refused with query uses a construct the pre-execution check cannot resolve: <construct>.
These constructs are common table expressions, set operations, subqueries outside FROM,
LATERAL, table-valued functions, APPLY, NATURAL JOIN when field rules apply, and statements
other than SELECT. Queries over a single table are decided as before, except that one using
such a construct, or a table modifier such as a hint or TABLESAMPLE, is now refused. The check
is exported as validate_query_references (Python), validateQueryReferences (TypeScript)
and SqlQueryReferences.Validate (.NET). When the caller supplies an object name, both it and
the table the query reads are checked, and a single-table query whose table is not the named
object is refused with object name does not match the table the query reads. Identifier and
literal forms the check does not model are refused rather than read: Unicode-escape forms
(U&"...", U&'...'), string literal prefixes other than N, E, X, B, R, typed
literals and character set introducers, triple-quoted literals, identifiers containing a
non-ASCII character, and identifiers that start with a digit. The shared fixture
fixtures/enforcement/sql-multi-table.json
pins the behaviour in all three SDKs.

A row filter could read another object's column. When a row had no exact key for a filter's
field, the lookup fell back to the field-name matcher. That matcher drops qualifiers, so a filter
on patients.region was evaluated against encounters.region whenever the leaf names matched.
For joins and multi-object projections, this let the other object's value decide whether a row
was kept. A bare filter such as region that matched several qualified keys used whichever
came first, so the decision depended on key order. All three SDKs now use the same lookup
(canonical-enforcement-spec §7).
An exact key wins. Otherwise a key qualified with a different object is never a candidate. One
candidate supplies the value, and none or several make the field absent, so the row is dropped.
A qualified filter still reads a bare key, and a bare filter still reads a single qualified key.
The shared fixture
fixtures/enforcement/row-filter-qualified-lookup.json
pins the behaviour. Qualifiers are compared ASCII case-insensitively (only A–Z folds), so
all three SDKs agree on non-ASCII names. Glob characters in a filter's qualifier are literal, and
db.patients and patients count as different qualifiers. Rows that relied on the old lookup,
through a conflicting qualifier or an ambiguous bare name, are now dropped rather than kept.
The update and delete target-row check uses the same lookup, so a write whose target row has
only a conflicting key, or an ambiguous one, is now refused with target row not permitted (#32).

allowedFields for one object allowed another object's column. The allow-list was matched
with the same field-name matcher as the deny rules, and that matcher drops qualifiers, so an
entry patients.name also allowed encounters.name. Where a result record or a write payload
carried keys qualified with their object, the projection kept a key the policy never listed and
the write path accepted it. All three SDKs now apply the row-filter qualifier rule from #32
when allowedFields decides inclusion
(canonical-enforcement-spec §4).
An entry does not allow a key qualified with a differen...

Read more