From 66c76a99301ab2f69ae2ad943ca569afb90c269c Mon Sep 17 00:00:00 2001 From: Nitsu45 Date: Wed, 14 Oct 2026 00:50:46 +0200 Subject: [PATCH] Add strict read-only source and database investigation tools --- README.md | 8 +- docs/investigation-tools-v1.md | 148 +++ .../profiles/vcore/db_tools.py | 19 + .../schemas/database-snapshot-v1.schema.json | 63 ++ .../investigation-request-v1.schema.json | 440 +++++++++ .../investigation-result-v1.schema.json | 846 ++++++++++++++++++ src/viano_bug_analysis/tools/__init__.py | 6 + src/viano_bug_analysis/tools/config.py | 27 + src/viano_bug_analysis/tools/context.py | 62 ++ .../tools/database_query.py | 80 ++ src/viano_bug_analysis/tools/router.py | 95 ++ src/viano_bug_analysis/tools/source_query.py | 67 ++ tests/profiles/vcore/test_real_package.py | 21 + tests/test_investigation_tools.py | 189 ++++ 14 files changed, 2069 insertions(+), 2 deletions(-) create mode 100644 docs/investigation-tools-v1.md create mode 100644 src/viano_bug_analysis/profiles/vcore/db_tools.py create mode 100644 src/viano_bug_analysis/schemas/database-snapshot-v1.schema.json create mode 100644 src/viano_bug_analysis/schemas/investigation-request-v1.schema.json create mode 100644 src/viano_bug_analysis/schemas/investigation-result-v1.schema.json create mode 100644 src/viano_bug_analysis/tools/__init__.py create mode 100644 src/viano_bug_analysis/tools/config.py create mode 100644 src/viano_bug_analysis/tools/context.py create mode 100644 src/viano_bug_analysis/tools/database_query.py create mode 100644 src/viano_bug_analysis/tools/router.py create mode 100644 src/viano_bug_analysis/tools/source_query.py create mode 100644 tests/test_investigation_tools.py diff --git a/README.md b/README.md index 4fb8092..98995f5 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/docs/investigation-tools-v1.md b/docs/investigation-tools-v1.md new file mode 100644 index 0000000..af1b2ac --- /dev/null +++ b/docs/investigation-tools-v1.md @@ -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. diff --git a/src/viano_bug_analysis/profiles/vcore/db_tools.py b/src/viano_bug_analysis/profiles/vcore/db_tools.py new file mode 100644 index 0000000..2a1c32a --- /dev/null +++ b/src/viano_bug_analysis/profiles/vcore/db_tools.py @@ -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"} diff --git a/src/viano_bug_analysis/schemas/database-snapshot-v1.schema.json b/src/viano_bug_analysis/schemas/database-snapshot-v1.schema.json new file mode 100644 index 0000000..b13dff6 --- /dev/null +++ b/src/viano_bug_analysis/schemas/database-snapshot-v1.schema.json @@ -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" + ] + } + } +} diff --git a/src/viano_bug_analysis/schemas/investigation-request-v1.schema.json b/src/viano_bug_analysis/schemas/investigation-request-v1.schema.json new file mode 100644 index 0000000..7059dd1 --- /dev/null +++ b/src/viano_bug_analysis/schemas/investigation-request-v1.schema.json @@ -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 + } + } + } + } + } + ] + } + } + } +} diff --git a/src/viano_bug_analysis/schemas/investigation-result-v1.schema.json b/src/viano_bug_analysis/schemas/investigation-result-v1.schema.json new file mode 100644 index 0000000..0d3dec0 --- /dev/null +++ b/src/viano_bug_analysis/schemas/investigation-result-v1.schema.json @@ -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" + } + } + } + } + } + ] + } + } + } +} diff --git a/src/viano_bug_analysis/tools/__init__.py b/src/viano_bug_analysis/tools/__init__.py new file mode 100644 index 0000000..62f3dc7 --- /dev/null +++ b/src/viano_bug_analysis/tools/__init__.py @@ -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"] diff --git a/src/viano_bug_analysis/tools/config.py b/src/viano_bug_analysis/tools/config.py new file mode 100644 index 0000000..c21a4ce --- /dev/null +++ b/src/viano_bug_analysis/tools/config.py @@ -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") diff --git a/src/viano_bug_analysis/tools/context.py b/src/viano_bug_analysis/tools/context.py new file mode 100644 index 0000000..340812b --- /dev/null +++ b/src/viano_bug_analysis/tools/context.py @@ -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"]} diff --git a/src/viano_bug_analysis/tools/database_query.py b/src/viano_bug_analysis/tools/database_query.py new file mode 100644 index 0000000..c2278b0 --- /dev/null +++ b/src/viano_bug_analysis/tools/database_query.py @@ -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"} diff --git a/src/viano_bug_analysis/tools/router.py b/src/viano_bug_analysis/tools/router.py new file mode 100644 index 0000000..81d491d --- /dev/null +++ b/src/viano_bug_analysis/tools/router.py @@ -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 diff --git a/src/viano_bug_analysis/tools/source_query.py b/src/viano_bug_analysis/tools/source_query.py new file mode 100644 index 0000000..2082635 --- /dev/null +++ b/src/viano_bug_analysis/tools/source_query.py @@ -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"(? 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"} diff --git a/tests/profiles/vcore/test_real_package.py b/tests/profiles/vcore/test_real_package.py index b264e34..8e24875 100644 --- a/tests/profiles/vcore/test_real_package.py +++ b/tests/profiles/vcore/test_real_package.py @@ -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 diff --git a/tests/test_investigation_tools.py b/tests/test_investigation_tools.py new file mode 100644 index 0000000..664add3 --- /dev/null +++ b/tests/test_investigation_tools.py @@ -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()))