Skip to content

safe-dict: make the "never a spread" NB a gate that can go red - #43

Merged
mdheller merged 1 commit into
mainfrom
guard/safe-dict-spread-gate
Jul 31, 2026
Merged

safe-dict: make the "never a spread" NB a gate that can go red#43
mdheller merged 1 commit into
mainfrom
guard/safe-dict-spread-gate

Conversation

@mdheller

Copy link
Copy Markdown
Member

The rule, and the fact that it was already broken

ts/src/safe-dict.ts (this PR's base, #34) closes CodeQL js/remote-property-injection across the query surfaces and ends its docstring with a rule:

NB: object spread ({ ...dict }) and object literals re-attach Object.prototype. Use cloneDict / mergeDicts, never a spread, to carry one of these forward.

That rule was a comment. In the same PR it was already violated: patternMatcher.ts had its interior converted to null-prototype groundings and its output row left as

const row: Record<string, string> = {}
for (const v of variables) { const atom = g[v] ? as.getAtom(g[v]) : undefined; row[v] = atom ? (atom.name ?? atom.type) : '' }

keyed by pattern variable names that arrive from query text. row['__proto__'] = v is an ordinary assignment: it walks the prototype chain, finds Object.prototype's inherited __proto__ setter, and the write is swallowed — the column stays listed in variables and is absent from every row. tsc accepted it. The whole suite passed. It was caught in 454a2c8 by a human reading the diff, which is not a control.

This PR is the control.

Which option, and why

Option 2 — a narrow AST check (scripts/check-safe-dict.mjs), wired into npm test.

Not option 1 (ESLint no-restricted-syntax), for two reasons, the second decisive:

  1. The repo has no ESLint config and no eslint in devDependencies (verified). Introducing eslint + typescript-eslint + a parser project + a new CI lane for a single rule is disproportionate, and it opens "so what's our style baseline?" as a side effect of a security fix.
  2. no-restricted-syntax is a selector over syntax. It cannot know that a value is a Grounding; it can only match the annotation text. The actual regression was annotated Record<string, string> — not Binding, Grounding, RRow or SafeDict. A rule keyed on those four names would have shipped green against the very bug that motivated this task. That is the failure mode being guarded against, so the gate must key on the shape, not the type name.

Not option 3 alone — a reflective HOSTILE_KEYS test is good and #34 already has one for the pattern-matcher row, but it only covers paths a test happens to drive and says nothing about a newly added surface. Both layers now fire (see below).

Option 2 also matches an existing, proven pattern in this repo: scripts/check-kko-provenance.mjs + npm run check:kko + a CI job. It uses typescript, already a devDependency — no new dependencies.

What it enforces

Guarded set = sparql.ts, cypher.ts, gremlin.ts, patternMatcher.ts, plus any file that imports safe-dict — so a new query surface is covered the moment it adopts the convention, not when someone remembers to edit a list.

rule
R1 A computed-key assignment onto a normal-prototype object: o = {} / Object.assign({}, …) / Object.fromEntries(…) / toPlainRow(…) followed by o[expr] = v. Literal-key writes (o['a'] = 1) are fine. Syntactic — it reads how the value was constructed, not how it was declared.
R2 Any object spread. Array spread [...xs] and call spread f(...xs) untouched.
R3 An object literal in a Binding / Grounding / RRow / SafeDict position — declaration, parameter default, as cast, satisfies, annotated return.
R4 Coverage: the canonical surfaces still exist, the tracked type names still resolve to declarations, the guarded set is non-empty.

Red-then-green evidence

1. Red against the pre-fix patternMatcher.ts. Restoring the exact construction from the task:

$ node scripts/check-safe-dict.mjs
✗ safe-dict gate: 1 violation(s) — ts/src/safe-dict.ts's "never a spread, never a plain literal" rule is not being kept.

  R1  ts/src/patternMatcher.ts:88:7  `row` is an object literal — it has Object.prototype — and takes a
      computed-key write `row[…] = …`. If that key can be "__proto__" the assignment hits the inherited
      SETTER and is SWALLOWED: the column is declared and absent from every row.

EXIT=1

…while npm run typecheck stays green (exit 0) on the same tree. The compiler cannot see this; the gate can.

2. Red through the actual CI command. npm test is what ts-ci.yml's Test step runs:

$ npm test          # pre-fix patternMatcher.ts restored
✖ GATE: the shipped patternMatcher output row is ACCEPTED
✖ GATE: the guarded set still covers every canonical query surface, and the live files are clean
✖ SECURITY: a pattern variable named __proto__ appears as an own column in the result row   ← #34's own test
ℹ tests 430   ℹ pass 427   ℹ fail 3
npm test exit: 1

3. Green on the shipped code.

$ npm run check:safe-dict
✓ safe-dict gate — 4 guarded surface(s) [cypher.ts, gremlin.ts, patternMatcher.ts, sparql.ts],
  14,886 AST nodes, rules R1–R4 clean; self-test: 13 must-fail / 8 must-pass fixtures all behaved.
$ npm test        # 430 tests, 430 pass, 0 fail
$ npm run typecheck   # clean
$ npm run build       # no ts/dist drift

4. It independently reproduces #34's whole remediation. Scanning origin/main's pre-hardening files:

file violations
sparql.ts 18 (R1 ×6, R2 ×7, R3 ×5)
cypher.ts 9 (R1 ×6, R2 ×2, R3 ×1)
patternMatcher.ts 4 (R1 ×1, R2 ×1, R3 ×2)
gremlin.ts 0 — see below

5. The gate's own teeth are tested. FIXTURES is a table of 13 sources that must be flagged and 8 that must stay clean; runSelfTest() runs before every scan and ts/src/safe-dict-gate.test.ts drives the same table from npm test. Gutting R1 (const origin = null) produces:

✗ safe-dict gate SELF-TEST failed — the rule engine no longer detects what it claims to.
  A checker that cannot go red is not a check. Fix the rules, do not relax the fixtures.
    · prefix-patternMatcher-row: MUST be flagged (R1) and was NOT — the verbatim regression …
    · prefix-row-untyped / prefix-row-cast-launder / objectassign-launder / toplainrow-then-write
EXIT=1

with the real files clean. That is the specific defect this estate keeps hitting — a checker that prints its own success line while validating nothing — and it is closed by construction.

Evasion: what was tried, and what still works

Each of these is a must-fail fixture, so it stays closed:

  • drop the annotationconst row = {}; row[k] = v → R1. (R1 never reads the type.)
  • as cast on the initializerconst row = {} as Record<string,string> → R1.
  • as Binding on the literal — → R3 (a cast does not change the prototype).
  • Object.assign({}, src)cloneDict minus the null prototype → R1.
  • toPlainRow(...) then write to the result — correct materialization, but the result carries Object.prototype, so a later computed write is unsafe again → R1.
  • an intermediate aliasconst alias = row; alias[k] = v → R1 follows identifier aliases.
  • deferred seedlet row; row = {}; row[k] = v → R1.
  • rename the type — R2 does not look at types at all.

What it does not catch

Listed at the top of the script rather than implied away:

  • The READ mode. props[key] off a normal-prototype object returns inherited functions (values("constructor") really does hand back the Object constructor). That was gremlin.ts's half of the CodeQL alert, and this gate scores gremlin.ts 0 both before and after that fix (table above). ownValue() there is still only a convention. This is the largest honest hole.
  • The in mode. '__proto__' in binding on a prototype-bearing object is not detected.
  • Laundering across a function boundary. R1 is intra-procedural: pass {} to a helper that performs the computed write and neither end is flagged alone. In practice such a helper's parameter is typed as a tracked dictionary and R3 fires at the call site — but a Record<string, …> parameter slips through.
  • Files outside the guarded set — covered the moment they import safe-dict, and not before.
  • Runtime re-attachmentObject.setPrototypeOf, structuredClone, JSON.parse(JSON.stringify(d)).
  • Someone editing the checker. There is deliberately no magic-comment escape hatch, so relaxing the rule has to appear as a change to the rule, in the diff, under review.

Coordination

safe-dict.ts's NB now names what enforces it, and what does not.

Copilot AI review requested due to automatic review settings July 30, 2026 04:32
@mdheller

Copy link
Copy Markdown
Member Author

Coordination note for #40 — a real dependency, not a nit.

ts-ci.yml's PR filter is currently ^(package(-lock)?\.json|ts/|\.github/workflows/ts-ci\.yml). It does not include scripts/. This PR is fine (it touches package.json and ts/src/**), but the checker itself lives at scripts/check-safe-dict.mjs, so a future PR that changes only the gate would skip the Test step and never run the gate on the change to the gate — the same shape #36 described for ts-ci.yml itself.

#40 widens that filter to match the push filter, which already covers scripts/**. So #40 closes this. Recording it here so the dependency is explicit rather than discovered later: if #40 is dropped or narrowed, this gate needs scripts/ added to the filter itself.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Note

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Adds an enforced “safe-dict” security gate to prevent prototype-related hazards (object spread, plain object literals, and computed-key writes onto prototype-bearing objects) in query-surface code, and wires it into npm test.

Changes:

  • Add an AST-based checker (scripts/check-safe-dict.mjs) with self-test fixtures and guarded-file discovery.
  • Add a Node test (ts/src/safe-dict-gate.test.ts) that asserts the checker goes red on known-bad patterns and green on current surfaces.
  • Document the enforcement mechanism in ts/src/safe-dict.ts and add npm run check:safe-dict.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 10 comments.

File Description
ts/src/safe-dict.ts Expands the docstring NB to explain the new enforcement gate and its scope/limitations.
ts/src/safe-dict-gate.test.ts Adds test-suite enforcement for the gate (red/green + coverage assertions).
scripts/check-safe-dict.mjs Implements the AST gate (rules R1–R4), guarded-file discovery, and self-test fixtures.
package.json Adds check:safe-dict script to run the gate.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


// Resolved through a variable so this stays a runtime import of a plain .mjs script rather
// than something `tsc` tries to find types for.
const GATE_URL = pathToFileURL(join(__dirname, '..', '..', 'scripts', 'check-safe-dict.mjs')).href

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Rejecting — __dirname is defined here. package.json is "type": "commonjs", and the suite runs as node --import tsx --test ts/src/*.test.ts, so tsx transpiles this .ts file to CommonJS where __dirname exists. It is used in this exact file (three sites) and the gate test passes 4/4 (7/7 at #45). The ESM concern applies to .mjs files, and scripts/check-safe-dict.mjs — which IS ESM — already uses fileURLToPath(new URL(..., import.meta.url)) rather than __dirname. No runtime break.


test('GATE: the shipped patternMatcher output row is ACCEPTED', async () => {
const { scanSource } = await loadGate()
const src = readFileSync(join(__dirname, 'patternMatcher.ts'), 'utf8')

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Rejecting — __dirname is defined here. package.json is "type": "commonjs", and the suite runs as node --import tsx --test ts/src/*.test.ts, so tsx transpiles this .ts file to CommonJS where __dirname exists. It is used in this exact file (three sites) and the gate test passes 4/4 (7/7 at #45). The ESM concern applies to .mjs files, and scripts/check-safe-dict.mjs — which IS ESM — already uses fileURLToPath(new URL(..., import.meta.url)) rather than __dirname. No runtime break.

assert.ok(guarded.includes(f), `${f} is no longer in the guarded set — coverage was silently dropped`)
}

const violations = guarded.flatMap((f) => scanSource(`ts/src/${f}`, readFileSync(join(__dirname, f), 'utf8')))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Rejecting — __dirname is defined here. package.json is "type": "commonjs", and the suite runs as node --import tsx --test ts/src/*.test.ts, so tsx transpiles this .ts file to CommonJS where __dirname exists. It is used in this exact file (three sites) and the gate test passes 4/4 (7/7 at #45). The ESM concern applies to .mjs files, and scripts/check-safe-dict.mjs — which IS ESM — already uses fileURLToPath(new URL(..., import.meta.url)) rather than __dirname. No runtime break.

`${FIXTURES.filter((f) => f.expect === 'clean').length} must-pass fixtures all behaved.`)
}

if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) main()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not a live risk, so keeping it — though the hardening is fair. The gate's CI enforcement is ts/src/safe-dict-gate.test.ts, which imports scanSource/guardedFiles directly via loadGate() and runs under npm test; it does not depend on main() firing. Separately, npm run check:safe-dict (node scripts/check-safe-dict.mjs) empirically does run main() and scans — Node resolves process.argv[1] to the same absolute path as fileURLToPath(import.meta.url) for a directly-invoked script, so the guard matches. Comparing pathToFileURL(process.argv[1]).href to import.meta.url is a reasonable belt-and-suspenders; it would not change today's behavior.

// Guarded-file discovery
// ─────────────────────────────────────────────────────────────────────────────────────

const SRC = fileURLToPath(new URL('../ts/src/', import.meta.url))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Keeping the string concatenation. SRC is fileURLToPath(new URL('../ts/src/', import.meta.url)), which always ends in a separator, and guardedFiles() is only ever called with it, so srcDir + f / SRC + home cannot produce a missing or doubled separator in practice. The gate runs on Linux CI, so the Windows path-sep concern does not reach it. path.join() is a fair portability hardening and I would take it in a sweep, but there is no correctness bug here to fix.

Comment on lines +556 to +561
export function guardedFiles(srcDir = SRC) {
const found = new Set()
for (const f of readdirSync(srcDir)) {
if (!f.endsWith('.ts') || f.endsWith('.test.ts') || f === 'safe-dict.ts') continue
if (CANONICAL_SURFACES.includes(f)) { found.add(f); continue }
const text = readFileSync(srcDir + f, 'utf8')

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Keeping the string concatenation. SRC is fileURLToPath(new URL('../ts/src/', import.meta.url)), which always ends in a separator, and guardedFiles() is only ever called with it, so srcDir + f / SRC + home cannot produce a missing or doubled separator in practice. The gate runs on Linux CI, so the Windows path-sep concern does not reach it. path.join() is a fair portability hardening and I would take it in a sweep, but there is no correctness bug here to fix.


// ── R4: coverage assertions ──
for (const f of CANONICAL_SURFACES) {
if (!existsSync(SRC + f)) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Keeping the string concatenation. SRC is fileURLToPath(new URL('../ts/src/', import.meta.url)), which always ends in a separator, and guardedFiles() is only ever called with it, so srcDir + f / SRC + home cannot produce a missing or doubled separator in practice. The gate runs on Linux CI, so the Windows path-sep concern does not reach it. path.join() is a fair portability hardening and I would take it in a sweep, but there is no correctness bug here to fix.

}
}
for (const [type, home] of Object.entries(TRACKED_TYPE_HOMES)) {
const path = SRC + home

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Keeping the string concatenation. SRC is fileURLToPath(new URL('../ts/src/', import.meta.url)), which always ends in a separator, and guardedFiles() is only ever called with it, so srcDir + f / SRC + home cannot produce a missing or doubled separator in practice. The gate runs on Linux CI, so the Windows path-sep concern does not reach it. path.join() is a fair portability hardening and I would take it in a sweep, but there is no correctness bug here to fix.

// ── scan ──
let nodes = 0
for (const f of files) {
const found = scanSource(`ts/src/${f}`, readFileSync(SRC + f, 'utf8'))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Keeping the string concatenation. SRC is fileURLToPath(new URL('../ts/src/', import.meta.url)), which always ends in a separator, and guardedFiles() is only ever called with it, so srcDir + f / SRC + home cannot produce a missing or doubled separator in practice. The gate runs on Linux CI, so the Windows path-sep concern does not reach it. path.join() is a fair portability hardening and I would take it in a sweep, but there is no correctness bug here to fix.

Comment thread ts/src/safe-dict.ts
Comment on lines 29 to 30
* NB: object spread (`{ ...dict }`) and object literals re-attach Object.prototype. Use
* cloneDict / mergeDicts, never a spread, to carry one of these forward.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fair — the wording over-claims. { __proto__: null } is exactly the counterexample, and the convention here is specifically about plain {} / object literals used as untrusted-key dictionaries re-attaching Object.prototype. I'll tighten the phrasing to "plain object literals ({}) re-attach Object.prototype" so it no longer reads as a universal claim. Behavior of the gate is unchanged; this is doc accuracy.

safe-dict.ts closed js/remote-property-injection across the query surfaces and
ended its docstring with a rule:

    NB: object spread ({ ...dict }) and object literals re-attach
    Object.prototype. Use cloneDict / mergeDicts, never a spread.

That rule was a comment, and it was already violated in the same PR. #34
converted the interior of patternMatcher.ts and left the OUTPUT row as

    const row: Record<string, string> = {}
    for (const v of variables) row[v] = ...

keyed by pattern variable names from query text, so row['__proto__'] = v hit
Object.prototype's inherited SETTER and the write was swallowed: a column
DECLARED in `variables` and absent from every row. tsc accepted it. 430 tests
passed. It was caught in 454a2c8 by a human reading the diff, which is not a
control.

scripts/check-safe-dict.mjs is the control. It parses the guarded surfaces —
sparql.ts, cypher.ts, gremlin.ts, patternMatcher.ts, plus anything that imports
safe-dict — with the TypeScript AST (typescript is already a devDependency; no
new deps, no ESLint config introduced for one rule) and fails on:

  R1  a computed-key assignment onto a normal-prototype object: `o = {}`,
      Object.assign({}, ...), Object.fromEntries(...), toPlainRow(...) then
      `o[expr] = v`. SYNTACTIC — it reads how the value was CONSTRUCTED, not
      how it was declared. That is the point: the real regression was typed
      `Record<string, string>`, so an ESLint no-restricted-syntax rule keyed on
      Binding/Grounding/RRow/SafeDict would have shipped GREEN against it.
  R2  any object spread (array and call spread untouched).
  R3  an object literal in a Binding/Grounding/RRow/SafeDict position —
      declaration, parameter default, `as` cast, `satisfies`, annotated return.
  R4  coverage: the surfaces still exist, the type names still resolve, the
      guarded set is non-empty.

The gate proves its own teeth. FIXTURES is a table of 13 sources that MUST be
flagged — including the verbatim pre-fix row loop, plus five evasions: dropped
annotation, `as` cast on the initializer, Object.assign({}, src), an alias
variable, and a deferred `row = {}` — and 8 that MUST stay clean, including
`constructor() {}` and a `/[(){}.,;]/` regex (this is an AST check, not a grep),
`params = {}` parameter defaults, array/call spread, and literal-key writes.
The self-test runs before every scan; gutting R1 makes the script exit 1 with
the real files clean. ts/src/safe-dict-gate.test.ts drives the same table from
`npm test`, so the gate sits on the required build-and-verify-dist check
without touching ts-ci.yml (two other lanes are editing it).

Evidence, verified locally:
  · pre-fix row restored  -> npm test exits 1; the gate reports
    R1 ts/src/patternMatcher.ts:88 while `npm run typecheck` stays GREEN
  · scanning origin/main's pre-hardening files reproduces #34's remediation
    independently: sparql.ts 18, cypher.ts 9, patternMatcher.ts 4 violations
  · gremlin.ts scores 0 before AND after — its bug was a READ
    (properties[key]), which this gate does not cover, and says so

Known holes are listed at the top of the script rather than implied away: the
READ mode, the `in` mode, laundering across a function boundary, files outside
the guarded set, runtime setPrototypeOf/structuredClone. There is deliberately
no magic-comment escape hatch, so relaxing the rule has to show up as a change
to the rule, in the diff, under review.

safe-dict.ts's NB now names what enforces it.
@mdheller
mdheller force-pushed the guard/safe-dict-spread-gate branch from 70a1241 to 3b8efb5 Compare July 31, 2026 01:29
@mdheller
mdheller changed the base branch from fix/codeql-injection-alerts to main July 31, 2026 01:29
@mdheller
mdheller merged commit 810583f into main Jul 31, 2026
5 of 17 checks passed
mdheller added a commit that referenced this pull request Jul 31, 2026
PR #43 shipped R1-R4 with an explicit scope statement, and the first hole on it was
the READ mode: `props[key]` off a normal-prototype object returns inherited
FUNCTIONS, which was gremlin.ts's half of the original CodeQL alert
(js/remote-property-injection). Measured on the real files, the gate scored
gremlin.ts ZERO both before and after 0.4.47 fixed it — `ownValue()` there was a
convention with nothing holding it.

R5 flags a computed-key READ off an object that carries Object.prototype. It is
ORIGIN-DIRECTED, not "any computed read": the four guarded surfaces hold 57 computed
element accesses and almost all are array indexing (`tokens[pos]`, `row[i]`) or reads
off dictionaries R3 already guarantees are null-prototype (`binding[term.name]`).
Flagging those would be 57 false positives and one switched-off gate. Two base forms,
both with known provenance:

  (a) `<expr>.properties[k]` — the graph's own bag. GraphNode.properties and
      GraphEdge.properties are Record<string, PropertyValue> built by store.addNode()
      from a caller object literal, so they carry Object.prototype, and their keys
      arrive in query text.
  (b) an identifier whose construction R1 already tracks as plain-prototype, read with
      a computed key. Same scope machinery, so aliases and casts launder a read no
      more than they launder a write.

Literal keys stay trusted. Assignment targets stay R1's — `isAssignmentTarget()`
covers `=`, compound assignment, `++/--` and `delete`, and a new `exclusive` fixture
flag asserts the two rules never both report one node.

The fix is `ownValue(bag, key)`, a CALL rather than an element access: corrected code
stops matching the rule instead of needing an exemption from it. No allowlist, no
magic comment, consistent with #43.

R4 grows a matching coverage assertion. R5 keys on a member NAME, so a rename would
neuter it the way a type rename would neuter R3; the gate now fails loudly if
`properties` stops being declared on GraphNode/GraphEdge.

RED PROOF, against the real files rather than a paraphrase:

  gremlin.ts       PRE-#34: R5=4  (has, values, both order arms)   POST: R5=0
  sparql.ts        PRE-#34: R5=1  (`next[term.name]`)              POST: R5=0
  cypher.ts        PRE-#34: R5=0                                   POST: R5=0
  patternMatcher.ts PRE-#34: R5=0                                  POST: R5=0

Teeth proven both ways: emptying PROTO_BEARING_BAGS makes the self-test go red, and
renaming GraphNode.properties makes R4 go red.

WHAT R5 STILL DOES NOT CATCH, stated to #43's standard:
  · the bag set is one member name, `.properties`; a new prototype-bearing bag under
    another name is uncovered until it is added (R4 catches a rename, not an addition)
  · a computed read off a bag reached through a `Record<string, …>` PARAMETER —
    provenance is unknown there and guessing is worse than the stated gap; R3 is the
    compensating control for the tracked dictionary types
  · a bag aliased through a local (`const p = node.properties; p[key]`); the direct
    form the alert was about is caught
  · the `in` mode, cross-function laundering, files outside the guarded set, and
    runtime prototype re-attachment — all unchanged from #43

433/433 green, typecheck clean, no ts/dist drift (the safe-dict.ts change is comment
only).
mdheller added a commit that referenced this pull request Jul 31, 2026
PR #43 shipped R1-R4 with an explicit scope statement, and the first hole on it was
the READ mode: `props[key]` off a normal-prototype object returns inherited
FUNCTIONS, which was gremlin.ts's half of the original CodeQL alert
(js/remote-property-injection). Measured on the real files, the gate scored
gremlin.ts ZERO both before and after 0.4.47 fixed it — `ownValue()` there was a
convention with nothing holding it.

R5 flags a computed-key READ off an object that carries Object.prototype. It is
ORIGIN-DIRECTED, not "any computed read": the four guarded surfaces hold 57 computed
element accesses and almost all are array indexing (`tokens[pos]`, `row[i]`) or reads
off dictionaries R3 already guarantees are null-prototype (`binding[term.name]`).
Flagging those would be 57 false positives and one switched-off gate. Two base forms,
both with known provenance:

  (a) `<expr>.properties[k]` — the graph's own bag. GraphNode.properties and
      GraphEdge.properties are Record<string, PropertyValue> built by store.addNode()
      from a caller object literal, so they carry Object.prototype, and their keys
      arrive in query text.
  (b) an identifier whose construction R1 already tracks as plain-prototype, read with
      a computed key. Same scope machinery, so aliases and casts launder a read no
      more than they launder a write.

Literal keys stay trusted. Assignment targets stay R1's — `isAssignmentTarget()`
covers `=`, compound assignment, `++/--` and `delete`, and a new `exclusive` fixture
flag asserts the two rules never both report one node.

The fix is `ownValue(bag, key)`, a CALL rather than an element access: corrected code
stops matching the rule instead of needing an exemption from it. No allowlist, no
magic comment, consistent with #43.

R4 grows a matching coverage assertion. R5 keys on a member NAME, so a rename would
neuter it the way a type rename would neuter R3; the gate now fails loudly if
`properties` stops being declared on GraphNode/GraphEdge.

RED PROOF, against the real files rather than a paraphrase:

  gremlin.ts       PRE-#34: R5=4  (has, values, both order arms)   POST: R5=0
  sparql.ts        PRE-#34: R5=1  (`next[term.name]`)              POST: R5=0
  cypher.ts        PRE-#34: R5=0                                   POST: R5=0
  patternMatcher.ts PRE-#34: R5=0                                  POST: R5=0

Teeth proven both ways: emptying PROTO_BEARING_BAGS makes the self-test go red, and
renaming GraphNode.properties makes R4 go red.

WHAT R5 STILL DOES NOT CATCH, stated to #43's standard:
  · the bag set is one member name, `.properties`; a new prototype-bearing bag under
    another name is uncovered until it is added (R4 catches a rename, not an addition)
  · a computed read off a bag reached through a `Record<string, …>` PARAMETER —
    provenance is unknown there and guessing is worse than the stated gap; R3 is the
    compensating control for the tracked dictionary types
  · a bag aliased through a local (`const p = node.properties; p[key]`); the direct
    form the alert was about is caught
  · the `in` mode, cross-function laundering, files outside the guarded set, and
    runtime prototype re-attachment — all unchanged from #43

433/433 green, typecheck clean, no ts/dist drift (the safe-dict.ts change is comment
only).
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