Add a read-only app resource session shared by CLI and MCP. Preserve resource-root provenance without inventing executable identity, reject binary-only snapshots and operations, and retain failed cleanup owners across switches and retries. Keep filesystem admission failures typed and preserve cancellation ordering from observed cleanup facts. Document resource routing in the packaged investigation skill. BREAKING CHANGE: resource-only observations may have a null Evidence subject and null executable target digests. Clients must use resource_root and reported per-resource provenance instead. Refs #5589
122 lines
4.3 KiB
TypeScript
122 lines
4.3 KiB
TypeScript
import { isAbsolute } from "node:path";
|
|
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
|
import type { SqliteDatabasePort } from "./SqliteDatabasePort.js";
|
|
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
|
import {
|
|
AnalysisCancelledError,
|
|
AnalysisInputError,
|
|
AnalysisOutputError,
|
|
} from "../../domain/analysisErrorCore.js";
|
|
import {
|
|
createEvidence,
|
|
type Evidence,
|
|
type EvidenceSubjectTarget,
|
|
} from "../../domain/evidence.js";
|
|
import {
|
|
inspectSqliteDatabaseInputSchema,
|
|
sqliteDatabaseSchema,
|
|
type InspectSqliteDatabaseInput,
|
|
type SqliteDatabase,
|
|
SQLITE_ROW_LIMIT_DEFAULT,
|
|
sqliteSchemaScope,
|
|
sqliteInspectionParameters,
|
|
} from "../../domain/sqlite/sqliteDatabase.js";
|
|
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
|
import { err, ok, type Result } from "../../domain/result.js";
|
|
import { jsonValueSchema } from "../../domain/jsonValue.js";
|
|
|
|
const OPERATION = "inspect_sqlite_database";
|
|
|
|
const matchesSelectedDatabase = (
|
|
report: SqliteDatabase,
|
|
input: InspectSqliteDatabaseInput,
|
|
subject: EvidenceSubjectTarget,
|
|
): boolean =>
|
|
report.artifact.path === input.path &&
|
|
subject.path === input.path &&
|
|
subject.sha256 === report.artifact.sha256 &&
|
|
subject.format === "file" &&
|
|
(report.wal === null || report.wal.path === `${input.path}-wal`) &&
|
|
report.schema.scope === sqliteSchemaScope(input) &&
|
|
(report.schema.scope !== "selected_table" ||
|
|
(report.schema.tables.length === 1 &&
|
|
report.schema.tables[0]?.name === input.table &&
|
|
report.schema.indexes.every((index) => index.table === input.table) &&
|
|
report.schema.triggers.every(
|
|
(trigger) => trigger.table === input.table,
|
|
) &&
|
|
report.schema.views.length === 0)) &&
|
|
(input.table === undefined
|
|
? report.rows === null
|
|
: report.rows?.table === input.table &&
|
|
report.rows.row_limit === (input.row_limit ?? SQLITE_ROW_LIMIT_DEFAULT) &&
|
|
(input.columns === undefined ||
|
|
(report.rows.columns.length === input.columns.length &&
|
|
report.rows.columns.every(
|
|
(name, index) => name === input.columns?.[index],
|
|
))));
|
|
|
|
/** Shared snapshot inspection and caller-selection validation for CLI and MCP. */
|
|
export class SqliteDatabaseService {
|
|
constructor(readonly provider: SqliteDatabasePort) {}
|
|
|
|
/** Await provider shutdown, retaining failed cleanup owners for retry. */
|
|
close(): Promise<void> {
|
|
return this.provider.close?.() ?? Promise.resolve();
|
|
}
|
|
|
|
/** Inspect an explicit database snapshot and preserve its original database/WAL identity. */
|
|
async inspect(
|
|
rawInput: unknown,
|
|
options?: ExecutionOptions,
|
|
): Promise<Result<Evidence, AnalysisError>> {
|
|
if (options?.signal?.aborted)
|
|
return err(new AnalysisCancelledError(OPERATION));
|
|
const input = inspectSqliteDatabaseInputSchema.safeParse(rawInput);
|
|
if (!input.success)
|
|
return err(
|
|
analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput, {
|
|
cause: input.error,
|
|
}),
|
|
);
|
|
if (!isAbsolute(input.data.path))
|
|
return err(
|
|
new AnalysisInputError(OPERATION, undefined, [
|
|
{
|
|
path: ["path"],
|
|
reason: "invalid_format",
|
|
message: "Expected an absolute filesystem path on this host.",
|
|
},
|
|
]),
|
|
);
|
|
const inspected = await this.provider.inspect(input.data, options);
|
|
if (!inspected.ok) return inspected;
|
|
const execution = inspected.value;
|
|
const report = sqliteDatabaseSchema.safeParse(execution.result);
|
|
if (
|
|
!report.success ||
|
|
execution.subject == null ||
|
|
!matchesSelectedDatabase(report.data, input.data, execution.subject)
|
|
)
|
|
return err(
|
|
new AnalysisOutputError(
|
|
OPERATION,
|
|
"SQLite adapter returned malformed data or changed the selected database identity/table/row limit.",
|
|
),
|
|
);
|
|
return ok(
|
|
createEvidence(execution.subject, execution.provider, {
|
|
operation: OPERATION,
|
|
parameters: sqliteInspectionParameters(input.data),
|
|
result: jsonValueSchema.parse(report.data),
|
|
...(execution.rawResult === null
|
|
? {}
|
|
: { rawResult: execution.rawResult }),
|
|
confidence: "observed",
|
|
locations: execution.locations,
|
|
limitations: execution.limitations,
|
|
}),
|
|
);
|
|
}
|
|
}
|