Add strict read-only source and database investigation tools

This commit is contained in:
Nico committed 2026-10-14 00:50:46 +02:00
1 parent 798925d25d
commit 66c76a9930
14 files changed
+2069 -2

No files matched your search

+6 -2
View File
@@ -22,8 +22,9 @@ python -m venv .venv
The lifecycle foundation supports one-pass inbox processing, resumable package
checkpoints and a deterministic generic CaseBuilder. An async LLM gateway and CaseInterpreter
provide interpretation and bounded initial hypothesis generation, with durable
per-hypothesis budget accounting. Investigation tools, diagnosis,
Change Requests and a continuous analysis worker remain future milestones.
per-hypothesis budget accounting. Read-only source/database snapshot queries are
available. Diagnosis, Change Requests and a continuous analysis worker remain
future milestones.
Expected-behavior resolution and the read-only provider contract: [expected-behavior-v1.md](docs/expected-behavior-v1.md).
@@ -39,3 +40,6 @@ rules: [budget-manager-v1.md](docs/budget-manager-v1.md), with [budget configura
Case-local asynchronous test requests, confirmed feedback and budget-preserving
event-driven resume: [async-tests-v1.md](docs/async-tests-v1.md). Web UI and external
bot/client adapters are future integrations.
Read-only packaged source/database queries and the strict tool allowlist:
[investigation-tools-v1.md](docs/investigation-tools-v1.md).
+148
View File
@@ -0,0 +1,148 @@
# Investigation tools v1
This milestone provides the read-only Investigation Tool Router and initial
source/database queries. The architecture is unchanged. No shell, arbitrary SQL,
live database access, source edits, web UI, bot/client steering, EvidenceStore or
autonomous Investigation Loop is introduced.
## API and controller boundary
```python
from viano_bug_analysis.tools import InvestigationToolRouter
router = InvestigationToolRouter()
result = router.query(package_path, {
"format": "viano-investigation-request", "format_version": 1,
"case_id": case_id, "package_id": package_id, "analysis_revision": revision,
"requests": [{
"request_id": "Q0001", "hypothesis_id": "H1", "action_id": "query-0001",
"operation": "source.find_text",
"arguments": {"source_refs": [source_ref], "text": "activate",
"case_sensitive": True, "offset": 0, "limit": 20},
}],
})
```
`request_schema()` exposes the bundled strict `investigation-request-v1` schema
for future prompting. Unknown operations/fields, invalid arguments, duplicate
request IDs and repeated hypothesis/action pairs are rejected. The router checks
manifest identity/revision, known initial hypothesis IDs, active generation or
investigation phase, and a fresh canonical case. Blocked cases cannot query.
The entire batch is validated and preflighted before any query handler executes.
Preflight includes declared roles, bounded source reads, encoding, ranges,
database structure and known columns. A rejected batch returns no partial result.
Preflight itself necessarily reads the selected snapshots; it has no writes or
external effects. Runtime errors are controlled `ToolError` codes without raw
source data in diagnostic text.
Tools read only the ZIP snapshots declared by `case.json` in `source_context` and
`database_context`. Each read verifies its existing case hash and size. Arbitrary
package members, unknown/unclassified attachments, absolute server paths and
manifest pointers are not alternative query targets. Server path strings inside
wowsrc data remain metadata. Nothing is extracted or executed.
The router returns a versioned `investigation-result-v1` document with manifest
identity, source package generation, canonical case ref/hash, batch hash,
per-request hash and typed operation-specific results. It does not update the
manifest, write checkpoints, charge/refund credits or persist evidence.
The future trusted controller must reserve budget **before** dispatch, persist
the request and result, verify that the case generation has not changed, and
apply BudgetManager result/settlement rules. Retries of these deterministic
snapshot queries have no tool side effects; billing idempotence remains in the
controller/BudgetManager. Future controller hypothesis eligibility, reservations
and updates require their own full validation; a known birth ID alone does not
authorize spending against a terminal account.
## Initial allowlist
| Operation | Arguments and behavior |
| --- | --- |
| `source.find_text` | Explicit `source_refs`, literal `text`, `case_sensitive`, `offset`, `limit`; matches lines, not regex |
| `source.find_symbol` | Explicit `source_refs`, one identifier `symbol`, `offset`, `limit`; token-boundary occurrences |
| `source.read_context` | `source_ref`, inclusive 1-based `start_line` and `end_line` |
| `database.describe` | `source_ref` selecting a verified schema/snapshot descriptor |
| `database.lookup` | `source_ref`, nonempty `filters` map of exact scalar equality, projection `columns`, `offset`, `limit` |
| `database.find_references` | `source_ref`, explicit `search_columns`, scalar `value`, projection `columns`, `offset`, `limit` |
Source results include actual source SHA-256, 1-based line numbers and line text.
Symbol matches can be uses, declarations, definitions, comments or strings: this
is not a language parser or verified definition/registration resolver. Text
search is literal and may explicitly ignore case. Source encoding is UTF-8 with
optional BOM; binary/NUL or undecodable sources reject the batch.
Database results include original source SHA-256 and an actual JSON Pointer to
each original row (for example `/rows/2` or `/2`). Projection never changes the
pointer. Equality does not coerce booleans, strings or numeric types; `true`, `1`,
`"1"` and `1.0` remain different typed values. Reference search only finds equal
values in specified columns; it does not assert foreign-key relationships.
SQL-like text in a scalar value is ordinary data, never executable SQL.
All searches expose checked sources, total matches, `truncated` and a stable
`next_offset`. Sorting follows requested file order and original row/line order.
Individual source lines have separate `text_truncated` markers. Limits reject
excessive input/output rather than quietly dropping queried files. A no-match
result applies only to the selected packaged snapshot; it proves neither absence
from an entire repository nor absence from the current server/database.
## Database snapshot contract and profile adapter
The neutral `database-snapshot-v1` format is:
```json
{
"format": "viano-database-snapshot", "format_version": 1,
"table": "switches",
"columns": [{"name": "id", "type": "integer"}],
"rows": [{"id": 42}], "coverage": "partial"
}
```
Coverage is explicit: `partial`, `complete_snapshot`, `unknown` or `schema_only`.
Even a complete snapshot is a packaged point-in-time claim, not a live query.
Duplicate column names, rows containing undeclared columns and schema-only
descriptors containing rows are rejected. Raw arrays of row objects can be
queried without inventing table identity or schema: their coverage is `unknown`,
and columns must occur in stored rows. An empty raw array cannot validate a
column name; querying unknown columns rejects. A schema-only descriptor may have
known columns and no rows, but that absence is not evidence of a missing DB row.
wowsrc's native `{table, columns:[{column_name, column_type, ...}], ...}` descriptors
are normalized explicitly in `profiles/vcore/db_tools.py`:
```python
from viano_bug_analysis.profiles.vcore.db_tools import normalize_schema_descriptor
from viano_bug_analysis.tools.database_query import DatabaseQueryTool
router = InvestigationToolRouter(
database_tool=DatabaseQueryTool(normalize_schema_descriptor),
)
```
This mapping reports `schema_only`. It does not infer table ownership from file
names, numeric IDs, ambiguous artifact rankings or `schema_table_matches`. Other
projects can supply their own trusted descriptor adapter or use the neutral
format. Live DB connectors, symbol parsers, registration discovery, timeline/log
queries and actual external research providers are later extensions.
## Limits and asynchronous testing
`ToolLimits` provides adjustable operational defaults, not permanent v1 format
caps: request 64 KiB, batch 16 requests, 64 distinct selected files, 8 MiB per file,
32 MiB combined selected bytes, 100,000 database rows, 100 returned matches per
query, 200 context lines, 4,096 characters per returned source line and 2 MiB per
result batch. Existing package and CaseBuilder limits apply separately when
validating the package and canonical case inventory.
Asynchronous human/automated tests remain in the separate controller handoff
described in [async-tests-v1.md](async-tests-v1.md). They are deliberately not
executed by this snapshot query router. The future Investigation Loop can combine
both interfaces: fast read-only query results and persisted external test waits.
No deadline, waiting state or budget preservation behavior changes here.
Tests cover generic snapshots, allowlist and path safety, whole-batch preflight,
type distinctions, pagination, provenance, input/output limits, blocked/stale
contexts and byte-for-byte read-only behavior. The actual BUG-20 regression reads
three lines of packaged source, its quest table schema using the Vcore adapter,
and the actual stored quest row through an explicit ID filter;
it does not inject a known fix or claim to test model reasoning.
@@ -0,0 +1,19 @@
"""Explicit wowsrc schema descriptor mapping; no guessed tables or live SQL."""
from viano_bug_analysis.tools.config import ToolError
def normalize_schema_descriptor(document):
if isinstance(document, dict) and document.get("format") == "viano-database-snapshot":
return document
if (not isinstance(document, dict) or not isinstance(document.get("table"), str)
or not isinstance(document.get("columns"), list)):
raise ToolError("TOOL_DATABASE_DESCRIPTOR")
columns = []
for column in document["columns"]:
if (not isinstance(column, dict) or not isinstance(column.get("column_name"), str)
or not isinstance(column.get("column_type"), str)):
raise ToolError("TOOL_DATABASE_DESCRIPTOR")
columns.append({"name": column["column_name"], "type": column["column_type"]})
return {"format": "viano-database-snapshot", "format_version": 1,
"table": document["table"], "columns": columns, "rows": [], "coverage": "schema_only"}
@@ -0,0 +1,63 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": [
"format",
"format_version",
"table",
"columns",
"rows",
"coverage"
],
"properties": {
"format": {
"const": "viano-database-snapshot"
},
"format_version": {
"const": 1
},
"table": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"columns": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"type"
],
"properties": {
"name": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"type": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
}
}
}
},
"rows": {
"type": "array",
"items": {
"type": "object"
}
},
"coverage": {
"enum": [
"partial",
"complete_snapshot",
"unknown",
"schema_only"
]
}
}
}
@@ -0,0 +1,440 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": [
"format",
"format_version",
"case_id",
"package_id",
"analysis_revision",
"requests"
],
"properties": {
"format": {
"const": "viano-investigation-request"
},
"format_version": {
"const": 1
},
"case_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"package_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"analysis_revision": {
"type": "integer",
"minimum": 1
},
"requests": {
"type": "array",
"minItems": 1,
"items": {
"oneOf": [
{
"type": "object",
"additionalProperties": false,
"required": [
"request_id",
"hypothesis_id",
"action_id",
"operation",
"arguments"
],
"properties": {
"request_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"hypothesis_id": {
"type": "string",
"pattern": "^H[1-9][0-9]*$"
},
"action_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"operation": {
"const": "source.find_text"
},
"arguments": {
"type": "object",
"additionalProperties": false,
"required": [
"source_refs",
"text",
"case_sensitive",
"offset",
"limit"
],
"properties": {
"source_refs": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
}
},
"text": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"case_sensitive": {
"type": "boolean"
},
"offset": {
"type": "integer",
"minimum": 0
},
"limit": {
"type": "integer",
"minimum": 1
}
}
}
}
},
{
"type": "object",
"additionalProperties": false,
"required": [
"request_id",
"hypothesis_id",
"action_id",
"operation",
"arguments"
],
"properties": {
"request_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"hypothesis_id": {
"type": "string",
"pattern": "^H[1-9][0-9]*$"
},
"action_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"operation": {
"const": "source.find_symbol"
},
"arguments": {
"type": "object",
"additionalProperties": false,
"required": [
"source_refs",
"symbol",
"offset",
"limit"
],
"properties": {
"source_refs": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
}
},
"symbol": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_]*$"
},
"offset": {
"type": "integer",
"minimum": 0
},
"limit": {
"type": "integer",
"minimum": 1
}
}
}
}
},
{
"type": "object",
"additionalProperties": false,
"required": [
"request_id",
"hypothesis_id",
"action_id",
"operation",
"arguments"
],
"properties": {
"request_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"hypothesis_id": {
"type": "string",
"pattern": "^H[1-9][0-9]*$"
},
"action_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"operation": {
"const": "source.read_context"
},
"arguments": {
"type": "object",
"additionalProperties": false,
"required": [
"source_ref",
"start_line",
"end_line"
],
"properties": {
"source_ref": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"start_line": {
"type": "integer",
"minimum": 1
},
"end_line": {
"type": "integer",
"minimum": 1
}
}
}
}
},
{
"type": "object",
"additionalProperties": false,
"required": [
"request_id",
"hypothesis_id",
"action_id",
"operation",
"arguments"
],
"properties": {
"request_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"hypothesis_id": {
"type": "string",
"pattern": "^H[1-9][0-9]*$"
},
"action_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"operation": {
"const": "database.describe"
},
"arguments": {
"type": "object",
"additionalProperties": false,
"required": [
"source_ref"
],
"properties": {
"source_ref": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
}
}
}
}
},
{
"type": "object",
"additionalProperties": false,
"required": [
"request_id",
"hypothesis_id",
"action_id",
"operation",
"arguments"
],
"properties": {
"request_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"hypothesis_id": {
"type": "string",
"pattern": "^H[1-9][0-9]*$"
},
"action_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"operation": {
"const": "database.lookup"
},
"arguments": {
"type": "object",
"additionalProperties": false,
"required": [
"source_ref",
"filters",
"columns",
"offset",
"limit"
],
"properties": {
"source_ref": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"filters": {
"type": "object",
"minProperties": 1,
"propertyNames": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"additionalProperties": {
"type": [
"string",
"number",
"boolean",
"null"
]
}
},
"columns": {
"type": "array",
"uniqueItems": true,
"items": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
}
},
"offset": {
"type": "integer",
"minimum": 0
},
"limit": {
"type": "integer",
"minimum": 1
}
}
}
}
},
{
"type": "object",
"additionalProperties": false,
"required": [
"request_id",
"hypothesis_id",
"action_id",
"operation",
"arguments"
],
"properties": {
"request_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"hypothesis_id": {
"type": "string",
"pattern": "^H[1-9][0-9]*$"
},
"action_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"operation": {
"const": "database.find_references"
},
"arguments": {
"type": "object",
"additionalProperties": false,
"required": [
"source_ref",
"search_columns",
"value",
"columns",
"offset",
"limit"
],
"properties": {
"source_ref": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"search_columns": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
}
},
"value": {
"type": [
"string",
"number",
"boolean",
"null"
]
},
"columns": {
"type": "array",
"uniqueItems": true,
"items": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
}
},
"offset": {
"type": "integer",
"minimum": 0
},
"limit": {
"type": "integer",
"minimum": 1
}
}
}
}
}
]
}
}
}
}
@@ -0,0 +1,846 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": [
"format",
"format_version",
"case_id",
"package_id",
"analysis_revision",
"package_generation",
"case_ref",
"case_sha256",
"batch_sha256",
"results"
],
"properties": {
"format": {
"const": "viano-investigation-result"
},
"format_version": {
"const": 1
},
"case_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"package_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"analysis_revision": {
"type": "integer",
"minimum": 1
},
"package_generation": {
"type": "integer",
"minimum": 0
},
"case_ref": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"case_sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"batch_sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"results": {
"type": "array",
"minItems": 1,
"items": {
"oneOf": [
{
"type": "object",
"additionalProperties": false,
"required": [
"request_id",
"hypothesis_id",
"action_id",
"operation",
"request_sha256",
"data"
],
"properties": {
"request_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"hypothesis_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"action_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"operation": {
"const": "source.find_text"
},
"request_sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"data": {
"type": "object",
"additionalProperties": false,
"required": [
"matches",
"total_matches",
"truncated",
"next_offset",
"checked_sources",
"scope",
"interpretation"
],
"properties": {
"matches": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"source_ref",
"sha256",
"line",
"text",
"text_truncated"
],
"properties": {
"source_ref": {
"type": "string",
"minLength": 1
},
"sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"line": {
"type": "integer",
"minimum": 1
},
"text": {
"type": "string"
},
"text_truncated": {
"type": "boolean"
}
}
}
},
"total_matches": {
"type": "integer",
"minimum": 0
},
"truncated": {
"type": "boolean"
},
"next_offset": {
"type": [
"integer",
"null"
],
"minimum": 0
},
"checked_sources": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"source_ref",
"sha256",
"size"
],
"properties": {
"source_ref": {
"type": "string",
"minLength": 1
},
"sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"size": {
"type": "integer",
"minimum": 0
}
}
}
},
"scope": {
"const": "packaged_source_snapshot"
},
"interpretation": {
"const": "text_occurrences_not_verified_definitions"
}
}
}
}
},
{
"type": "object",
"additionalProperties": false,
"required": [
"request_id",
"hypothesis_id",
"action_id",
"operation",
"request_sha256",
"data"
],
"properties": {
"request_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"hypothesis_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"action_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"operation": {
"const": "source.find_symbol"
},
"request_sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"data": {
"type": "object",
"additionalProperties": false,
"required": [
"matches",
"total_matches",
"truncated",
"next_offset",
"checked_sources",
"scope",
"interpretation"
],
"properties": {
"matches": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"source_ref",
"sha256",
"line",
"text",
"text_truncated"
],
"properties": {
"source_ref": {
"type": "string",
"minLength": 1
},
"sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"line": {
"type": "integer",
"minimum": 1
},
"text": {
"type": "string"
},
"text_truncated": {
"type": "boolean"
}
}
}
},
"total_matches": {
"type": "integer",
"minimum": 0
},
"truncated": {
"type": "boolean"
},
"next_offset": {
"type": [
"integer",
"null"
],
"minimum": 0
},
"checked_sources": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"source_ref",
"sha256",
"size"
],
"properties": {
"source_ref": {
"type": "string",
"minLength": 1
},
"sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"size": {
"type": "integer",
"minimum": 0
}
}
}
},
"scope": {
"const": "packaged_source_snapshot"
},
"interpretation": {
"const": "text_occurrences_not_verified_definitions"
}
}
}
}
},
{
"type": "object",
"additionalProperties": false,
"required": [
"request_id",
"hypothesis_id",
"action_id",
"operation",
"request_sha256",
"data"
],
"properties": {
"request_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"hypothesis_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"action_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"operation": {
"const": "source.read_context"
},
"request_sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"data": {
"type": "object",
"additionalProperties": false,
"required": [
"matches",
"total_matches",
"truncated",
"next_offset",
"checked_sources",
"scope",
"interpretation"
],
"properties": {
"matches": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"source_ref",
"sha256",
"line",
"text",
"text_truncated"
],
"properties": {
"source_ref": {
"type": "string",
"minLength": 1
},
"sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"line": {
"type": "integer",
"minimum": 1
},
"text": {
"type": "string"
},
"text_truncated": {
"type": "boolean"
}
}
}
},
"total_matches": {
"type": "integer",
"minimum": 0
},
"truncated": {
"type": "boolean"
},
"next_offset": {
"type": [
"integer",
"null"
],
"minimum": 0
},
"checked_sources": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"source_ref",
"sha256",
"size"
],
"properties": {
"source_ref": {
"type": "string",
"minLength": 1
},
"sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"size": {
"type": "integer",
"minimum": 0
}
}
}
},
"scope": {
"const": "packaged_source_snapshot"
},
"interpretation": {
"const": "text_occurrences_not_verified_definitions"
}
}
}
}
},
{
"type": "object",
"additionalProperties": false,
"required": [
"request_id",
"hypothesis_id",
"action_id",
"operation",
"request_sha256",
"data"
],
"properties": {
"request_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"hypothesis_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"action_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"operation": {
"const": "database.describe"
},
"request_sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"data": {
"type": "object",
"additionalProperties": false,
"required": [
"table",
"columns",
"coverage",
"checked_sources",
"scope"
],
"properties": {
"table": {
"type": "string",
"minLength": 1
},
"columns": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"type"
],
"properties": {
"name": {
"type": "string",
"minLength": 1
},
"type": {
"type": "string",
"minLength": 1
}
}
}
},
"coverage": {
"enum": [
"partial",
"complete_snapshot",
"unknown",
"schema_only"
]
},
"checked_sources": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"source_ref",
"sha256",
"size"
],
"properties": {
"source_ref": {
"type": "string",
"minLength": 1
},
"sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"size": {
"type": "integer",
"minimum": 0
}
}
}
},
"scope": {
"const": "packaged_database_snapshot"
}
}
}
}
},
{
"type": "object",
"additionalProperties": false,
"required": [
"request_id",
"hypothesis_id",
"action_id",
"operation",
"request_sha256",
"data"
],
"properties": {
"request_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"hypothesis_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"action_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"operation": {
"const": "database.lookup"
},
"request_sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"data": {
"type": "object",
"additionalProperties": false,
"required": [
"matches",
"total_matches",
"truncated",
"next_offset",
"checked_sources",
"coverage",
"scope",
"interpretation"
],
"properties": {
"matches": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"row",
"source_ref",
"sha256",
"json_pointer"
],
"properties": {
"row": {
"type": "object"
},
"source_ref": {
"type": "string",
"minLength": 1
},
"sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"json_pointer": {
"type": "string",
"minLength": 1
}
}
}
},
"total_matches": {
"type": "integer",
"minimum": 0
},
"truncated": {
"type": "boolean"
},
"next_offset": {
"type": [
"integer",
"null"
],
"minimum": 0
},
"checked_sources": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"source_ref",
"sha256",
"size"
],
"properties": {
"source_ref": {
"type": "string",
"minLength": 1
},
"sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"size": {
"type": "integer",
"minimum": 0
}
}
}
},
"coverage": {
"enum": [
"partial",
"complete_snapshot",
"unknown",
"schema_only"
]
},
"scope": {
"const": "packaged_database_snapshot"
},
"interpretation": {
"const": "explicit_column_matches_not_inferred_relationships"
}
}
}
}
},
{
"type": "object",
"additionalProperties": false,
"required": [
"request_id",
"hypothesis_id",
"action_id",
"operation",
"request_sha256",
"data"
],
"properties": {
"request_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"hypothesis_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"action_id": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"operation": {
"const": "database.find_references"
},
"request_sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"data": {
"type": "object",
"additionalProperties": false,
"required": [
"matches",
"total_matches",
"truncated",
"next_offset",
"checked_sources",
"coverage",
"scope",
"interpretation"
],
"properties": {
"matches": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"row",
"source_ref",
"sha256",
"json_pointer"
],
"properties": {
"row": {
"type": "object"
},
"source_ref": {
"type": "string",
"minLength": 1
},
"sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"json_pointer": {
"type": "string",
"minLength": 1
}
}
}
},
"total_matches": {
"type": "integer",
"minimum": 0
},
"truncated": {
"type": "boolean"
},
"next_offset": {
"type": [
"integer",
"null"
],
"minimum": 0
},
"checked_sources": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"source_ref",
"sha256",
"size"
],
"properties": {
"source_ref": {
"type": "string",
"minLength": 1
},
"sha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"size": {
"type": "integer",
"minimum": 0
}
}
}
},
"coverage": {
"enum": [
"partial",
"complete_snapshot",
"unknown",
"schema_only"
]
},
"scope": {
"const": "packaged_database_snapshot"
},
"interpretation": {
"const": "explicit_column_matches_not_inferred_relationships"
}
}
}
}
}
]
}
}
}
}
+6
View File
@@ -0,0 +1,6 @@
"""Read-only, structured investigation queries over declared case snapshots."""
from .config import ToolError, ToolLimits
from .router import InvestigationToolRouter
__all__ = ["ToolError", "ToolLimits", "InvestigationToolRouter"]
+27
View File
@@ -0,0 +1,27 @@
from dataclasses import asdict, dataclass
class ToolError(Exception):
def __init__(self, code):
self.code = code
super().__init__(code)
@dataclass(frozen=True)
class ToolLimits:
"""Operational limits, independent of the versioned request/result format."""
max_request_bytes: int = 65536
max_batch_requests: int = 16
max_files: int = 64
max_file_bytes: int = 8 * 1024 * 1024
max_total_bytes: int = 32 * 1024 * 1024
max_rows: int = 100000
max_matches: int = 100
max_context_lines: int = 200
max_line_characters: int = 4096
max_result_bytes: int = 2 * 1024 * 1024
def __post_init__(self):
if any(type(value) is not int or value <= 0 for value in asdict(self).values()):
raise ToolError("TOOL_LIMIT_CONFIG")
+62
View File
@@ -0,0 +1,62 @@
import hashlib
from viano_bug_analysis.case.assembler import CaseAssembler, CaseBuilderLimits
from viano_bug_analysis.case.contracts import read_json, require_file
from viano_bug_analysis.package.manifest import parse_manifest
from .config import ToolError
class QueryContext:
def __init__(self, reader, limits, case_limits=None):
self.reader, self.limits = reader, limits
self.used_bytes = 0
self.cache = {}
manifest = reader.manifest
self.case_ref = manifest["artifacts"].get("case")
self.case = read_json(reader, self.case_ref, "case", (case_limits or CaseBuilderLimits()).max_case_bytes)
if any(self.case["case"][field] != manifest[field] for field in ("case_id", "package_id", "analysis_revision")):
raise ToolError("TOOL_CASE_IDENTITY")
current = CaseAssembler(case_limits).assemble(reader)
if current["inputs"]["request_hash"] != self.case["inputs"]["request_hash"]:
raise ToolError("TOOL_STALE_CASE")
if any(current[field] != self.case[field] for field in ("source_context", "database_context")):
raise ToolError("TOOL_SOURCE_CONFLICT")
self.sources = {}
for role in ("source", "database"):
records = self.case[role + "_context"]["refs"]
if len({record["path"] for record in records}) != len(records):
raise ToolError("TOOL_SOURCE_CONFLICT")
self.sources[role] = {record["path"]: record for record in records}
def check_ref(self, role, ref):
if ref not in self.sources[role]:
raise ToolError("TOOL_SOURCE_NOT_DECLARED")
require_file(self.reader, ref)
def read(self, role, ref):
self.check_ref(role, ref)
if ref not in self.cache:
if len(self.cache) >= self.limits.max_files:
raise ToolError("TOOL_FILE_LIMIT")
record = self.sources[role][ref]
size = self.reader._archive.getinfo(ref).file_size
if size > self.limits.max_file_bytes or self.used_bytes + size > self.limits.max_total_bytes:
raise ToolError("TOOL_INPUT_LIMIT")
with self.reader._archive.open(ref) as stream:
data = self.reader.validator._read_bounded(stream, min(self.limits.max_file_bytes, self.reader.validator.limits.max_file_bytes))
if len(data) != record["size"] or hashlib.sha256(data).hexdigest() != record["sha256"]:
raise ToolError("TOOL_SOURCE_CHANGED")
self.used_bytes += len(data)
self.cache[ref] = data
return self.cache[ref]
def json(self, ref):
try:
return parse_manifest(self.read("database", ref))
except (ValueError, UnicodeError, RecursionError):
raise ToolError("TOOL_DATABASE_JSON") from None
def provenance(self, role, ref):
record = self.sources[role][ref]
return {"source_ref": ref, "sha256": record["sha256"], "size": record["size"]}
@@ -0,0 +1,80 @@
from .config import ToolError
class DatabaseQueryTool:
"""Structured equality filters over packaged JSON, never a SQL connection."""
def __init__(self, descriptor_adapter=None):
self.descriptor_adapter = descriptor_adapter
def prepare(self, context, request):
args = request["arguments"]
context.check_ref("database", args["source_ref"])
if args.get("limit", 1) > context.limits.max_matches:
raise ToolError("TOOL_MATCH_LIMIT")
operation = request["operation"]
document = context.json(args["source_ref"])
if operation == "database.describe":
if self.descriptor_adapter is not None:
document = self.descriptor_adapter(document)
if not isinstance(document, dict) or document.get("format") != "viano-database-snapshot" or document.get("format_version") != 1:
raise ToolError("TOOL_DATABASE_DESCRIPTOR")
from viano_bug_analysis.case.contracts import validate
validate(document, "database-snapshot")
self._check_snapshot(document, context.limits.max_rows)
return document, [], "", document["coverage"]
if isinstance(document, list):
rows, pointer, coverage = document, "", "unknown"
elif isinstance(document, dict) and document.get("format") == "viano-database-snapshot":
from viano_bug_analysis.case.contracts import validate
validate(document, "database-snapshot")
self._check_snapshot(document, context.limits.max_rows)
rows, pointer, coverage = document["rows"], "/rows", document["coverage"]
else:
raise ToolError("TOOL_DATABASE_ROWS")
if len(rows) > context.limits.max_rows or any(not isinstance(row, dict) for row in rows):
raise ToolError("TOOL_DATABASE_ROWS")
columns = set().union(*(row.keys() for row in rows)) if rows else set()
if isinstance(document, dict):
columns |= {column["name"] for column in document["columns"]}
requested = set(args.get("filters", {})) | set(args.get("columns", []))
if operation == "database.find_references":
requested |= set(args["search_columns"])
if not requested <= columns:
# An empty raw array supplies no verified column schema.
raise ToolError("TOOL_DATABASE_COLUMN_UNKNOWN")
return document, rows, pointer, coverage
@staticmethod
def _check_snapshot(document, max_rows):
names = [column["name"] for column in document["columns"]]
if (len(set(names)) != len(names) or len(document["rows"]) > max_rows
or any(not set(row) <= set(names) for row in document["rows"])
or (document["coverage"] == "schema_only" and document["rows"])):
raise ToolError("TOOL_DATABASE_DESCRIPTOR")
def execute(self, context, request):
document, rows, pointer, coverage = self.prepare(context, request)
args, operation = request["arguments"], request["operation"]
if operation == "database.describe":
return {"table": document["table"], "columns": document["columns"], "coverage": document["coverage"],
"checked_sources": [context.provenance("database", args["source_ref"])], "scope": "packaged_database_snapshot"}
def same(left, right):
return type(left) is type(right) and left == right
def predicate(row):
if operation == "database.lookup":
return all(name in row and same(row[name], value) for name, value in args["filters"].items())
return any(name in row and same(row[name], args["value"]) for name in args["search_columns"])
matches, total = [], 0
for index, row in enumerate(rows):
if not predicate(row):
continue
if args["offset"] <= total < args["offset"] + args["limit"]:
value = {name: row[name] for name in args["columns"] if name in row} if args.get("columns") else row
matches.append({"row": value, "source_ref": args["source_ref"], "sha256": context.sources["database"][args["source_ref"]]["sha256"],
"json_pointer": f"{pointer}/{index}"})
total += 1
return {"matches": matches, "total_matches": total, "checked_sources": [context.provenance("database", args["source_ref"])],
"truncated": total > args["offset"] + len(matches),
"next_offset": args["offset"] + len(matches) if total > args["offset"] + len(matches) else None,
"coverage": coverage, "scope": "packaged_database_snapshot", "interpretation": "explicit_column_matches_not_inferred_relationships"}
+95
View File
@@ -0,0 +1,95 @@
import copy
import hashlib
from importlib.resources import files
import json
from viano_bug_analysis.case.contracts import read_json, validate
from viano_bug_analysis.case.errors import CaseBuildError
from viano_bug_analysis.lifecycle import State
from viano_bug_analysis.lifecycle.storage import json_bytes
from viano_bug_analysis.package import PackageReader, PackageReadError
from viano_bug_analysis.package.validator import ARCHIVE_ERRORS
from .config import ToolError, ToolLimits
from .context import QueryContext
from .database_query import DatabaseQueryTool
from .source_query import SourceQueryTool
OPERATIONS = ("source.find_text", "source.find_symbol", "source.read_context",
"database.describe", "database.lookup", "database.find_references")
class InvestigationToolRouter:
"""Validate a whole batch before dispatch; return results without case writes.
Budget reservation, result persistence, asynchronous handoffs and model calls
remain responsibilities of the trusted controller, not investigation tools.
"""
def __init__(self, limits=None, *, database_tool=None, case_limits=None):
self.limits = limits or ToolLimits()
self.case_limits = case_limits
self.source = SourceQueryTool()
self.database = database_tool or DatabaseQueryTool()
@staticmethod
def request_schema():
return json.loads(files("viano_bug_analysis.schemas").joinpath("investigation-request-v1.schema.json").read_text())
def query(self, package, batch):
try:
batch = copy.deepcopy(batch)
if len(json_bytes(batch)) > self.limits.max_request_bytes:
raise ToolError("TOOL_REQUEST_LIMIT")
validate(batch, "investigation-request")
requests = batch["requests"]
if type(batch["analysis_revision"]) is not int or any(
type(request["arguments"][field]) is not int
for request in requests for field in ("offset", "limit", "start_line", "end_line")
if field in request["arguments"]):
raise ToolError("TOOL_REQUEST_INTEGER")
if len(requests) > self.limits.max_batch_requests:
raise ToolError("TOOL_BATCH_LIMIT")
if (len({r["request_id"] for r in requests}) != len(requests)
or len({(r["hypothesis_id"], r["action_id"]) for r in requests}) != len(requests)):
raise ToolError("TOOL_DUPLICATE_REQUEST")
with PackageReader(package) as reader:
manifest = reader.manifest
if any(batch[field] != manifest[field] for field in ("case_id", "package_id", "analysis_revision")):
raise ToolError("TOOL_IDENTITY")
if manifest["state"] not in {State.GENERATING_HYPOTHESES, State.INVESTIGATING_HYPOTHESES}:
raise ToolError("TOOL_PHASE")
context = QueryContext(reader, self.limits, self.case_limits)
hypotheses = read_json(reader, manifest["artifacts"].get("hypotheses"), "hypotheses", self.limits.max_result_bytes)
if any(hypotheses[field] != manifest[field] for field in ("case_id", "package_id", "analysis_revision")):
raise ToolError("TOOL_HYPOTHESIS_IDENTITY")
known = {record["id"] for record in hypotheses["hypotheses"]}
prepared = []
for request in requests:
if request["hypothesis_id"] not in known:
raise ToolError("TOOL_HYPOTHESIS_UNKNOWN")
tool = self.source if request["operation"].startswith("source.") else self.database
tool.prepare(context, request)
prepared.append((tool, request))
results = []
for tool, request in prepared:
result = {"request_id": request["request_id"], "hypothesis_id": request["hypothesis_id"],
"action_id": request["action_id"], "operation": request["operation"],
"request_sha256": hashlib.sha256(json_bytes(request)).hexdigest(),
"data": tool.execute(context, request)}
results.append(result)
if len(json_bytes(results)) > self.limits.max_result_bytes:
raise ToolError("TOOL_RESULT_LIMIT")
document = {"format": "viano-investigation-result", "format_version": 1,
**{field: manifest[field] for field in ("case_id", "package_id", "analysis_revision", "package_generation")},
"case_ref": context.case_ref, "case_sha256": hashlib.sha256(json_bytes(context.case)).hexdigest(),
"batch_sha256": hashlib.sha256(json_bytes(batch)).hexdigest(), "results": results}
validate(document, "investigation-result")
if len(json_bytes(document)) > self.limits.max_result_bytes:
raise ToolError("TOOL_RESULT_LIMIT")
return document
except (CaseBuildError, PackageReadError) as error:
raise ToolError(getattr(error, "code", "TOOL_PACKAGE")) from None
except ARCHIVE_ERRORS + (ValueError, TypeError, KeyError, UnicodeError, RecursionError):
raise ToolError("TOOL_INPUT") from None
@@ -0,0 +1,67 @@
import re
from itertools import islice
from .config import ToolError
class SourceQueryTool:
def prepare(self, context, request):
args, operation = request["arguments"], request["operation"]
refs = args.get("source_refs", [args["source_ref"]] if "source_ref" in args else [])
if len(refs) > context.limits.max_files:
raise ToolError("TOOL_FILE_LIMIT")
for ref in refs:
context.check_ref("source", ref)
try:
text = context.read("source", ref).decode("utf-8-sig")
except UnicodeError:
raise ToolError("TOOL_SOURCE_ENCODING") from None
if "\x00" in text:
raise ToolError("TOOL_SOURCE_BINARY")
if operation == "source.read_context" and args["end_line"] > len(text.splitlines()):
raise ToolError("TOOL_CONTEXT_RANGE")
if operation == "source.read_context":
if args["end_line"] < args["start_line"] or args["end_line"] - args["start_line"] + 1 > context.limits.max_context_lines:
raise ToolError("TOOL_CONTEXT_RANGE")
elif args["limit"] > context.limits.max_matches:
raise ToolError("TOOL_MATCH_LIMIT")
return refs
def execute(self, context, request):
args, operation = request["arguments"], request["operation"]
refs = self.prepare(context, request)
matches, checked = [], []
total = 0
offset = args.get("offset", 0)
limit = args.get("limit", context.limits.max_context_lines)
for ref in refs:
try:
text = context.read("source", ref).decode("utf-8-sig")
except UnicodeError:
raise ToolError("TOOL_SOURCE_ENCODING") from None
if "\x00" in text:
raise ToolError("TOOL_SOURCE_BINARY")
lines = text.splitlines()
checked.append(context.provenance("source", ref))
if operation == "source.read_context":
if args["start_line"] > len(lines) or args["end_line"] > len(lines):
raise ToolError("TOOL_CONTEXT_RANGE")
selected = islice(enumerate(lines, 1), args["start_line"]-1, args["end_line"])
else:
if operation == "source.find_symbol":
pattern = re.compile(r"(?<![A-Za-z0-9_])" + re.escape(args["symbol"]) + r"(?![A-Za-z0-9_])")
predicate = lambda line: pattern.search(line) is not None
else:
needle = args["text"] if args["case_sensitive"] else args["text"].casefold()
predicate = lambda line: needle in (line if args["case_sensitive"] else line.casefold())
selected = ((number, line) for number, line in enumerate(lines, 1) if predicate(line))
for number, line in selected:
if offset <= total < offset + limit:
matches.append({"source_ref": ref, "sha256": context.sources["source"][ref]["sha256"],
"line": number, "text": line[:context.limits.max_line_characters],
"text_truncated": len(line) > context.limits.max_line_characters})
total += 1
return {"matches": matches, "checked_sources": checked, "total_matches": total,
"truncated": total > offset + len(matches),
"next_offset": offset + len(matches) if total > offset + len(matches) else None,
"scope": "packaged_source_snapshot", "interpretation": "text_occurrences_not_verified_definitions"}
+21
View File
@@ -93,4 +93,25 @@ def test_full_bug20_package_pair(tmp_path):
if name != "manifest.json":
assert before.read_bytes(name) == after.read_bytes(name)
assert CaseBuilder(store).build(*identity).reused
from viano_bug_analysis.tools import InvestigationToolRouter
from viano_bug_analysis.tools.database_query import DatabaseQueryTool
from viano_bug_analysis.profiles.vcore.db_tools import normalize_schema_descriptor
code_ref = next(r["path"] for r in case["source_context"]["refs"] if r["path"].endswith(".cpp"))
schema_ref = next(r["path"] for r in case["database_context"]["refs"] if r["path"].endswith("/database/schemas/quest_template.json"))
rows_ref = next(r["path"] for r in case["database_context"]["refs"] if r["path"].endswith("/database/quest_template.json"))
request = {"format":"viano-investigation-request","format_version":1,"case_id":identity[0],"package_id":identity[1],"analysis_revision":1,
"requests":[{"request_id":"source-query","hypothesis_id":"H1","action_id":"source-query","operation":"source.read_context",
"arguments":{"source_ref":code_ref,"start_line":1,"end_line":3}},
{"request_id":"schema-query","hypothesis_id":"H2","action_id":"schema-query","operation":"database.describe",
"arguments":{"source_ref":schema_ref}},
{"request_id":"rows-query","hypothesis_id":"H3","action_id":"rows-query","operation":"database.lookup",
"arguments":{"source_ref":rows_ref,"filters":{"ID":11117},"columns":["ID"],"offset":0,"limit":10}}]}
before_query_hash = hashlib.sha256(accounted.package.read_bytes()).hexdigest()
queried = InvestigationToolRouter(database_tool=DatabaseQueryTool(normalize_schema_descriptor)).query(accounted.package,request)
assert len(queried["results"][0]["data"]["matches"]) == 3
assert queried["results"][1]["data"]["table"] == "quest_template"
assert queried["results"][1]["data"]["coverage"] == "schema_only"
assert queried["results"][2]["data"]["matches"][0]["row"] == {"ID":11117}
assert queried["results"][2]["data"]["coverage"] == "unknown"
assert hashlib.sha256(accounted.package.read_bytes()).hexdigest() == before_query_hash
assert [hashlib.sha256(path.read_bytes()).hexdigest() for path in (diag, src)] == original_hashes
+189
View File
@@ -0,0 +1,189 @@
import asyncio
import copy
import hashlib
import json
from importlib.resources import files
import pytest
from jsonschema import Draft202012Validator
from viano_bug_analysis.analysis import CaseInterpreter, HypothesisBuilder
from viano_bug_analysis.case import CaseBuilder
from viano_bug_analysis.lifecycle import State
from viano_bug_analysis.package import PackageReader, PackageWriter
from viano_bug_analysis.profiles.vcore.db_tools import normalize_schema_descriptor
from viano_bug_analysis.tools import InvestigationToolRouter, ToolError, ToolLimits
from viano_bug_analysis.tools.database_query import DatabaseQueryTool
from test_case_builder import case_setup
from test_case_interpreter import gateway, FixtureAdapter
from test_hypothesis_builder import ProposalAdapter
@pytest.fixture
def tool_case(case_setup, tmp_path):
store, _, _, _, _, old_index = case_setup
index = copy.deepcopy(old_index)
index["context"]["source"].append("tool/code.cpp")
index["context"]["database"] += ["tool/db.json", "tool/schema.json", "tool/raw.json"]
snapshot = {"format":"viano-database-snapshot","format_version":1,"table":"switches",
"columns":[{"name":"id","type":"integer"},{"name":"enabled","type":"boolean"},{"name":"ref","type":"text"}],
"rows":[{"id":1,"enabled":False,"ref":"A"},{"id":2,"enabled":True,"ref":"A"},{"id":3,"enabled":False,"ref":"B"}],"coverage":"partial"}
schema = {"table":"switches","columns":[{"column_name":"id","column_type":"int(11)"}]}
incoming = tmp_path / "tools-input.zip"
PackageWriter().write(store.latest("case-A","stable-package"), incoming,
additions={"tool/code.cpp":b"void activate() {\n activate();\n activate_more();\n}\n",
"tool/db.json":json.dumps(snapshot).encode(),"tool/schema.json":json.dumps(schema).encode(),
"tool/raw.json":b'[{"id":1},{"id":"1"},{"id":true}]',"tool/index.json":json.dumps(index).encode()},
manifest_updates={"artifacts":{"case_inputs":"tool/index.json"}})
store.intake(incoming)
CaseBuilder(store).build("case-A","stable-package")
asyncio.run(CaseInterpreter(store,gateway(FixtureAdapter())).interpret("case-A","stable-package"))
result = asyncio.run(HypothesisBuilder(store,gateway(ProposalAdapter())).build("case-A","stable-package"))
return store,result.package
def batch(operation="source.find_text", arguments=None):
args = arguments if arguments is not None else {"source_refs":["tool/code.cpp"],"text":"activate","case_sensitive":True,"offset":0,"limit":10}
return {"format":"viano-investigation-request","format_version":1,"case_id":"case-A","package_id":"stable-package","analysis_revision":1,
"requests":[{"request_id":"Q1","hypothesis_id":"H1","action_id":"A1","operation":operation,"arguments":args}]}
def query(tool_case, operation="source.find_text", args=None, router=None):
return (router or InvestigationToolRouter()).query(tool_case[1],batch(operation,args))["results"][0]["data"]
def test_literal_search_provenance_pagination_and_no_writes(tool_case):
original = hashlib.sha256(tool_case[1].read_bytes()).hexdigest()
args = batch()["requests"][0]["arguments"]
args["limit"] = 1
first = query(tool_case,args=args)
assert first["total_matches"] == 3 and first["truncated"] and first["next_offset"] == 1
assert first["matches"][0]["line"] == 1
with PackageReader(tool_case[1]) as reader:
assert first["matches"][0]["sha256"] == hashlib.sha256(reader.read_bytes("tool/code.cpp")).hexdigest()
args["offset"] = 2
last = query(tool_case,args=args)
assert last["matches"][0]["line"] == 3 and not last["truncated"]
assert hashlib.sha256(tool_case[1].read_bytes()).hexdigest() == original
assert tool_case[0].latest("case-A","stable-package") == tool_case[1]
def test_symbol_occurrences_not_substrings_or_definitions(tool_case):
result = query(tool_case,"source.find_symbol",{"source_refs":["tool/code.cpp"],"symbol":"activate","offset":0,"limit":10})
assert [m["line"] for m in result["matches"]] == [1,2]
assert result["interpretation"] == "text_occurrences_not_verified_definitions"
def test_read_context_and_long_line_marking(tool_case):
result = query(tool_case,"source.read_context",{"source_ref":"tool/code.cpp","start_line":2,"end_line":3},
InvestigationToolRouter(ToolLimits(max_line_characters=4)))
assert [m["line"] for m in result["matches"]] == [2,3]
assert all(m["text_truncated"] for m in result["matches"])
def test_database_structured_lookup_and_row_pointer(tool_case):
result = query(tool_case,"database.lookup",{"source_ref":"tool/db.json","filters":{"enabled":False},"columns":["id"],"offset":0,"limit":1})
assert result["matches"][0]["row"] == {"id":1}
assert result["matches"][0]["json_pointer"] == "/rows/0"
assert result["coverage"] == "partial" and result["next_offset"] == 1
assert result["total_matches"] == 2
def test_database_references_are_explicit_values_not_relationship_claims(tool_case):
result = query(tool_case,"database.find_references",{"source_ref":"tool/db.json","search_columns":["ref"],"value":"A","columns":[],"offset":0,"limit":10})
assert [m["row"]["id"] for m in result["matches"]] == [1,2]
assert result["interpretation"] == "explicit_column_matches_not_inferred_relationships"
@pytest.mark.parametrize("value,index", [(1,0),("1",1),(True,2)])
def test_database_equality_does_not_coerce_boolean_string_or_number(tool_case,value,index):
result = query(tool_case,"database.lookup",{"source_ref":"tool/raw.json","filters":{"id":value},"columns":[],"offset":0,"limit":10})
assert result["total_matches"] == 1 and result["matches"][0]["json_pointer"] == f"/{index}"
assert result["coverage"] == "unknown"
def test_describe_generic_and_vcore_descriptor(tool_case):
result = query(tool_case,"database.describe",{"source_ref":"tool/db.json"})
assert result["table"] == "switches" and len(result["columns"]) == 3
router = InvestigationToolRouter(database_tool=DatabaseQueryTool(normalize_schema_descriptor))
result = query(tool_case,"database.describe",{"source_ref":"tool/schema.json"},router)
assert result["coverage"] == "schema_only" and result["columns"] == [{"name":"id","type":"int(11)"}]
@pytest.mark.parametrize("change", [
lambda b: b["requests"][0].update(operation="shell.exec"),
lambda b: b["requests"][0].update(operation="testing.request_human"),
lambda b: b["requests"][0]["arguments"].update(sql="DROP TABLE switches"),
lambda b: b["requests"][0]["arguments"].update(source_refs=["../escape"]),
lambda b: b["requests"][0]["arguments"].update(source_refs=["C:/server/code.cpp"]),
lambda b: b["requests"][0]["arguments"].update(source_refs=["tool/db.json"]),
lambda b: b["requests"][0]["arguments"].update(source_refs=["unknown/vendor"]),
lambda b: b["requests"][0]["arguments"].update(source_refs=["manifest.json"]),
lambda b: b["requests"][0]["arguments"].update(source_refs=["tool/code.cpp","tool/code.cpp"]),
lambda b: b.update(analysis_revision=2),
lambda b: b["requests"][0].update(hypothesis_id="H99"),
lambda b: b["requests"][0]["arguments"].update(limit=0),
lambda b: b["requests"][0]["arguments"].update(limit=10.0),
lambda b: b["requests"][0]["arguments"].update(text=""),
])
def test_unsafe_unsupported_and_wrongly_bound_requests_rejected(tool_case,change):
request = batch()
change(request)
with pytest.raises(ToolError):
InvestigationToolRouter().query(tool_case[1],request)
def test_whole_batch_preflight_before_any_dispatch(tool_case,monkeypatch):
router = InvestigationToolRouter()
request = batch()
invalid = batch("database.lookup",{"source_ref":"tool/db.json","filters":{"unknown":1},"columns":[],"offset":0,"limit":1})["requests"][0]
invalid.update(request_id="Q2",action_id="A2")
request["requests"].append(invalid)
calls=[]
monkeypatch.setattr(router.source,"execute",lambda *args: calls.append("dispatched"))
with pytest.raises(ToolError,match="TOOL_DATABASE_COLUMN_UNKNOWN"):
router.query(tool_case[1],request)
assert not calls
def test_search_empty_match_is_scoped_and_regex_is_literal(tool_case):
args=batch()["requests"][0]["arguments"]
args["text"]=".*"
result=query(tool_case,args=args)
assert result["matches"] == [] and result["scope"] == "packaged_source_snapshot"
assert result["checked_sources"][0]["source_ref"] == "tool/code.cpp"
def test_repeated_request_identity_rejected(tool_case):
request=batch()
request["requests"].append(copy.deepcopy(request["requests"][0]))
with pytest.raises(ToolError,match="TOOL_DUPLICATE_REQUEST"):
InvestigationToolRouter().query(tool_case[1],request)
def test_context_ranges_and_operational_limits(tool_case):
for start,end in [(0,1),(1,99),(3,2)]:
with pytest.raises(ToolError):
query(tool_case,"source.read_context",{"source_ref":"tool/code.cpp","start_line":start,"end_line":end})
for limits in [ToolLimits(max_file_bytes=2),ToolLimits(max_total_bytes=2),ToolLimits(max_matches=1),ToolLimits(max_request_bytes=2),ToolLimits(max_result_bytes=2)]:
with pytest.raises(ToolError):
query(tool_case,router=InvestigationToolRouter(limits))
def test_blocked_case_cannot_issue_queries(tool_case):
store,_=tool_case
path=store.advance("case-A","stable-package",State.BLOCKED,step_id="wait",reason="PROVIDER_UNAVAILABLE",retry_not_before="2099-01-01T00:00:00Z")
with pytest.raises(ToolError,match="TOOL_PHASE"):
InvestigationToolRouter().query(path,batch())
def test_new_undeclared_input_invalidates_query_context(tool_case,tmp_path):
changed=tmp_path/"changed.zip"
PackageWriter().write(tool_case[1],changed,additions={"new/raw":b"evidence"})
with pytest.raises(ToolError,match="TOOL_STALE_CASE"):
InvestigationToolRouter().query(changed,batch())
@pytest.mark.parametrize("name", ["investigation-request","investigation-result","database-snapshot"])
def test_new_schemas_valid(name):
Draft202012Validator.check_schema(json.loads(files("viano_bug_analysis.schemas").joinpath(name+'-v1.schema.json').read_text()))