From 2e4bea1f7b4f1730eed0c1e626e6dec1ac1fbc0e Mon Sep 17 00:00:00 2001 From: Dan Levy Date: Wed, 26 Aug 2026 16:44:58 -0400 Subject: [PATCH] Add evidence-gated device research lanes --- CONTEXT.md | 6 + docs/architecture.md | 2 + .../targets/device-research-admission.ts | 311 ++++++++++++++++++ src/server/targets/index.ts | 1 + .../device-research-admission.test.ts | 157 +++++++++ 5 files changed, 477 insertions(+) create mode 100644 src/server/targets/device-research-admission.ts create mode 100644 tests/integration/device-research-admission.test.ts diff --git a/CONTEXT.md b/CONTEXT.md index 0dffbeb0a..1bf0cea83 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -56,6 +56,10 @@ _Avoid_: reasoning text, inferred edge, model explanation An unresolved, citation-backed Investigation Assertion ranked for follow-up after accounting for objective relevance, missing evidence, expected information gain, target importance, cost, risk, and authorization readiness. _Avoid_: autonomous plan, agent hunch, task queue +**Device Research Lane**: +One evidence-gated authorization stage for public research, owned-device offline analysis, non-mutating interaction, or separately approved persistent/destructive work. Eligibility for a lane never creates execution authority or carries approval into another lane. +_Avoid_: device mode, blanket hardware authorization, safe command + **Shared Terminal Session**: A project/thread-scoped interactive shell session whose input, output, resize events, interrupts, approvals, and actor attribution are visible to both the researcher and approved agent automation. _Avoid_: generic shell bridge, hidden agent shell, human terminal takeover @@ -156,6 +160,8 @@ _Avoid_: hidden gold, judge assertion - A **Research Observation** may indicate several **Knowledge Concepts** through proposed, cited Investigation Assertions without becoming a **Finding**. - A **Research Observation** may preserve several external identifiers and versioned score assessments; each remains attributable to the Observation's citations and time. - A **Research Observation** may cite a message from another project thread when that discussion materially supports or contextualizes it. +- A **Device Research Lane** binds one exact operation and device identity to lane-matching authorization, evidence, stop conditions, and—when interaction is requested—a single-use exact-intent approval. +- Crossing a **Device Research Lane** always creates a new gate. Lane 4 is a separate campaign with rehearsed independent recovery and interactive irreversible checkpoints; earlier authorization never carries forward. - An **Investigation Entity** references a canonical project record when one exists instead of copying that record into the **Investigation Graph**. - An **Investigation Assertion** may be supported, contradicted, derived, revised, rejected, or left unresolved without changing the canonical record it discusses. - An **Investigation Citation** identifies why an **Investigation Assertion** exists; an **Artifact** remains the durable evidence object. diff --git a/docs/architecture.md b/docs/architecture.md index 411374ef9..03c14cfb2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -140,6 +140,8 @@ The explicit Security Knowledge Graph remains in SQLite for versioned, reusable The project Investigation Graph is an assertion layer over existing records, not another owner of Targets, Artifacts, Findings, Research Observations, Tasks, Attack Paths, Tool Runs, messages, memory, or reusable security knowledge. A Research Observation preserves measured or directly seen behavior, structured inputs and outputs, measurements, external identifiers, versioned scores, actor, time, and precise citations before interpretation. The user-facing Research Map projects canonical records, cited threads and messages, external sources, reusable-concept references, current Investigation Assertions, and Research Priorities through one coherent relational snapshot. The write model resolves canonical records through project-local Investigation Entities and stores append-only Assertions, coordinate-only role-bearing Citations, and rule-versioned Derivations with ordered inputs. Evidence state (`observed`, `derived`, `proposed`, `contradicted`, or `rejected`) stays separate from assertion lifecycle (`current`, `withdrawn`, or `superseded`). Revision is an optimistic, transactional replacement that retains the predecessor and its citations. SQLite and PostgreSQL relational queries define correctness. See [ADR 0001](./adr/0001-investigation-graph-as-assertion-layer.md). +Device research uses four server-owned admission lanes: public-source research, owned-device acquisition/offline analysis, non-mutating interaction, and a separate persistent/destructive campaign. The deterministic admission boundary checks the exact operation, physical-unit identity, lane-matching target authorization, single-use normalized approval intent where required, evidence readiness, isolation, before/after observation, and universal stop conditions. Persistent work additionally requires a new campaign, an evidenced research need, pinned original/candidate/recovery images, independent rehearsed recovery, replaceability, physical-safety planning, disclosure readiness, and operator checkpoints. An eligible result only identifies the next enforcement gate; it never creates target authorization, consumes an approval, or operates a device. + A Research Priority is an unresolved, citation-backed current assertion ranked for follow-up. Its deterministic score weights objective relevance (25%), evidence gap (20%), expected information gain (20%), target importance (15%), inverse predicate cost (8%), inverse predicate risk (7%), and authorization readiness (5%). Authorization readiness comes from the durable target ledger. Deliberately turning a Research Priority into a Task uses the existing Task workflow and a unique assertion-task receipt; it never schedules work, creates an approval, runs a tool, or promotes a Finding. The former generic security-graph repository is retired. Historical database tables may remain so existing local data is not destructively dropped, but no product path writes them and they are not authoritative. The only graph ownership boundaries are the global Security Knowledge Graph and each project's Investigation Graph. diff --git a/src/server/targets/device-research-admission.ts b/src/server/targets/device-research-admission.ts new file mode 100644 index 000000000..2a2ba4520 --- /dev/null +++ b/src/server/targets/device-research-admission.ts @@ -0,0 +1,311 @@ +export const DEVICE_RESEARCH_METHOD_VERSION = "device-research-method-v1"; + +export type DeviceResearchLane = 1 | 2 | 3 | 4; + +export type DeviceResearchOperation = + | "public-source-review" + | "public-artifact-download" + | "offline-analysis" + | "owned-read-only-acquisition" + | "non-mutating-observation" + | "persistent-write" + | "destructive-validation"; + +export type DeviceResearchAdmissionInput = { + methodVersion: typeof DEVICE_RESEARCH_METHOD_VERSION; + lane: DeviceResearchLane; + operation: DeviceResearchOperation; + intentDigest: string; + target?: { + targetId: string; + ownership: "confirmed" | "denied" | "unknown"; + authorizedBy: string; + unitIdentifier: string; + model: string; + hardwareRevision: string; + firmwareVersion: string; + includedInterfaces: string[]; + excludedSurfaces: string[]; + safetyClass: "ordinary" | "bodily-safety" | "critical-service"; + acceptableLoss: "replaceable" | "cannot-be-lost"; + }; + authorization?: { + targetAuthorizationId: string; + lane: DeviceResearchLane; + approvalId?: string; + approvalIntentDigest?: string; + approvalSingleUse?: boolean; + }; + evidence: { + artifactStorageReady: boolean; + redactionPlanReady: boolean; + synchronizedTimeReady: boolean; + sourceLedgerReady: boolean; + immutableOriginalsReady: boolean; + derivationManifestReady: boolean; + rawCaptureReady: boolean; + }; + interaction?: { + operatorPresent: boolean; + isolatedFromUnrelatedHostsAccountsAndRadios: boolean; + oneHypothesis: boolean; + effectKnownFromDocumentationOrStaticEvidence: boolean; + expectedPersistentChange: boolean; + acceptableIncidentalEffects: string[]; + baselineCaptureReady: boolean; + afterStateCaptureReady: boolean; + noChangeCheckReady: boolean; + }; + persistentCampaign?: { + campaignId: string; + newCampaign: boolean; + validatedFindingId?: string; + researchQuestionArtifactId?: string; + originalImageDigest: string; + candidateImageDigest: string; + recoveryImageDigest: string; + recoveryAvailableOffline: boolean; + recoveryIndependentOfResearchFirmware: boolean; + recoveryRehearsedOnEquivalentUnit: boolean; + compatibleRecoveryHardwareReady: boolean; + powerAndPhysicalSafetyPlanReady: boolean; + sacrificialOrReplaceableUnitReady: boolean; + interactiveIrreversibleCheckpoints: boolean; + disclosurePlanReady: boolean; + }; + stopConditions: string[]; + incidentOpen: boolean; +}; + +export type DeviceResearchAdmission = { + methodVersion: typeof DEVICE_RESEARCH_METHOD_VERSION; + lane: DeviceResearchLane; + status: "eligible-for-next-gate" | "blocked"; + executionAuthorityCreated: false; + failures: string[]; + requiredEvidence: string[]; + nextGate: + | "passive-work" + | "durable-approval-verification" + | "operator-checkpoint" + | "resolve-blockers"; +}; + +const LANE_OPERATIONS: Record< + DeviceResearchLane, + readonly DeviceResearchOperation[] +> = { + 1: ["public-source-review", "public-artifact-download"], + 2: ["offline-analysis", "owned-read-only-acquisition"], + 3: ["non-mutating-observation"], + 4: ["persistent-write", "destructive-validation"], +}; + +const LANE_EVIDENCE: Record = { + 1: ["source-ledger", "immutable-artifact-hashes", "version-map"], + 2: [ + "acquisition-record", + "derivation-manifest", + "component-and-protocol-map", + ], + 3: [ + "before-state", + "raw-transcript-or-capture", + "after-state-and-no-change-check", + ], + 4: [ + "exact-intent-approval", + "recovery-rehearsal", + "pre-and-post-images", + "restoration-result", + ], +}; + +export function assessDeviceResearchAdmission( + input: DeviceResearchAdmissionInput, +): DeviceResearchAdmission { + const failures = [ + ...validateCommon(input), + ...validateLaneAuthorization(input), + ...validateEvidence(input), + ...validateInteraction(input), + ...validatePersistentCampaign(input), + ]; + const status = failures.length === 0 ? "eligible-for-next-gate" : "blocked"; + return { + methodVersion: DEVICE_RESEARCH_METHOD_VERSION, + lane: input.lane, + status, + executionAuthorityCreated: false, + failures, + requiredEvidence: LANE_EVIDENCE[input.lane], + nextGate: + status === "blocked" + ? "resolve-blockers" + : input.lane === 1 && input.operation === "public-source-review" + ? "passive-work" + : input.lane === 4 + ? "operator-checkpoint" + : "durable-approval-verification", + }; +} + +function validateCommon(input: DeviceResearchAdmissionInput) { + const failures: string[] = []; + if (input.methodVersion !== DEVICE_RESEARCH_METHOD_VERSION) { + failures.push("method_version_unsupported"); + } + if (!LANE_OPERATIONS[input.lane]?.includes(input.operation)) { + failures.push("operation_not_allowed_in_lane"); + } + if (!input.intentDigest.trim()) + failures.push("normalized_intent_digest_missing"); + if ( + input.stopConditions.length === 0 || + input.stopConditions.some((item) => !item.trim()) + ) { + failures.push("stop_conditions_missing"); + } + if (input.incidentOpen) + failures.push("open_incident_requires_explicit_resume_process"); + if (input.lane > 1) { + if (!input.target) { + failures.push("exact_owned_device_missing"); + } else { + if (input.target.ownership !== "confirmed") + failures.push("device_ownership_not_confirmed"); + for (const [name, value] of Object.entries({ + targetId: input.target.targetId, + authorizedBy: input.target.authorizedBy, + unitIdentifier: input.target.unitIdentifier, + model: input.target.model, + hardwareRevision: input.target.hardwareRevision, + firmwareVersion: input.target.firmwareVersion, + })) { + if (!value.trim()) failures.push(`device_${name}_missing`); + } + if (input.target.includedInterfaces.length === 0) + failures.push("included_interfaces_missing"); + if (input.target.excludedSurfaces.length === 0) + failures.push("excluded_surfaces_missing"); + } + } + return failures; +} + +function validateLaneAuthorization(input: DeviceResearchAdmissionInput) { + const approvalRequired = + input.operation === "public-artifact-download" || + input.operation === "owned-read-only-acquisition" || + input.lane >= 3; + if (input.lane === 1 && !approvalRequired) return []; + + const failures: string[] = []; + if (!input.authorization?.targetAuthorizationId) { + failures.push("durable_target_authorization_missing"); + } + if (input.authorization?.lane !== input.lane) { + failures.push("authorization_lane_mismatch"); + } + if (approvalRequired) { + if (!input.authorization?.approvalId) + failures.push("exact_intent_approval_missing"); + if (input.authorization?.approvalIntentDigest !== input.intentDigest) { + failures.push("approval_intent_mismatch"); + } + if (input.authorization?.approvalSingleUse !== true) { + failures.push("single_use_approval_required"); + } + } + return failures; +} + +function validateEvidence(input: DeviceResearchAdmissionInput) { + const failures: string[] = []; + if (!input.evidence.artifactStorageReady) + failures.push("artifact_storage_not_ready"); + if (!input.evidence.redactionPlanReady) + failures.push("redaction_plan_not_ready"); + if (!input.evidence.synchronizedTimeReady) + failures.push("synchronized_time_not_ready"); + if (!input.evidence.sourceLedgerReady) + failures.push("source_ledger_not_ready"); + if (input.lane >= 2 && !input.evidence.immutableOriginalsReady) { + failures.push("immutable_originals_not_ready"); + } + if (input.lane >= 2 && !input.evidence.derivationManifestReady) { + failures.push("derivation_manifest_not_ready"); + } + if (input.lane >= 3 && !input.evidence.rawCaptureReady) + failures.push("raw_capture_not_ready"); + return failures; +} + +function validateInteraction(input: DeviceResearchAdmissionInput) { + if (input.lane < 3) return []; + const failures: string[] = []; + const interaction = input.interaction; + if (!interaction) return ["interaction_plan_missing"]; + if (!interaction.operatorPresent) failures.push("operator_presence_required"); + if (!interaction.isolatedFromUnrelatedHostsAccountsAndRadios) { + failures.push("device_and_host_isolation_not_ready"); + } + if (!interaction.oneHypothesis) failures.push("single_hypothesis_required"); + if (!interaction.baselineCaptureReady) + failures.push("baseline_capture_not_ready"); + if (!interaction.afterStateCaptureReady) + failures.push("after_state_capture_not_ready"); + if (input.lane === 3) { + if (!interaction.effectKnownFromDocumentationOrStaticEvidence) { + failures.push("operation_effect_not_established"); + } + if (interaction.expectedPersistentChange) + failures.push("persistent_change_not_allowed_in_lane_3"); + if (interaction.acceptableIncidentalEffects.length === 0) { + failures.push("acceptable_incidental_effects_missing"); + } + if (!interaction.noChangeCheckReady) + failures.push("no_change_check_not_ready"); + } + return failures; +} + +function validatePersistentCampaign(input: DeviceResearchAdmissionInput) { + if (input.lane !== 4) return []; + const campaign = input.persistentCampaign; + if (!campaign) return ["separate_persistent_campaign_missing"]; + const failures: string[] = []; + if (!campaign.campaignId.trim() || !campaign.newCampaign) { + failures.push("new_campaign_required_for_lane_4"); + } + if (!campaign.validatedFindingId && !campaign.researchQuestionArtifactId) { + failures.push("validated_finding_or_explicit_question_required"); + } + for (const [name, value] of Object.entries({ + originalImageDigest: campaign.originalImageDigest, + candidateImageDigest: campaign.candidateImageDigest, + recoveryImageDigest: campaign.recoveryImageDigest, + })) { + if (!value.trim()) failures.push(`${name}_missing`); + } + if (!campaign.recoveryAvailableOffline) + failures.push("offline_recovery_artifact_missing"); + if (!campaign.recoveryIndependentOfResearchFirmware) { + failures.push("independent_recovery_path_missing"); + } + if (!campaign.recoveryRehearsedOnEquivalentUnit) + failures.push("recovery_not_rehearsed"); + if (!campaign.compatibleRecoveryHardwareReady) + failures.push("recovery_hardware_not_ready"); + if (!campaign.powerAndPhysicalSafetyPlanReady) + failures.push("physical_safety_plan_not_ready"); + if (!campaign.sacrificialOrReplaceableUnitReady) + failures.push("replaceable_unit_not_ready"); + if (!campaign.interactiveIrreversibleCheckpoints) { + failures.push("interactive_irreversible_checkpoints_missing"); + } + if (!campaign.disclosurePlanReady) failures.push("disclosure_plan_not_ready"); + if (input.target?.acceptableLoss === "cannot-be-lost") + failures.push("unit_cannot_be_lost"); + return failures; +} diff --git a/src/server/targets/index.ts b/src/server/targets/index.ts index f3949a52b..1d07dc181 100644 --- a/src/server/targets/index.ts +++ b/src/server/targets/index.ts @@ -18,6 +18,7 @@ export { type TargetAuthorizationCoverage, } from "./authorization-ledger"; export * from "./authorized-http-fetch"; +export * from "./device-research-admission"; export { type MultiTargetScope, type MultiTargetScopeInput, diff --git a/tests/integration/device-research-admission.test.ts b/tests/integration/device-research-admission.test.ts new file mode 100644 index 000000000..cf2ac5cd2 --- /dev/null +++ b/tests/integration/device-research-admission.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; + +import { + assessDeviceResearchAdmission, + type DeviceResearchAdmissionInput, +} from "../../src/server/targets"; + +function input( + lane: DeviceResearchAdmissionInput["lane"], +): DeviceResearchAdmissionInput { + return { + methodVersion: "device-research-method-v1", + lane, + operation: + lane === 1 + ? "public-source-review" + : lane === 2 + ? "offline-analysis" + : lane === 3 + ? "non-mutating-observation" + : "persistent-write", + intentDigest: "intent-sha", + target: + lane === 1 + ? undefined + : { + targetId: "target-device", + ownership: "confirmed", + authorizedBy: "owner", + unitIdentifier: "masked-unit-id", + model: "device-model", + hardwareRevision: "hardware-revision", + firmwareVersion: "firmware-version", + includedInterfaces: ["isolated-usb"], + excludedSurfaces: ["vendor-cloud", "unrelated-radios"], + safetyClass: "ordinary", + acceptableLoss: "replaceable", + }, + authorization: + lane === 1 + ? undefined + : { + targetAuthorizationId: "authorization-row", + lane, + ...(lane >= 3 + ? { + approvalId: "approval-row", + approvalIntentDigest: "intent-sha", + approvalSingleUse: true, + } + : {}), + }, + evidence: { + artifactStorageReady: true, + redactionPlanReady: true, + synchronizedTimeReady: true, + sourceLedgerReady: true, + immutableOriginalsReady: true, + derivationManifestReady: true, + rawCaptureReady: true, + }, + interaction: + lane < 3 + ? undefined + : { + operatorPresent: true, + isolatedFromUnrelatedHostsAccountsAndRadios: true, + oneHypothesis: true, + effectKnownFromDocumentationOrStaticEvidence: true, + expectedPersistentChange: lane === 4, + acceptableIncidentalEffects: ["transient protocol counter"], + baselineCaptureReady: true, + afterStateCaptureReady: true, + noChangeCheckReady: true, + }, + persistentCampaign: + lane === 4 + ? { + campaignId: "device-campaign-2", + newCampaign: true, + researchQuestionArtifactId: "artifact-question", + originalImageDigest: "sha256:original", + candidateImageDigest: "sha256:candidate", + recoveryImageDigest: "sha256:recovery", + recoveryAvailableOffline: true, + recoveryIndependentOfResearchFirmware: true, + recoveryRehearsedOnEquivalentUnit: true, + compatibleRecoveryHardwareReady: true, + powerAndPhysicalSafetyPlanReady: true, + sacrificialOrReplaceableUnitReady: true, + interactiveIrreversibleCheckpoints: true, + disclosurePlanReady: true, + } + : undefined, + stopConditions: ["unexpected state change", "evidence capture failure"], + incidentOpen: false, + }; +} + +describe("device research lane admission", () => { + it("allows passive public-source planning without claiming device authority", () => { + expect(assessDeviceResearchAdmission(input(1))).toMatchObject({ + lane: 1, + status: "eligible-for-next-gate", + executionAuthorityCreated: false, + nextGate: "passive-work", + failures: [], + }); + }); + + it("does not let an earlier or generic approval authorize device interaction", () => { + const candidate = input(3); + if (!candidate.authorization || !candidate.interaction) + throw new Error("invalid fixture"); + candidate.authorization.lane = 2; + candidate.authorization.approvalIntentDigest = "different-intent"; + candidate.interaction.noChangeCheckReady = false; + + expect(assessDeviceResearchAdmission(candidate)).toMatchObject({ + status: "blocked", + executionAuthorityCreated: false, + failures: expect.arrayContaining([ + "authorization_lane_mismatch", + "approval_intent_mismatch", + "no_change_check_not_ready", + ]), + }); + }); + + it("blocks persistent work when recovery or replaceability is only assumed", () => { + const candidate = input(4); + if (!candidate.target || !candidate.persistentCampaign) + throw new Error("invalid fixture"); + candidate.target.acceptableLoss = "cannot-be-lost"; + candidate.persistentCampaign.recoveryRehearsedOnEquivalentUnit = false; + candidate.persistentCampaign.sacrificialOrReplaceableUnitReady = false; + + expect(assessDeviceResearchAdmission(candidate)).toMatchObject({ + status: "blocked", + failures: expect.arrayContaining([ + "recovery_not_rehearsed", + "replaceable_unit_not_ready", + "unit_cannot_be_lost", + ]), + }); + }); + + it("routes a fully evidenced persistent campaign to interactive checkpoints", () => { + expect(assessDeviceResearchAdmission(input(4))).toMatchObject({ + lane: 4, + status: "eligible-for-next-gate", + executionAuthorityCreated: false, + nextGate: "operator-checkpoint", + failures: [], + }); + }); +});