Skip to content

Commit 6332b31

Browse files
philcunliffeclaude
andcommitted
Harden raw-rule column guard + cover likePrefix + @import types (neutral review, PR #291)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 209e250 commit 6332b31

4 files changed

Lines changed: 178 additions & 8 deletions

File tree

hypaware-core/plugins-workspace/context-graph/src/contract-registry.js

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,9 @@ export function createContractRegistry(opts = {}) {
9292
throw new TypeError(`registerContract: ${at} where is only valid with columns`)
9393
}
9494
if (hasSql && contract.rowFilter) {
95+
const sql = /** @type {string} */ (rule.sql)
9596
for (const col of contract.rowFilter.columns) {
96-
if (!rule.sql?.includes(col)) {
97+
if (!rawSqlProjectsColumn(sql, col)) {
9798
throw new TypeError(`registerContract: ${at} raw sql must select rowFilter column '${col}'`)
9899
}
99100
}
@@ -170,3 +171,127 @@ export function createContractRegistry(opts = {}) {
170171

171172
return { register, list }
172173
}
174+
175+
/**
176+
* True when a raw rule's SQL provably projects `col` in its top-level
177+
* SELECT list, so the contract's rowFilter (which reads `row[col]`) has the
178+
* column to test. A loose `sql.includes(col)` accepts false positives (a
179+
* `WHERE attributes IS NOT NULL`, or a different column whose name merely
180+
* contains `col`), so match the projection list only: take the identifiers
181+
* between the first top-level SELECT and its FROM, accept `*` / `table.*`,
182+
* and reduce each item to its output name (the alias after AS, or the column
183+
* past a `table.` qualifier). Anything ambiguous (a computed expression with
184+
* no alias) is treated as not-a-match, so the guard stays conservative and
185+
* rejects registration when the column is not provably projected. This is a
186+
* focused projection check, not a general SQL parser.
187+
*
188+
* @param {string} sql
189+
* @param {string} col
190+
* @returns {boolean}
191+
*/
192+
function rawSqlProjectsColumn(sql, col) {
193+
const projection = selectProjection(sql)
194+
if (projection === undefined) return false
195+
for (const item of splitTopLevel(projection)) {
196+
const name = projectionOutputName(item)
197+
if (name === '*' || name === col) return true
198+
}
199+
return false
200+
}
201+
202+
/**
203+
* The text between the first top-level `SELECT` and its matching `FROM`
204+
* (both matched as standalone, case-insensitive keywords at parenthesis
205+
* depth 0, so a subquery's SELECT/FROM never leaks in). Undefined when the
206+
* SQL has no top-level `SELECT ... FROM`.
207+
*
208+
* @param {string} sql
209+
* @returns {string | undefined}
210+
*/
211+
function selectProjection(sql) {
212+
const upper = sql.toUpperCase()
213+
let depth = 0
214+
let selectEnd = -1
215+
for (let i = 0; i < sql.length; i++) {
216+
const ch = sql[i]
217+
if (ch === '(') depth++
218+
else if (ch === ')') depth--
219+
else if (depth === 0 && selectEnd === -1 && matchKeyword(upper, i, 'SELECT')) {
220+
selectEnd = i + 'SELECT'.length
221+
i = selectEnd - 1
222+
} else if (depth === 0 && selectEnd !== -1 && matchKeyword(upper, i, 'FROM')) {
223+
return sql.slice(selectEnd, i)
224+
}
225+
}
226+
return undefined
227+
}
228+
229+
/**
230+
* True when `kw` sits at index `i` of `upper` as a whole word (its
231+
* neighbours are not identifier characters), so `FROM` matches but
232+
* `FROMAGE` or a `from_x` column does not.
233+
*
234+
* @param {string} upper
235+
* @param {number} i
236+
* @param {string} kw
237+
* @returns {boolean}
238+
*/
239+
function matchKeyword(upper, i, kw) {
240+
if (!upper.startsWith(kw, i)) return false
241+
const boundary = (/** @type {string | undefined} */ c) => c === undefined || !/[A-Z0-9_]/.test(c)
242+
return boundary(upper[i - 1]) && boundary(upper[i + kw.length])
243+
}
244+
245+
/**
246+
* Split on commas at parenthesis depth 0, so a `f(a, b)` projection item
247+
* stays whole.
248+
*
249+
* @param {string} s
250+
* @returns {string[]}
251+
*/
252+
function splitTopLevel(s) {
253+
/** @type {string[]} */
254+
const parts = []
255+
let depth = 0
256+
let start = 0
257+
for (let i = 0; i < s.length; i++) {
258+
const ch = s[i]
259+
if (ch === '(') depth++
260+
else if (ch === ')') depth--
261+
else if (ch === ',' && depth === 0) {
262+
parts.push(s.slice(start, i))
263+
start = i + 1
264+
}
265+
}
266+
parts.push(s.slice(start))
267+
return parts
268+
}
269+
270+
/**
271+
* The output name a single projection item exposes on the result row: the
272+
* alias after `AS`, `*` for a wildcard (`*` or `table.*`), or the column
273+
* past a `table.` qualifier. A bare computed expression has no derivable
274+
* column name and returns its trimmed text, which will not match a plain
275+
* column name, keeping the guard conservative.
276+
*
277+
* @param {string} item
278+
* @returns {string}
279+
*/
280+
function projectionOutputName(item) {
281+
let s = item.trim()
282+
if (s.length === 0) return ''
283+
const asMatch = /\s+AS\s+("?[A-Za-z0-9_]+"?)\s*$/i.exec(s)
284+
if (asMatch) return stripQuotes(asMatch[1])
285+
if (s === '*' || s.endsWith('.*')) return '*'
286+
const dot = s.lastIndexOf('.')
287+
if (dot !== -1) s = s.slice(dot + 1)
288+
return stripQuotes(s)
289+
}
290+
291+
/**
292+
* @param {string} s
293+
* @returns {string}
294+
*/
295+
function stripQuotes(s) {
296+
return s.startsWith('"') && s.endsWith('"') && s.length >= 2 ? s.slice(1, -1) : s
297+
}

hypaware-core/plugins-workspace/context-graph/src/project.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
/**
1515
* @import { HypAwareV2Config, QueryRegistry } from '../../../../hypaware-plugin-kernel-types.js'
1616
* @import { ExtendedQueryStorageService } from '../../../../src/core/cache/types.js'
17-
* @import { Contract, GraphRow } from './types.js'
17+
* @import { Contract, ContractRule, GraphRow, RulePredicate } from './types.js'
1818
*/
1919

2020
/**
@@ -52,7 +52,7 @@ export async function projectGraph({ query, storage, contracts, config, dryRun =
5252
* one-query-per-rule order.
5353
*
5454
* @param {Record<string, unknown>} row
55-
* @param {import('./types.js').ContractRule[]} rules
55+
* @param {ContractRule[]} rules
5656
*/
5757
const applyRules = (row, rules) => {
5858
for (const rule of rules) {
@@ -107,7 +107,7 @@ export async function projectGraph({ query, storage, contracts, config, dryRun =
107107
// rule pairs sharing a query (a surface's node + edge rule) cost one
108108
// scan. Their SQL already selects the rowFilter's columns (registry
109109
// enforced), so the filter applies here too.
110-
/** @type {Map<string, import('./types.js').ContractRule[]>} */
110+
/** @type {Map<string, ContractRule[]>} */
111111
const bySql = new Map()
112112
for (const rule of raw) {
113113
const sql = /** @type {string} */ (rule.sql)
@@ -213,12 +213,12 @@ async function dedupExisting(rows, idCol, dataset, query, storage, config) {
213213
* semantics: every clause must hold, and a null or absent column never
214214
* matches (`WHERE col = 'x'` skips null rows; so does this).
215215
*
216-
* @param {import('./types.js').RulePredicate} where
216+
* @param {RulePredicate} where
217217
* @param {Record<string, unknown>} row
218218
* @returns {boolean}
219219
* @ref LLP 0096#decision [implements]: eq/in/likePrefix only, AND-composed, null never matches
220220
*/
221-
function matchesPredicate(where, row) {
221+
export function matchesPredicate(where, row) {
222222
if (where.eq) {
223223
for (const [col, value] of Object.entries(where.eq)) {
224224
if (row[col] !== value) return false
@@ -244,7 +244,7 @@ function matchesPredicate(where, row) {
244244
* no rule's `columns` lists them (a rule may filter on a column `toRow`
245245
* never touches).
246246
*
247-
* @param {import('./types.js').RulePredicate | undefined} where
247+
* @param {RulePredicate | undefined} where
248248
* @returns {string[]}
249249
*/
250250
function predicateColumns(where) {

test/plugins/context-graph-contract.test.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,34 @@ test('contract registry validates rowFilter, and raw sql must select its columns
161161
const raw = { kind: 'node', type: 'T', sql: 'SELECT a FROM x', toRow: () => null }
162162
assert.throws(() => reg.register(sampleContract({ name: 'y', rules: [raw], rowFilter: filter })), /raw sql must select rowFilter column 'attributes'/)
163163
reg.register(sampleContract({ name: 'z', rules: [{ ...raw, sql: 'SELECT attributes, a FROM x' }], rowFilter: filter }))
164+
165+
// The guard checks the SELECT projection list, not a loose substring: the
166+
// column named in a WHERE clause, or a different column whose name merely
167+
// contains it, is not projected and must be rejected.
168+
const notProjected = [
169+
'SELECT a FROM x WHERE attributes IS NOT NULL',
170+
'SELECT other_attributes FROM x',
171+
'SELECT a FROM x WHERE b IN (SELECT attributes FROM y)',
172+
]
173+
for (const sql of notProjected) {
174+
assert.throws(
175+
() => reg.register(sampleContract({ name: 'n', rules: [{ ...raw, sql }], rowFilter: filter })),
176+
/raw sql must select rowFilter column 'attributes'/,
177+
sql
178+
)
179+
}
180+
181+
// Projection forms that do provably carry the column are accepted: a
182+
// qualified reference, an alias, and a wildcard.
183+
const projected = [
184+
'SELECT x.attributes, a FROM x',
185+
'SELECT foo AS attributes, a FROM x',
186+
'SELECT * FROM x',
187+
'SELECT x.* FROM x',
188+
]
189+
projected.forEach((sql, i) => {
190+
reg.register(sampleContract({ name: `p${i}`, rules: [{ ...raw, sql }], rowFilter: filter }))
191+
})
164192
})
165193

166194
test('contract registry rejects a duplicate (plugin, name)', () => {

test/plugins/context-graph-project.test.js

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import assert from 'node:assert/strict'
44
import test from 'node:test'
55

6-
import { firstSeenTime, mergeRow } from '../../hypaware-core/plugins-workspace/context-graph/src/project.js'
6+
import { firstSeenTime, matchesPredicate, mergeRow } from '../../hypaware-core/plugins-workspace/context-graph/src/project.js'
77

88
/**
99
* @param {Partial<Record<string, unknown>>} overrides
@@ -93,6 +93,23 @@ test('mergeRow tolerates rows with unparseable or missing first_seen', () => {
9393
assert.equal(ba.first_seen, '2026-06-01T00:00:00.000Z')
9494
})
9595

96+
// The equivalence e2e (context-graph-project-e2e.test.js) claims likePrefix
97+
// coverage, but the ai-gateway prefix surfaces run as raw SQL, so its JS
98+
// `likePrefix` branch is never actually evaluated there. Exercise it directly.
99+
// @ref LLP 0096#decision [tests]: likePrefix matches a string prefix; null and non-string columns never match
100+
test('matchesPredicate likePrefix keeps only matching-prefix string rows', () => {
101+
const where = { likePrefix: { content_text: 'Base directory for this skill: ' } }
102+
const match = { content_text: 'Base directory for this skill: /repo/skills/foo' }
103+
const nonMatch = { content_text: 'a different message' }
104+
const nullValue = { content_text: null }
105+
const nonString = { content_text: 42 }
106+
107+
assert.equal(matchesPredicate(where, match), true, 'matching prefix is kept')
108+
assert.equal(matchesPredicate(where, nonMatch), false, 'non-matching string is dropped')
109+
assert.equal(matchesPredicate(where, nullValue), false, 'null never matches')
110+
assert.equal(matchesPredicate(where, nonString), false, 'non-string never matches')
111+
})
112+
96113
test('firstSeenTime normalizes strings, Dates, and epoch numbers', () => {
97114
const t = Date.UTC(2026, 5, 1)
98115
assert.equal(firstSeenTime('2026-06-01T00:00:00.000Z'), t)

0 commit comments

Comments
 (0)