From 73eccb30db463aa92b068fb4efd0f99ffacc0e5b Mon Sep 17 00:00:00 2001 From: andreihasna Date: Thu, 23 Jul 2026 04:34:24 +0300 Subject: [PATCH 1/9] feat: add observe-only control validator --- CHANGELOG.md | 1 + README.md | 10 + docs/HASNA-CONTROL-V1-THREAT-MODEL.md | 73 +++ docs/HASNA-CONTROL-V1.md | 141 +++++ fixtures/hasna-control-v1/conformance.json | 121 ++++ .../hasna-control-v1/legacy-blockers.json | 59 ++ src/lib/control-conformance.test.ts | 130 +++++ src/lib/control-contract.test.ts | 353 ++++++++++++ src/lib/control-contract.ts | 537 ++++++++++++++++++ src/lib/control-evaluator.test.ts | 409 +++++++++++++ src/lib/control-evaluator.ts | 362 ++++++++++++ 11 files changed, 2196 insertions(+) create mode 100644 docs/HASNA-CONTROL-V1-THREAT-MODEL.md create mode 100644 docs/HASNA-CONTROL-V1.md create mode 100644 fixtures/hasna-control-v1/conformance.json create mode 100644 fixtures/hasna-control-v1/legacy-blockers.json create mode 100644 src/lib/control-conformance.test.ts create mode 100644 src/lib/control-contract.test.ts create mode 100644 src/lib/control-contract.ts create mode 100644 src/lib/control-evaluator.test.ts create mode 100644 src/lib/control-evaluator.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 98426a6..a65f2a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. ## Unreleased ### Added +- Added the repository-internal, observe-only `hasna.control/v1` metadata validator/evaluator, portable conformance and legacy-blocker fixtures, and a test-linked threat model. This slice adds no schema, ingress, live hook, or enforcement. - **Self-hosted HTTP API surface (`conversations-serve`)**: a pure-remote (Amendment A1) service that reads/writes the app's cloud Postgres directly via the vendored `@hasna/contracts` storage kit. Exposes `GET /health`, `/ready`, `/version` (`{status,version,mode}`) and a versioned `/v1` API (messages, channels, projects, agent presence) guarded by `@hasna/contracts` API-key auth (`conversations:read` / `conversations:write` scopes). `GET /v1/openapi.json` serves the OpenAPI document. - **Generated typed SDK** under the `@hasna/conversations/sdk` export, generated from the serve OpenAPI (`bun run sdk:generate`). - **Migration runner** (`src/server/migrate.ts`) that applies the app schema + `api_keys` table via the owner role (idempotent; never clobbers data). diff --git a/README.md b/README.md index 2aaf53c..7b9f88c 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,16 @@ messages should update read state. Channel names are normalized to stable human-readable ids. For example, `#Engineering Updates` is stored as `engineering-updates`. +## Observe-only control validation + +The repository includes a pure, versioned `hasna.control/v1` metadata validator +and lifecycle evaluator. It computes `allow`, `hold`, or `indeterminate` with +`enforced: false`; it does not create controls, change storage, or block tools. +Message content—including literal `FREEZE`, `UNFREEZE`, and `BLOCKED` text—has +no control semantics. See [the contract](docs/HASNA-CONTROL-V1.md), +[threat model](docs/HASNA-CONTROL-V1-THREAT-MODEL.md), and portable fixtures in +`fixtures/hasna-control-v1/`. + The `conversations-hook` binary is still installed for hook integrations: ```bash diff --git a/docs/HASNA-CONTROL-V1-THREAT-MODEL.md b/docs/HASNA-CONTROL-V1-THREAT-MODEL.md new file mode 100644 index 0000000..7686b9c --- /dev/null +++ b/docs/HASNA-CONTROL-V1-THREAT-MODEL.md @@ -0,0 +1,73 @@ +# Threat model — `hasna.control/v1` observe-only evaluator + +Scope: the pure TypeScript contract validator, lifecycle evaluator, and portable +fixtures. Existing generic message metadata is the untrusted storage carrier. +Database schema, message/API/CLI/MCP integration, live control creation, +Codewith hooks, enforcement, deployment, and independent safety containment are +explicitly excluded. + +Assets and goals: control-decision integrity; tenant and authority-domain +isolation; exact release semantics; replay/order determinism; availability of +unrelated operations; confidentiality of rejected secret-shaped values; and the +guarantee that this slice cannot enforce or create controls. + +Actors: authenticated but unauthorized publishers, malicious tenants, content +authors, callers that forge metadata claims, compromised or buggy storage +adapters, replaying/backdating callers, backend failures, and in-process callers +supplying hostile JavaScript objects. + +Trust boundaries: + +1. Untrusted message content and generic metadata to the validator. +2. Authenticated ingress context to the trusted envelope adapter (future work). +3. Stored observations to the bounded backend adapter (future work). +4. Evaluator diagnostics to telemetry or a future Codewith hook. +5. Tenant, authority-domain, scope, operation, and resource transitions. + +Data flow: + +```text +[Publisher/content] -- untrusted metadata --> |metadata boundary| --> [Validator] +[Authenticated ingress] -- trusted envelope --> |identity/policy boundary| --> [Validator] +[Bounded observation backend] --> |availability/order boundary| --> [Evaluator] +[Evaluator] -- allow|hold|indeterminate, enforced=false --> |hook boundary| --> [Telemetry] +``` + +| ID | Element/flow | STRIDE | Threat and abuse path | L×I | Control | Verification test | +| --- | --- | --- | --- | --- | --- | --- | +| T1 | Metadata → validator | S/E | A caller self-asserts publisher, tenant, domain, surface, or policy and gains control authority. | H×H | Compare every claim to a separately supplied, closed trusted envelope; metadata never supplies envelope values. | Trusted-claim mismatch matrix. | +| T2 | Message content | E | Literal `[FREEZE]`, `UNFREEZE`, or `[BLOCKED]` text is interpreted as authority. | H×H | Evaluator never reads content; only the exact metadata key can be a candidate. | Legacy compatibility JSON vectors. | +| T3 | Unfreeze lifecycle | T/E | A stale, wrong-scope, wrong-domain, wrong-ID, or wrong-fingerprint release clears a valid freeze. | H×H | Exact three-field reference, exact context/target equality, strict version and trusted-time ordering; invalid release never mutates active state. | Bad-release matrix and concurrent/reversed-order tests. | +| T4 | Cross-tenant state | E/I | Reused control IDs collide across tenants or authority domains. | M×H | Lifecycle key is tenant + authority domain + control ID; target matching repeats tenant/domain checks. | Identical-ID cross-tenant test. | +| T5 | Backend ordering/replay | T/R | Reverse ordering, duplicate delivery, or concurrent rows changes the lifecycle result. | H×M | Deterministic trusted ordering, canonical event hash, exact replay dedupe, and conflicting-version rejection. | Forward/reverse fixture equality, replay, duplicate, and concurrency tests. | +| T6 | Preseeded rows | E | Metadata inserted before activation becomes a control when the feature is enabled. | M×H | Event issue time and trusted ingress server time must both be at or after activation. | Pre-activation event and pre-activation ingress tests. | +| T7 | Scope ambiguity | E/D | Missing scope or wildcard is treated as global, holding unrelated work. | M×H | Closed scope enum, non-empty sorted unique IDs, no wildcard/global kind, exact operation/resource tokens. | Empty/unsorted/duplicate scope matrix and unrelated-target tests. | +| T8 | Backend/version failure | D/E | Failure or unknown version invents a global hold. | M×H | Return `indeterminate`, empty active controls, and `enforced: false`; no fallback hold. | Backend-unavailable and unsupported-version tests. | +| T9 | Secret-bearing input | I | Credential-like material is echoed through an exception, diagnostic, or log. | M×H | Detect high-signal secret shapes before semantic diagnostics; return code only; no logging or rejected values. | Event/envelope secret-shape and non-echo tests. | +| T10 | Hostile JS values | D/I | Getters, proxies, sparse arrays, or non-finite values execute code, crash evaluation, or evade hashing. | M×M | Data-property-only plain objects, dense unaugmented arrays, bounded canonicalization, normalized copies, top-level exception containment. | Accessor/proxy/sparse-array tests. | +| T11 | TTL/order manipulation | T/D | Future, expired, indefinite, or reordered events persist or resurrect holds. | M×H | Canonical timestamps, seven-day maximum TTL, future/expiry checks at ingress, strict release ordering, automatic freeze expiry. | TTL boundary, future/expired ingress, stale release, and expiry tests. | +| T12 | Evaluator → future hook | E | An observe-only result blocks a real tool or replaces independent safety containment. | M×H | Result always carries `enforced: false`; only `off` and `observe_only` modes exist; docs require a separate reviewed activation. | Hold-result and rollback tests; integration follow-up gate. | + +Assumptions and open questions: + +- The future ingress adapter can prove the authenticated principal and derive + tenant/domain/surface/policy without trusting caller metadata. +- `server_time` will be the immutable ingress time, not query time. +- The observation backend will provide bounded rows or an explicit unavailable + state. +- The first live consumer will preserve exact operation/resource/scope mapping; + wildcard expansion requires a new reviewed contract version. + +Residual risks: + +- Trusted-envelope persistence and authentication are not implemented here. + Owner: Conversations ingress integration; review before any activation. +- SHA-256 collision resistance and host crypto correctness are external + assumptions. Review if the platform cryptography baseline changes. +- Secret-shape detection is deliberately high-signal, not a general DLP system. + Owner: security review; update patterns only with non-secret fixtures. +- A valid authenticated publisher can still issue a harmful but correctly + scoped freeze. Authorization policy, quorum, rate limits, audit delivery, and + emergency recovery belong to the trusted ingress/control-plane slice. +- Independent evidence-based safety containment remains external and may hold + work even when this evaluator returns `allow` or `indeterminate`. diff --git a/docs/HASNA-CONTROL-V1.md b/docs/HASNA-CONTROL-V1.md new file mode 100644 index 0000000..b31af91 --- /dev/null +++ b/docs/HASNA-CONTROL-V1.md @@ -0,0 +1,141 @@ +# `hasna.control/v1` observe-only contract + +`hasna.control/v1` is a versioned validator and evaluator for authenticated, +scoped freeze lifecycles stored in the existing message `metadata` JSON. It is +an observation surface only. It does not write rows, create controls, block a +tool, modify a hook, or turn message content into authority. + +The contract-owned value is stored at the exact metadata key +`hasna.control`. Other metadata keys remain generic application data. + +## Event shape + +The event object is closed: every listed key is required and every unlisted key +is rejected. + +| Key | `v1` rule | +| --- | --- | +| `version` | Exact literal `hasna.control/v1`. | +| `event_id` | `sha256:` plus 64 lowercase hex characters. It must equal the SHA-256 of the canonical event payload with `event_id` omitted. | +| `control_id` | Canonical lowercase RFC 4122 UUID. One UUID owns one two-event lifecycle within a tenant and authority domain. | +| `lifecycle_version` | `1` for `freeze`; `2` for `unfreeze`. A new freeze needs a new `control_id`. | +| `state` | Closed enum: `freeze` or `unfreeze`. | +| `fingerprint` | `sha256:` plus 64 lowercase hex characters. | +| `tenant` | Bounded ASCII token and an exact match for the trusted tenant. | +| `authority_domain` | Bounded ASCII token and an exact match for the trusted principal's authority domain. | +| `policy_version` | Bounded ASCII token and an exact match for the trusted ingress policy version. | +| `publisher` | Bounded ASCII token and an exact match for the authenticated principal. | +| `surface` | Closed enum: `announcements` or `incidents`; it must equal the trusted permitted surface. | +| `scope` | Exact object `{kind, ids}`. `kind` is `tenant`, `project`, `repository`, `machine`, or `resource`; `ids` is non-empty, sorted, and unique. There is no global scope. | +| `affected_operations` | 1–32 bounded tokens, sorted and unique. No wildcard. | +| `affected_resources` | 1–32 bounded tokens, sorted and unique. No wildcard. | +| `issued_at` / `expires_at` | Canonical UTC timestamps with milliseconds. TTL must be positive and no more than seven days. | +| `unfreeze_of` | `null` for a freeze. For an unfreeze, exact object `{event_id, control_id, fingerprint}` referencing the active freeze. | + +Tokens match `^[a-z0-9](?:[a-z0-9._:/-]{0,127})$`. Arrays must already be +in canonical lexical order; the validator never silently normalizes authority. + +## Canonical JSON and event IDs + +Canonical JSON recursively sorts object keys, preserves validated array order, +normalizes negative zero to zero, and rejects non-finite numbers, sparse or +augmented arrays, accessors, non-plain objects, excessive nesting, and values +larger than the contract bound. The event ID is: + +```text +sha256(utf8(canonical_json(event_without_event_id))) +``` + +This repository's portable conformance fixture is +`fixtures/hasna-control-v1/conformance.json`: + +- fixture id: `project-freeze-unfreeze` +- freeze event id: `sha256:41a6e7b5480d20fd587b3170ee6615bd509e6fc4411f5ce15f612b0bc4328547` +- unfreeze event id: `sha256:0b5500cd5f2c29b65badf330b612100baa44052cf8c607fb39ee6414c19eb6f3` +- canonical event-sequence hash: `sha256:0cffbec5ba8980dd9b2fd27e0c52b472f285dd7da8efc910a28351141c1d0b31` + +## Trusted envelope + +Metadata claims never authenticate themselves. The caller must supply this +closed, server-trusted envelope independently of message content and control +metadata: + +```ts +interface TrustedControlEnvelopeV1 { + authenticated_principal: string; + tenant: string; + authority_domain: string; + permitted_surface: "announcements" | "incidents"; + policy_version: string; + server_time: string; + blocking: boolean; +} +``` + +`server_time` is the trusted ingress time for that observation, not an +event-authored timestamp or the time a historical query happens to run. A +freeze requires `blocking: true`; an unfreeze requires `blocking: false`. +Generic rows with `blocking=1` remain ordinary blockers and do not become +controls. + +The evaluator configuration also supplies an activation timestamp. Both the +event issue time and trusted ingress time must be at or after activation, so a +preseeded metadata row cannot become authoritative after the validator is +enabled. + +## Lifecycle and evaluation + +The evaluator processes validated observations by trusted ingress time, then +event issue time, lifecycle version, and event ID. Backend return order does not +change the result. + +- An exact replay is idempotent. +- A different event at the same control/lifecycle version is rejected. +- An unfreeze must be later than the freeze in both trusted ingress and issue + time, arrive before the freeze expires, match the same trusted context and + target arrays, and carry the exact `unfreeze_of` reference. +- A bad, stale, future, reordered, wrong-domain, wrong-scope, wrong-ID, or + wrong-fingerprint unfreeze leaves the freeze active. +- Lifecycles are isolated by tenant, authority domain, and control ID. +- Overlapping controls remain independent; releasing one cannot release a + sibling control. +- A freeze applies only when tenant, authority domain, scope intersection, + operation, and resource all match. Unrelated operations continue. + +The result is `allow`, `hold`, or `indeterminate`, always with +`enforced: false`. `hold` is an observation for an applicable active freeze, +not an enforcement action. Malformed candidates, unsupported versions, invalid +evaluator input, or backend failure return `indeterminate` without inventing a +global hold. Independent safety containment remains outside this contract. + +Literal or malformed `FREEZE`, `UNFREEZE`, and `BLOCKED` text is never read by +the evaluator. The legacy compatibility vectors are in +`fixtures/hasna-control-v1/legacy-blockers.json`. + +Secret-shaped values are rejected with a stable diagnostic code. Diagnostics +contain no rejected field value, and the core does not log inputs. + +## Activation, rollback, and integration boundary + +Activation is configuration-only: `mode: "observe_only"`, validator version +`hasna.control/v1`, and a trusted activation timestamp. Rollback sets the mode +to `off` (or stops selecting this validator version). There is no database down +migration because this slice adds no schema and performs no writes. + +The following work is intentionally not part of this core: + +1. A trusted Conversations ingress adapter must derive principal, tenant, + authority domain, permitted surface, policy version, server ingress time, + and blocking state from authenticated server context and immutable row + columns. It must reserve/overwrite the control metadata key rather than trust + caller-supplied envelope fields. +2. Historical rows without that trusted ingress evidence must stay ineligible; + activation must be later than their ingress time. +3. A bounded backend adapter must return `status: "unavailable"` on read failure + and must not synthesize rows or global controls. +4. A future Codewith `PreToolUse` integration must map the exact tool operation, + resource, tenant, authority domain, and scope into the evaluator. While the + mode is observe-only it may emit redacted telemetry only; it must not deny a + tool call. Live enforcement requires a separately reviewed activation slice. +5. A package export can be added after the open `src/index.ts` lane lands. Until + then the core stays disjoint and repository-internal. diff --git a/fixtures/hasna-control-v1/conformance.json b/fixtures/hasna-control-v1/conformance.json new file mode 100644 index 0000000..c22bfd3 --- /dev/null +++ b/fixtures/hasna-control-v1/conformance.json @@ -0,0 +1,121 @@ +{ + "fixture_version": "hasna.control/conformance-v1", + "fixture_id": "project-freeze-unfreeze", + "expected": { + "freeze_event_id": "sha256:41a6e7b5480d20fd587b3170ee6615bd509e6fc4411f5ce15f612b0bc4328547", + "unfreeze_event_id": "sha256:0b5500cd5f2c29b65badf330b612100baa44052cf8c607fb39ee6414c19eb6f3", + "canonical_event_sequence_sha256": "sha256:0cffbec5ba8980dd9b2fd27e0c52b472f285dd7da8efc910a28351141c1d0b31", + "decision": "allow", + "enforced": false + }, + "config": { + "mode": "observe_only", + "validator_version": "hasna.control/v1", + "activation_timestamp": "2026-07-22T00:00:00.000Z", + "evaluation_time": "2026-07-23T12:00:00.000Z" + }, + "target": { + "tenant": "tenant:hasna", + "authority_domain": "hasna/control-operators", + "scope": { + "kind": "project", + "ids": [ + "project:conversations" + ] + }, + "operation": "tool.execute", + "resource": "repo:hasna/conversations" + }, + "observations": [ + { + "content": "[FREEZE] text is inert; metadata is authoritative", + "metadata": { + "hasna.control": { + "version": "hasna.control/v1", + "control_id": "123e4567-e89b-42d3-a456-426614174000", + "lifecycle_version": 1, + "state": "freeze", + "fingerprint": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "tenant": "tenant:hasna", + "authority_domain": "hasna/control-operators", + "policy_version": "1.0.1", + "publisher": "agent:security-operator", + "surface": "announcements", + "scope": { + "kind": "project", + "ids": [ + "project:conversations" + ] + }, + "affected_operations": [ + "tool.execute", + "workflow.dispatch" + ], + "affected_resources": [ + "repo:hasna/conversations" + ], + "issued_at": "2026-07-23T00:00:00.000Z", + "expires_at": "2026-07-24T00:00:00.000Z", + "unfreeze_of": null, + "event_id": "sha256:41a6e7b5480d20fd587b3170ee6615bd509e6fc4411f5ce15f612b0bc4328547" + } + }, + "trusted_envelope": { + "authenticated_principal": "agent:security-operator", + "tenant": "tenant:hasna", + "authority_domain": "hasna/control-operators", + "permitted_surface": "announcements", + "policy_version": "1.0.1", + "server_time": "2026-07-23T00:00:01.000Z", + "blocking": true + } + }, + { + "content": "UNFREEZE text is inert; exact metadata reference releases", + "metadata": { + "hasna.control": { + "version": "hasna.control/v1", + "control_id": "123e4567-e89b-42d3-a456-426614174000", + "lifecycle_version": 2, + "state": "unfreeze", + "fingerprint": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "tenant": "tenant:hasna", + "authority_domain": "hasna/control-operators", + "policy_version": "1.0.1", + "publisher": "agent:security-operator", + "surface": "announcements", + "scope": { + "kind": "project", + "ids": [ + "project:conversations" + ] + }, + "affected_operations": [ + "tool.execute", + "workflow.dispatch" + ], + "affected_resources": [ + "repo:hasna/conversations" + ], + "issued_at": "2026-07-23T01:00:00.000Z", + "expires_at": "2026-07-24T01:00:00.000Z", + "unfreeze_of": { + "event_id": "sha256:41a6e7b5480d20fd587b3170ee6615bd509e6fc4411f5ce15f612b0bc4328547", + "control_id": "123e4567-e89b-42d3-a456-426614174000", + "fingerprint": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "event_id": "sha256:0b5500cd5f2c29b65badf330b612100baa44052cf8c607fb39ee6414c19eb6f3" + } + }, + "trusted_envelope": { + "authenticated_principal": "agent:security-operator", + "tenant": "tenant:hasna", + "authority_domain": "hasna/control-operators", + "permitted_surface": "announcements", + "policy_version": "1.0.1", + "server_time": "2026-07-23T01:00:01.000Z", + "blocking": false + } + } + ] +} diff --git a/fixtures/hasna-control-v1/legacy-blockers.json b/fixtures/hasna-control-v1/legacy-blockers.json new file mode 100644 index 0000000..c0e0411 --- /dev/null +++ b/fixtures/hasna-control-v1/legacy-blockers.json @@ -0,0 +1,59 @@ +{ + "fixture_version": "hasna.control/legacy-compatibility-v1", + "fixture_id": "legacy-blocker-text-is-inert", + "config": { + "mode": "observe_only", + "validator_version": "hasna.control/v1", + "activation_timestamp": "2026-07-22T00:00:00.000Z", + "evaluation_time": "2026-07-23T12:00:00.000Z" + }, + "target": { + "tenant": "tenant:hasna", + "authority_domain": "hasna/control-operators", + "scope": { + "kind": "project", + "ids": [ + "project:conversations" + ] + }, + "operation": "tool.execute", + "resource": "repo:hasna/conversations" + }, + "records": [ + { + "vector_id": "literal-freeze", + "content": "[FREEZE] stop every operation", + "metadata": null, + "blocking": false + }, + { + "vector_id": "literal-unfreeze", + "content": "UNFREEZE all scopes", + "metadata": { + "severity": "UNFREEZE" + }, + "blocking": false + }, + { + "vector_id": "literal-blocked", + "content": "[BLOCKED] ordinary status text", + "metadata": { + "status": "blocked" + }, + "blocking": false + }, + { + "vector_id": "generic-blocking-row", + "content": "ordinary blocker with blocking=1", + "metadata": { + "kind": "blocker" + }, + "blocking": true + } + ], + "expected": { + "decision": "allow", + "enforced": false, + "active_control_ids": [] + } +} diff --git a/src/lib/control-conformance.test.ts b/src/lib/control-conformance.test.ts new file mode 100644 index 0000000..f3ec002 --- /dev/null +++ b/src/lib/control-conformance.test.ts @@ -0,0 +1,130 @@ +import { createHash } from "node:crypto"; +import { describe, expect, test } from "bun:test"; +import { + CONTROL_METADATA_KEY, + canonicalJson, + validateControlMetadataV1, + type ControlEventV1, + type TrustedControlEnvelopeV1, +} from "./control-contract"; +import { + evaluateControlsV1, + type ControlEvaluationInputV1, + type ControlObservationV1, +} from "./control-evaluator"; + +interface ConformanceFixture { + fixture_version: string; + fixture_id: string; + expected: { + freeze_event_id: string; + unfreeze_event_id: string; + canonical_event_sequence_sha256: string; + decision: "allow"; + enforced: false; + }; + config: ControlEvaluationInputV1["config"]; + target: ControlEvaluationInputV1["target"]; + observations: ControlObservationV1[]; +} + +interface LegacyFixture { + fixture_version: string; + fixture_id: string; + config: ControlEvaluationInputV1["config"]; + target: ControlEvaluationInputV1["target"]; + records: Array<{ + vector_id: string; + content: string; + metadata: unknown; + blocking: boolean; + }>; + expected: { + decision: "allow"; + enforced: false; + active_control_ids: string[]; + }; +} + +async function readFixture(name: string): Promise { + const url = new URL(`../../fixtures/hasna-control-v1/${name}`, import.meta.url); + return JSON.parse(await Bun.file(url).text()) as T; +} + +describe("hasna.control/v1 conformance fixtures", () => { + test("pins canonical event ids, sequence hash, and observe-only release result", async () => { + const fixture = await readFixture("conformance.json"); + expect(fixture.fixture_version).toBe("hasna.control/conformance-v1"); + expect(fixture.fixture_id).toBe("project-freeze-unfreeze"); + + const events = fixture.observations.map((observation) => + (observation.metadata as Record)[CONTROL_METADATA_KEY]!, + ); + expect(events.map((event) => event.event_id)).toEqual([ + fixture.expected.freeze_event_id, + fixture.expected.unfreeze_event_id, + ]); + const sequenceHash = `sha256:${createHash("sha256") + .update(canonicalJson(events), "utf8") + .digest("hex")}`; + expect(sequenceHash).toBe(fixture.expected.canonical_event_sequence_sha256); + + for (const observation of fixture.observations) { + expect( + validateControlMetadataV1(observation.metadata, { + trusted_envelope: observation.trusted_envelope, + activation_timestamp: fixture.config.activation_timestamp, + }).status, + ).toBe("valid"); + } + + const forward = evaluateControlsV1({ + config: fixture.config, + target: fixture.target, + backend: { status: "available", observations: fixture.observations }, + }); + const reverse = evaluateControlsV1({ + config: fixture.config, + target: fixture.target, + backend: { status: "available", observations: [...fixture.observations].reverse() }, + }); + expect(forward).toEqual(reverse); + expect(forward).toMatchObject({ + decision: fixture.expected.decision, + enforced: fixture.expected.enforced, + active_control_ids: [], + accepted_event_count: 2, + rejected_event_count: 0, + }); + }); + + test("keeps legacy blocker rows and literal control text non-semantic", async () => { + const fixture = await readFixture("legacy-blockers.json"); + expect(fixture.fixture_version).toBe("hasna.control/legacy-compatibility-v1"); + expect(fixture.fixture_id).toBe("legacy-blocker-text-is-inert"); + + for (const record of fixture.records) { + const trusted: TrustedControlEnvelopeV1 = { + authenticated_principal: "agent:legacy", + tenant: "tenant:hasna", + authority_domain: "hasna/control-operators", + permitted_surface: "announcements", + policy_version: "1.0.1", + server_time: "2026-07-23T00:00:01.000Z", + blocking: record.blocking, + }; + const result = evaluateControlsV1({ + config: fixture.config, + target: fixture.target, + backend: { + status: "available", + observations: [{ content: record.content, metadata: record.metadata, trusted_envelope: trusted }], + }, + }); + expect(result).toMatchObject(fixture.expected); + if (record.blocking) { + expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toContain("ordinary_blocker_ignored"); + } + } + }); +}); diff --git a/src/lib/control-contract.test.ts b/src/lib/control-contract.test.ts new file mode 100644 index 0000000..d14ba9e --- /dev/null +++ b/src/lib/control-contract.test.ts @@ -0,0 +1,353 @@ +import { describe, expect, test } from "bun:test"; +import { + CONTROL_CONTRACT_VERSION, + CONTROL_METADATA_KEY, + MAX_CONTROL_ARRAY_ITEMS, + MAX_CONTROL_TTL_MS, + canonicalJson, + controlMetadataV1, + createControlEventV1, + deriveControlEventId, + validateControlMetadataV1, + type ControlEventPayloadV1, + type ControlEventV1, + type ControlValidationContextV1, + type TrustedControlEnvelopeV1, +} from "./control-contract"; + +const ISSUED_AT = "2026-07-23T00:00:00.000Z"; +const SERVER_TIME = "2026-07-23T00:00:01.000Z"; +const ACTIVATION_TIME = "2026-07-22T00:00:00.000Z"; +const CONTROL_ID = "123e4567-e89b-42d3-a456-426614174000"; +const FINGERPRINT = `sha256:${"a".repeat(64)}`; + +function freezePayload(overrides: Partial = {}): ControlEventPayloadV1 { + return { + version: CONTROL_CONTRACT_VERSION, + control_id: CONTROL_ID, + lifecycle_version: 1, + state: "freeze", + fingerprint: FINGERPRINT, + tenant: "tenant:hasna", + authority_domain: "hasna/control-operators", + policy_version: "1.0.1", + publisher: "agent:security-operator", + surface: "announcements", + scope: { kind: "project", ids: ["project:conversations"] }, + affected_operations: ["tool.execute", "workflow.dispatch"], + affected_resources: ["repo:hasna/conversations"], + issued_at: ISSUED_AT, + expires_at: "2026-07-24T00:00:00.000Z", + unfreeze_of: null, + ...overrides, + }; +} + +function trustedEnvelope(overrides: Partial = {}): TrustedControlEnvelopeV1 { + return { + authenticated_principal: "agent:security-operator", + tenant: "tenant:hasna", + authority_domain: "hasna/control-operators", + permitted_surface: "announcements", + policy_version: "1.0.1", + server_time: SERVER_TIME, + blocking: true, + ...overrides, + }; +} + +function context( + trusted: TrustedControlEnvelopeV1 = trustedEnvelope(), + activation_timestamp = ACTIVATION_TIME, +): ControlValidationContextV1 { + return { trusted_envelope: trusted, activation_timestamp }; +} + +function withMutation(event: ControlEventV1, mutation: Record): Record { + return { ...event, ...mutation }; +} + +function metadata(candidate: unknown): Record { + return { [CONTROL_METADATA_KEY]: candidate }; +} + +function invalidCode(metadata: unknown, ctx = context()): string { + const result = validateControlMetadataV1(metadata, ctx); + expect(result.status).toBe("invalid"); + if (result.status !== "invalid") throw new Error("expected invalid control metadata"); + expect(result.diagnostics).toHaveLength(1); + return result.diagnostics[0]!.code; +} + +describe("hasna.control/v1 canonical contract", () => { + test("canonical JSON recursively sorts object keys and normalizes negative zero", () => { + expect(canonicalJson({ z: [3, { b: true, a: null }], a: -0 })).toBe( + '{"a":0,"z":[3,{"a":null,"b":true}]}', + ); + }); + + test("canonical JSON rejects non-JSON and non-finite inputs without echoing values", () => { + for (const value of [undefined, 1n, Number.NaN, Number.POSITIVE_INFINITY, new Date()]) { + expect(() => canonicalJson(value)).toThrow("invalid canonical JSON value"); + } + const sparse = new Array(2); + sparse[1] = "present"; + expect(() => canonicalJson(sparse)).toThrow("invalid canonical JSON value"); + }); + + test("derives a stable event id from the canonical payload and verifies it", () => { + const payload = freezePayload(); + const event = createControlEventV1(payload); + + expect(event.event_id).toBe(deriveControlEventId(payload)); + expect(event.event_id).toMatch(/^sha256:[a-f0-9]{64}$/); + + const result = validateControlMetadataV1(controlMetadataV1(event), context()); + expect(result.status).toBe("valid"); + if (result.status !== "valid") throw new Error("expected valid event"); + expect(result.event).toEqual(event); + expect(result.canonical_payload).toBe(canonicalJson(payload)); + expect(result.canonical_event).toBe(canonicalJson(event)); + }); + + test("uses only the dedicated metadata key and treats unrelated metadata as absent", () => { + expect(validateControlMetadataV1(null, context())).toEqual({ + status: "absent", + diagnostics: [{ code: "no_control_metadata" }], + }); + expect(validateControlMetadataV1({ control: freezePayload() }, context())).toEqual({ + status: "absent", + diagnostics: [{ code: "no_control_metadata" }], + }); + expect(Object.hasOwn(controlMetadataV1(createControlEventV1(freezePayload())), CONTROL_METADATA_KEY)).toBe(true); + }); + + test("rejects unknown keys at every contract-owned boundary", () => { + const event = createControlEventV1(freezePayload()); + expect(invalidCode(metadata(withMutation(event, { surprise: true })))).toBe("unexpected_keys"); + expect( + invalidCode(metadata({ ...event, scope: { ...event.scope, surprise: true } })), + ).toBe("unexpected_keys"); + expect( + invalidCode( + controlMetadataV1(event), + context({ ...trustedEnvelope(), surprise: true } as TrustedControlEnvelopeV1), + ), + ).toBe("invalid_trusted_envelope"); + + const operations = [...event.affected_operations] as string[] & { extra?: string }; + operations.extra = "not-json-array-data"; + expect(invalidCode(metadata({ ...event, affected_operations: operations }))).toBe( + "invalid_sorted_unique_array", + ); + }); + + test("fails closed on accessors and hostile objects without invoking or echoing them", () => { + let accessed = false; + const accessorMetadata = {} as Record; + Object.defineProperty(accessorMetadata, CONTROL_METADATA_KEY, { + enumerable: true, + get() { + accessed = true; + throw new Error("must not execute"); + }, + }); + expect(validateControlMetadataV1(accessorMetadata, context())).toEqual({ + status: "invalid", + diagnostics: [{ code: "malformed_control_metadata" }], + }); + expect(accessed).toBe(false); + + const hostile = new Proxy({}, { + getPrototypeOf() { + throw new Error("hostile trap"); + }, + }); + expect(validateControlMetadataV1(hostile, context())).toEqual({ + status: "invalid", + diagnostics: [{ code: "malformed_control_metadata" }], + }); + }); + + test("rejects unsupported versions, enums, UUIDs, fingerprints, and token grammars", () => { + const event = createControlEventV1(freezePayload()); + expect(invalidCode(metadata(withMutation(event, { version: "hasna.control/v2" })))).toBe( + "unsupported_contract_version", + ); + expect(invalidCode(metadata(withMutation(event, { state: "blocked" })))).toBe("invalid_lifecycle"); + expect(invalidCode(metadata(withMutation(event, { surface: "general" })))).toBe("invalid_field"); + expect(invalidCode(metadata(withMutation(event, { control_id: "not-a-uuid" })))).toBe( + "invalid_control_id", + ); + expect(invalidCode(metadata(withMutation(event, { fingerprint: "same" })))).toBe( + "invalid_field", + ); + expect(invalidCode(metadata(withMutation(event, { tenant: "*" })))).toBe("invalid_field"); + }); + + test("requires non-empty bounded sorted unique scope, operation, and resource arrays", () => { + const event = createControlEventV1(freezePayload()); + const cases: unknown[] = [ + { ...event, scope: { kind: "project", ids: [] } }, + { ...event, scope: { kind: "project", ids: ["project:b", "project:a"] } }, + { ...event, scope: { kind: "project", ids: ["project:a", "project:a"] } }, + { ...event, affected_operations: [] }, + { ...event, affected_operations: ["z", "a"] }, + { ...event, affected_resources: Array.from({ length: MAX_CONTROL_ARRAY_ITEMS + 1 }, (_, i) => `r:${i.toString().padStart(2, "0")}`) }, + ]; + + for (const candidate of cases) { + expect(["invalid_scope", "invalid_sorted_unique_array"]).toContain( + invalidCode({ [CONTROL_METADATA_KEY]: candidate }), + ); + } + }); + + test("requires canonical timestamps and a positive finite TTL at or below the contract maximum", () => { + const event = createControlEventV1(freezePayload()); + expect(invalidCode(metadata(withMutation(event, { issued_at: "2026-07-23T00:00:00Z" })))).toBe( + "invalid_timestamp", + ); + expect(invalidCode(metadata(withMutation(event, { expires_at: ISSUED_AT })))).toBe("invalid_ttl"); + + const tooLong = new Date(Date.parse(ISSUED_AT) + MAX_CONTROL_TTL_MS + 1).toISOString(); + expect(invalidCode(metadata(withMutation(event, { expires_at: tooLong })))).toBe("invalid_ttl"); + }); + + test("rejects pre-activation, future, and already-expired ingress observations", () => { + const event = createControlEventV1(freezePayload()); + expect(invalidCode(controlMetadataV1(event), context(trustedEnvelope(), "2026-07-23T00:00:00.001Z"))).toBe( + "event_before_activation", + ); + expect( + invalidCode( + controlMetadataV1(event), + context(trustedEnvelope({ server_time: "2026-07-21T23:59:59.999Z" })), + ), + ).toBe("event_before_activation"); + expect( + invalidCode( + controlMetadataV1(event), + context(trustedEnvelope({ server_time: "2026-07-22T23:59:59.999Z" })), + ), + ).toBe("event_from_future"); + expect( + invalidCode( + controlMetadataV1(event), + context(trustedEnvelope({ server_time: "2026-07-24T00:00:00.000Z" })), + ), + ).toBe("event_expired_at_ingress"); + }); + + test("authenticates every metadata claim against the trusted envelope", () => { + const event = createControlEventV1(freezePayload()); + const mismatches: TrustedControlEnvelopeV1[] = [ + trustedEnvelope({ authenticated_principal: "agent:other" }), + trustedEnvelope({ tenant: "tenant:other" }), + trustedEnvelope({ authority_domain: "other/control-operators" }), + trustedEnvelope({ permitted_surface: "incidents" }), + trustedEnvelope({ policy_version: "1.0.0" }), + ]; + for (const trusted of mismatches) { + expect(invalidCode(controlMetadataV1(event), context(trusted))).toBe("trusted_claim_mismatch"); + } + }); + + test("requires freeze to be blocking and unfreeze to be non-blocking", () => { + const freeze = createControlEventV1(freezePayload()); + expect( + invalidCode(controlMetadataV1(freeze), context(trustedEnvelope({ blocking: false }))), + ).toBe("blocking_state_mismatch"); + + const unfreeze = createControlEventV1({ + ...freezePayload(), + lifecycle_version: 2, + state: "unfreeze", + issued_at: "2026-07-23T01:00:00.000Z", + expires_at: "2026-07-24T01:00:00.000Z", + unfreeze_of: { + event_id: freeze.event_id, + control_id: freeze.control_id, + fingerprint: freeze.fingerprint, + }, + }); + expect( + validateControlMetadataV1( + controlMetadataV1(unfreeze), + context( + trustedEnvelope({ + blocking: false, + server_time: "2026-07-23T01:00:01.000Z", + }), + ), + ).status, + ).toBe("valid"); + }); + + test("returns a normalized copy so post-validation metadata mutation cannot change the result", () => { + const event = createControlEventV1(freezePayload()); + const result = validateControlMetadataV1(controlMetadataV1(event), context()); + expect(result.status).toBe("valid"); + if (result.status !== "valid") throw new Error("expected valid event"); + + event.scope.ids[0] = "project:tampered"; + event.affected_operations[0] = "tool.tampered"; + expect(result.event.scope.ids).toEqual(["project:conversations"]); + expect(result.event.affected_operations).toEqual(["tool.execute", "workflow.dispatch"]); + }); + + test("enforces a two-event lifecycle and exact unfreeze reference shape", () => { + const freeze = createControlEventV1(freezePayload()); + expect(invalidCode(metadata(withMutation(freeze, { lifecycle_version: 2 })))).toBe( + "invalid_lifecycle", + ); + expect(invalidCode(metadata(withMutation(freeze, { unfreeze_of: {} })))).toBe( + "invalid_unfreeze_reference", + ); + + const unfreezeBase = { + ...freeze, + event_id: freeze.event_id, + lifecycle_version: 2, + state: "unfreeze", + unfreeze_of: { + event_id: freeze.event_id, + control_id: freeze.control_id, + fingerprint: freeze.fingerprint, + extra: true, + }, + }; + expect( + invalidCode( + controlMetadataV1(unfreezeBase as unknown as ControlEventV1), + context(trustedEnvelope({ blocking: false })), + ), + ).toBe("invalid_unfreeze_reference"); + }); + + test("rejects event-id tampering after any signed field changes", () => { + const event = createControlEventV1(freezePayload()); + expect( + invalidCode(metadata(withMutation(event, { expires_at: "2026-07-24T00:00:00.001Z" }))), + ).toBe("invalid_event_id"); + }); + + test("rejects secret-shaped control values and never echoes the value in diagnostics", () => { + const event = createControlEventV1(freezePayload()); + const secretLike = ["AK", "IA", "ABCDEFGHIJKLMNOP"].join(""); + const result = validateControlMetadataV1( + metadata(withMutation(event, { publisher: secretLike })), + context(), + ); + expect(result.status).toBe("invalid"); + expect(result.diagnostics).toEqual([{ code: "secret_shaped_value" }]); + expect(JSON.stringify(result)).not.toContain(secretLike); + + const trustedResult = validateControlMetadataV1( + controlMetadataV1(event), + context(trustedEnvelope({ authenticated_principal: secretLike })), + ); + expect(trustedResult).toEqual({ status: "invalid", diagnostics: [{ code: "secret_shaped_value" }] }); + expect(JSON.stringify(trustedResult)).not.toContain(secretLike); + }); +}); diff --git a/src/lib/control-contract.ts b/src/lib/control-contract.ts new file mode 100644 index 0000000..e2ba7ea --- /dev/null +++ b/src/lib/control-contract.ts @@ -0,0 +1,537 @@ +import { createHash } from "node:crypto"; + +export const CONTROL_CONTRACT_VERSION = "hasna.control/v1" as const; +export const CONTROL_METADATA_KEY = "hasna.control" as const; +export const CONTROL_VALIDATOR_VERSION = "hasna.control/v1" as const; +export const MAX_CONTROL_TTL_MS = 7 * 24 * 60 * 60 * 1000; +export const MAX_CONTROL_ARRAY_ITEMS = 32; + +export const CONTROL_STATES = ["freeze", "unfreeze"] as const; +export const CONTROL_SURFACES = ["announcements", "incidents"] as const; +export const CONTROL_SCOPE_KINDS = [ + "tenant", + "project", + "repository", + "machine", + "resource", +] as const; + +export type ControlStateV1 = (typeof CONTROL_STATES)[number]; +export type ControlSurfaceV1 = (typeof CONTROL_SURFACES)[number]; +export type ControlScopeKindV1 = (typeof CONTROL_SCOPE_KINDS)[number]; + +export interface ControlScopeV1 { + kind: ControlScopeKindV1; + ids: string[]; +} + +export interface ControlReferenceV1 { + event_id: string; + control_id: string; + fingerprint: string; +} + +export interface ControlEventV1 { + version: typeof CONTROL_CONTRACT_VERSION; + event_id: string; + control_id: string; + lifecycle_version: number; + state: ControlStateV1; + fingerprint: string; + tenant: string; + authority_domain: string; + policy_version: string; + publisher: string; + surface: ControlSurfaceV1; + scope: ControlScopeV1; + affected_operations: string[]; + affected_resources: string[]; + issued_at: string; + expires_at: string; + unfreeze_of: ControlReferenceV1 | null; +} + +export type ControlEventPayloadV1 = Omit; + +export interface TrustedControlEnvelopeV1 { + authenticated_principal: string; + tenant: string; + authority_domain: string; + permitted_surface: ControlSurfaceV1; + policy_version: string; + server_time: string; + blocking: boolean; +} + +export type ControlValidationCode = + | "no_control_metadata" + | "malformed_control_metadata" + | "unsupported_contract_version" + | "invalid_trusted_envelope" + | "unexpected_keys" + | "invalid_field" + | "invalid_event_id" + | "invalid_control_id" + | "invalid_lifecycle" + | "invalid_scope" + | "invalid_sorted_unique_array" + | "invalid_timestamp" + | "invalid_ttl" + | "event_before_activation" + | "event_from_future" + | "event_expired_at_ingress" + | "trusted_claim_mismatch" + | "blocking_state_mismatch" + | "invalid_unfreeze_reference" + | "secret_shaped_value"; + +export interface ControlValidationDiagnostic { + code: ControlValidationCode; +} + +export type ControlValidationResult = + | { + status: "absent"; + diagnostics: ControlValidationDiagnostic[]; + } + | { + status: "invalid"; + diagnostics: ControlValidationDiagnostic[]; + } + | { + status: "valid"; + event: ControlEventV1; + canonical_event: string; + canonical_payload: string; + diagnostics: ControlValidationDiagnostic[]; + }; + +export interface ControlValidationContextV1 { + trusted_envelope: TrustedControlEnvelopeV1; + activation_timestamp: string; +} + +const MAX_CONTROL_EVENT_BYTES = 16_384; +const MAX_CONTROL_STRING_LENGTH = 128; +const MAX_CANONICAL_DEPTH = 16; +const MAX_CANONICAL_NODES = 1_024; + +const TOKEN_PATTERN = /^[a-z0-9](?:[a-z0-9._:/-]{0,127})$/; +const EVENT_ID_PATTERN = /^sha256:[a-f0-9]{64}$/; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +const EVENT_KEYS = [ + "version", + "event_id", + "control_id", + "lifecycle_version", + "state", + "fingerprint", + "tenant", + "authority_domain", + "policy_version", + "publisher", + "surface", + "scope", + "affected_operations", + "affected_resources", + "issued_at", + "expires_at", + "unfreeze_of", +] as const; + +const PAYLOAD_KEYS = EVENT_KEYS.filter((key) => key !== "event_id"); +const SCOPE_KEYS = ["kind", "ids"] as const; +const REFERENCE_KEYS = ["event_id", "control_id", "fingerprint"] as const; +const TRUSTED_ENVELOPE_KEYS = [ + "authenticated_principal", + "tenant", + "authority_domain", + "permitted_surface", + "policy_version", + "server_time", + "blocking", +] as const; + +const SECRET_PATTERNS = [ + /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/i, + /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/, + /\bgh[pousr]_[A-Za-z0-9]{20,}\b/, + /\bsk-[A-Za-z0-9_-]{20,}\b/, + /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/, + /\bAIza[A-Za-z0-9_-]{30,}\b/, + /(?:password|passwd|api[_-]?key|access[_-]?token|secret)\s*[:=]\s*\S{8,}/i, +] as const; + +function invalidCanonicalJson(): never { + throw new TypeError("invalid canonical JSON value"); +} + +function isPlainRecord(value: unknown): value is Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function hasDataPropertiesOnly(value: Record): boolean { + const names = Object.getOwnPropertyNames(value); + if (Object.getOwnPropertySymbols(value).length > 0) return false; + return names.every((name) => { + const descriptor = Object.getOwnPropertyDescriptor(value, name); + return Boolean(descriptor?.enumerable && "value" in descriptor); + }); +} + +function hasExactKeys(value: unknown, expected: readonly string[]): value is Record { + if (!isPlainRecord(value) || !hasDataPropertiesOnly(value)) return false; + const keys = Object.keys(value); + return keys.length === expected.length && expected.every((key) => Object.hasOwn(value, key)); +} + +function compareCanonicalStrings(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function readArrayValues(value: unknown): unknown[] | null { + if (!Array.isArray(value) || Object.getOwnPropertySymbols(value).length > 0) return null; + const names = Object.getOwnPropertyNames(value); + if (names.length !== value.length + 1 || !names.includes("length")) return null; + const items: unknown[] = []; + for (let index = 0; index < value.length; index++) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !("value" in descriptor)) return null; + items.push(descriptor.value); + } + return items; +} + +export function canonicalJson(value: unknown): string { + let nodeCount = 0; + + function visit(node: unknown, depth: number): string { + nodeCount += 1; + if (depth > MAX_CANONICAL_DEPTH || nodeCount > MAX_CANONICAL_NODES) { + return invalidCanonicalJson(); + } + + if (node === null) return "null"; + if (typeof node === "boolean") return node ? "true" : "false"; + if (typeof node === "string") return JSON.stringify(node); + if (typeof node === "number") { + if (!Number.isFinite(node)) return invalidCanonicalJson(); + return JSON.stringify(Object.is(node, -0) ? 0 : node); + } + if (Array.isArray(node)) { + const items = readArrayValues(node); + if (!items || items.length > MAX_CANONICAL_NODES) return invalidCanonicalJson(); + return `[${items.map((item) => visit(item, depth + 1)).join(",")}]`; + } + if (!isPlainRecord(node) || !hasDataPropertiesOnly(node)) return invalidCanonicalJson(); + + const keys = Object.keys(node).sort(compareCanonicalStrings); + const entries = keys.map((key) => `${JSON.stringify(key)}:${visit(node[key], depth + 1)}`); + return `{${entries.join(",")}}`; + } + + const canonical = visit(value, 0); + if (Buffer.byteLength(canonical, "utf8") > MAX_CONTROL_EVENT_BYTES) return invalidCanonicalJson(); + return canonical; +} + +function containsSecretShapedValue(value: unknown, depth = 0): boolean { + if (depth > MAX_CANONICAL_DEPTH) return false; + if (typeof value === "string") return SECRET_PATTERNS.some((pattern) => pattern.test(value)); + if (Array.isArray(value)) { + const items = readArrayValues(value); + return items ? items.some((item) => containsSecretShapedValue(item, depth + 1)) : false; + } + if (!isPlainRecord(value) || !hasDataPropertiesOnly(value)) return false; + return Object.values(value).some((item) => containsSecretShapedValue(item, depth + 1)); +} + +function isToken(value: unknown): value is string { + return typeof value === "string" && value.length <= MAX_CONTROL_STRING_LENGTH && TOKEN_PATTERN.test(value); +} + +export function isControlTokenV1(value: unknown): value is string { + return isToken(value); +} + +function isEventId(value: unknown): value is string { + return typeof value === "string" && EVENT_ID_PATTERN.test(value); +} + +function isUuid(value: unknown): value is string { + return typeof value === "string" && UUID_PATTERN.test(value); +} + +function parseTimestamp(value: unknown): number | null { + if (typeof value !== "string" || !TIMESTAMP_PATTERN.test(value)) return null; + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp) || new Date(timestamp).toISOString() !== value) return null; + return timestamp; +} + +export function controlTimestampMsV1(value: unknown): number | null { + return parseTimestamp(value); +} + +function isSortedUniqueTokenArray(value: unknown): value is string[] { + const items = readArrayValues(value); + if (!items || items.length === 0 || items.length > MAX_CONTROL_ARRAY_ITEMS) return false; + let previous: string | undefined; + for (const item of items) { + if (!isToken(item)) return false; + if (previous !== undefined && compareCanonicalStrings(previous, item) >= 0) return false; + previous = item; + } + return true; +} + +function validateScope(value: unknown): value is ControlScopeV1 { + if (!hasExactKeys(value, SCOPE_KEYS)) return false; + return ( + typeof value.kind === "string" && + CONTROL_SCOPE_KINDS.includes(value.kind as ControlScopeKindV1) && + isSortedUniqueTokenArray(value.ids) + ); +} + +export function isControlScopeV1(value: unknown): value is ControlScopeV1 { + return validateScope(value); +} + +function validateReference(value: unknown): value is ControlReferenceV1 { + if (!hasExactKeys(value, REFERENCE_KEYS)) return false; + return isEventId(value.event_id) && isUuid(value.control_id) && isEventId(value.fingerprint); +} + +function intrinsicPayloadError(value: unknown): ControlValidationCode | null { + if (!hasExactKeys(value, PAYLOAD_KEYS)) return "unexpected_keys"; + if (containsSecretShapedValue(value)) return "secret_shaped_value"; + if (value.version !== CONTROL_CONTRACT_VERSION) return "unsupported_contract_version"; + if (!isUuid(value.control_id)) return "invalid_control_id"; + if (!isEventId(value.fingerprint)) return "invalid_field"; + if (!isToken(value.tenant) || !isToken(value.authority_domain) || !isToken(value.policy_version) || !isToken(value.publisher)) { + return "invalid_field"; + } + if (typeof value.surface !== "string" || !CONTROL_SURFACES.includes(value.surface as ControlSurfaceV1)) { + return "invalid_field"; + } + if (!isPlainRecord(value.scope)) return "invalid_scope"; + if (!hasExactKeys(value.scope, SCOPE_KEYS)) return "unexpected_keys"; + if (!validateScope(value.scope)) return "invalid_scope"; + if (!isSortedUniqueTokenArray(value.affected_operations) || !isSortedUniqueTokenArray(value.affected_resources)) { + return "invalid_sorted_unique_array"; + } + + const issuedAt = parseTimestamp(value.issued_at); + const expiresAt = parseTimestamp(value.expires_at); + if (issuedAt === null || expiresAt === null) return "invalid_timestamp"; + const ttl = expiresAt - issuedAt; + if (!Number.isSafeInteger(ttl) || ttl <= 0 || ttl > MAX_CONTROL_TTL_MS) return "invalid_ttl"; + + if (value.state === "freeze") { + if (value.lifecycle_version !== 1) return "invalid_lifecycle"; + if (value.unfreeze_of !== null) return "invalid_unfreeze_reference"; + } else if (value.state === "unfreeze") { + if (value.lifecycle_version !== 2) return "invalid_lifecycle"; + if (!validateReference(value.unfreeze_of)) return "invalid_unfreeze_reference"; + if (value.unfreeze_of.control_id !== value.control_id || value.unfreeze_of.fingerprint !== value.fingerprint) { + return "invalid_unfreeze_reference"; + } + } else { + return "invalid_lifecycle"; + } + + try { + canonicalJson(value); + } catch { + return "malformed_control_metadata"; + } + return null; +} + +function eventPayload(event: ControlEventV1): ControlEventPayloadV1 { + return { + version: event.version, + control_id: event.control_id, + lifecycle_version: event.lifecycle_version, + state: event.state, + fingerprint: event.fingerprint, + tenant: event.tenant, + authority_domain: event.authority_domain, + policy_version: event.policy_version, + publisher: event.publisher, + surface: event.surface, + scope: event.scope, + affected_operations: event.affected_operations, + affected_resources: event.affected_resources, + issued_at: event.issued_at, + expires_at: event.expires_at, + unfreeze_of: event.unfreeze_of, + }; +} + +function safePayload(value: ControlEventPayloadV1): ControlEventPayloadV1 { + const error = intrinsicPayloadError(value); + if (error) throw new TypeError(`invalid control event payload: ${error}`); + return value; +} + +export function deriveControlEventId(payload: ControlEventPayloadV1): string { + const canonical = canonicalJson(safePayload(payload)); + return `sha256:${createHash("sha256").update(canonical, "utf8").digest("hex")}`; +} + +export function createControlEventV1(payload: ControlEventPayloadV1): ControlEventV1 { + const validPayload = safePayload(payload); + return { + ...validPayload, + event_id: deriveControlEventId(validPayload), + }; +} + +export function controlMetadataV1(event: ControlEventV1): Record { + return { [CONTROL_METADATA_KEY]: event }; +} + +function validateControlMetadataV1Unsafe( + metadata: unknown, + context: ControlValidationContextV1, +): ControlValidationResult { + if (!isPlainRecord(metadata)) { + return { status: "absent", diagnostics: [{ code: "no_control_metadata" }] }; + } + if (!hasDataPropertiesOnly(metadata)) { + return Object.hasOwn(metadata, CONTROL_METADATA_KEY) + ? { status: "invalid", diagnostics: [{ code: "malformed_control_metadata" }] } + : { status: "absent", diagnostics: [{ code: "no_control_metadata" }] }; + } + + const descriptor = Object.getOwnPropertyDescriptor(metadata, CONTROL_METADATA_KEY); + if (!descriptor) return { status: "absent", diagnostics: [{ code: "no_control_metadata" }] }; + if (!("value" in descriptor)) { + return { status: "invalid", diagnostics: [{ code: "malformed_control_metadata" }] }; + } + const candidate = descriptor.value; + if (!isPlainRecord(candidate) || !hasDataPropertiesOnly(candidate)) { + return { status: "invalid", diagnostics: [{ code: "malformed_control_metadata" }] }; + } + if (typeof candidate.version === "string" && candidate.version !== CONTROL_CONTRACT_VERSION) { + return { status: "invalid", diagnostics: [{ code: "unsupported_contract_version" }] }; + } + if (!hasExactKeys(candidate, EVENT_KEYS)) { + return { status: "invalid", diagnostics: [{ code: "unexpected_keys" }] }; + } + if (containsSecretShapedValue(candidate)) { + return { status: "invalid", diagnostics: [{ code: "secret_shaped_value" }] }; + } + + if (!hasExactKeys(context.trusted_envelope, TRUSTED_ENVELOPE_KEYS)) { + return { status: "invalid", diagnostics: [{ code: "invalid_trusted_envelope" }] }; + } + const trusted = context.trusted_envelope; + if (containsSecretShapedValue(trusted)) { + return { status: "invalid", diagnostics: [{ code: "secret_shaped_value" }] }; + } + if ( + !isToken(trusted.authenticated_principal) || + !isToken(trusted.tenant) || + !isToken(trusted.authority_domain) || + !isToken(trusted.policy_version) || + !CONTROL_SURFACES.includes(trusted.permitted_surface) || + typeof trusted.blocking !== "boolean" || + parseTimestamp(trusted.server_time) === null + ) { + return { status: "invalid", diagnostics: [{ code: "invalid_trusted_envelope" }] }; + } + + const payload = eventPayload(candidate as unknown as ControlEventV1); + const intrinsicError = intrinsicPayloadError(payload); + if (intrinsicError) return { status: "invalid", diagnostics: [{ code: intrinsicError }] }; + if (!isEventId(candidate.event_id)) { + return { status: "invalid", diagnostics: [{ code: "invalid_event_id" }] }; + } + + const activationTime = parseTimestamp(context.activation_timestamp); + const issuedAt = parseTimestamp(candidate.issued_at)!; + const expiresAt = parseTimestamp(candidate.expires_at)!; + const serverTime = parseTimestamp(trusted.server_time)!; + if (activationTime === null) { + return { status: "invalid", diagnostics: [{ code: "invalid_timestamp" }] }; + } + if (issuedAt < activationTime || serverTime < activationTime) { + return { status: "invalid", diagnostics: [{ code: "event_before_activation" }] }; + } + if (issuedAt > serverTime) { + return { status: "invalid", diagnostics: [{ code: "event_from_future" }] }; + } + if (expiresAt <= serverTime) { + return { status: "invalid", diagnostics: [{ code: "event_expired_at_ingress" }] }; + } + if ( + candidate.publisher !== trusted.authenticated_principal || + candidate.tenant !== trusted.tenant || + candidate.authority_domain !== trusted.authority_domain || + candidate.surface !== trusted.permitted_surface || + candidate.policy_version !== trusted.policy_version + ) { + return { status: "invalid", diagnostics: [{ code: "trusted_claim_mismatch" }] }; + } + if ((candidate.state === "freeze") !== trusted.blocking) { + return { status: "invalid", diagnostics: [{ code: "blocking_state_mismatch" }] }; + } + if (deriveControlEventId(payload) !== candidate.event_id) { + return { status: "invalid", diagnostics: [{ code: "invalid_event_id" }] }; + } + + const event: ControlEventV1 = { + version: CONTROL_CONTRACT_VERSION, + event_id: candidate.event_id as string, + control_id: candidate.control_id as string, + lifecycle_version: candidate.lifecycle_version as number, + state: candidate.state as ControlStateV1, + fingerprint: candidate.fingerprint as string, + tenant: candidate.tenant as string, + authority_domain: candidate.authority_domain as string, + policy_version: candidate.policy_version as string, + publisher: candidate.publisher as string, + surface: candidate.surface as ControlSurfaceV1, + scope: { + kind: (candidate.scope as ControlScopeV1).kind, + ids: [...(candidate.scope as ControlScopeV1).ids], + }, + affected_operations: [...(candidate.affected_operations as string[])], + affected_resources: [...(candidate.affected_resources as string[])], + issued_at: candidate.issued_at as string, + expires_at: candidate.expires_at as string, + unfreeze_of: + candidate.unfreeze_of === null + ? null + : { + event_id: (candidate.unfreeze_of as ControlReferenceV1).event_id, + control_id: (candidate.unfreeze_of as ControlReferenceV1).control_id, + fingerprint: (candidate.unfreeze_of as ControlReferenceV1).fingerprint, + }, + }; + return { + status: "valid", + event, + canonical_event: canonicalJson(event), + canonical_payload: canonicalJson(payload), + diagnostics: [], + }; +} + +export function validateControlMetadataV1( + metadata: unknown, + context: ControlValidationContextV1, +): ControlValidationResult { + try { + return validateControlMetadataV1Unsafe(metadata, context); + } catch { + return { status: "invalid", diagnostics: [{ code: "malformed_control_metadata" }] }; + } +} diff --git a/src/lib/control-evaluator.test.ts b/src/lib/control-evaluator.test.ts new file mode 100644 index 0000000..0e161fe --- /dev/null +++ b/src/lib/control-evaluator.test.ts @@ -0,0 +1,409 @@ +import { describe, expect, test } from "bun:test"; +import { + CONTROL_CONTRACT_VERSION, + CONTROL_METADATA_KEY, + CONTROL_VALIDATOR_VERSION, + controlMetadataV1, + createControlEventV1, + type ControlEventPayloadV1, + type ControlEventV1, + type ControlScopeV1, + type TrustedControlEnvelopeV1, +} from "./control-contract"; +import { + MAX_CONTROL_OBSERVATIONS, + evaluateControlsV1, + type ControlEvaluationInputV1, + type ControlObservationV1, +} from "./control-evaluator"; + +const ACTIVATION_TIME = "2026-07-22T00:00:00.000Z"; +const EVALUATION_TIME = "2026-07-23T12:00:00.000Z"; +const CONTROL_ID = "123e4567-e89b-42d3-a456-426614174000"; +const SECOND_CONTROL_ID = "223e4567-e89b-42d3-a456-426614174000"; +const FINGERPRINT = `sha256:${"a".repeat(64)}`; + +function freezePayload(overrides: Partial = {}): ControlEventPayloadV1 { + return { + version: CONTROL_CONTRACT_VERSION, + control_id: CONTROL_ID, + lifecycle_version: 1, + state: "freeze", + fingerprint: FINGERPRINT, + tenant: "tenant:hasna", + authority_domain: "hasna/control-operators", + policy_version: "1.0.1", + publisher: "agent:security-operator", + surface: "announcements", + scope: { kind: "project", ids: ["project:conversations"] }, + affected_operations: ["tool.execute", "workflow.dispatch"], + affected_resources: ["repo:hasna/conversations"], + issued_at: "2026-07-23T00:00:00.000Z", + expires_at: "2026-07-24T00:00:00.000Z", + unfreeze_of: null, + ...overrides, + }; +} + +function trustedEnvelope( + event: ControlEventV1, + overrides: Partial = {}, +): TrustedControlEnvelopeV1 { + return { + authenticated_principal: event.publisher, + tenant: event.tenant, + authority_domain: event.authority_domain, + permitted_surface: event.surface, + policy_version: event.policy_version, + server_time: new Date(Date.parse(event.issued_at) + 1_000).toISOString(), + blocking: event.state === "freeze", + ...overrides, + }; +} + +function observation( + event: ControlEventV1, + overrides: Partial = {}, +): ControlObservationV1 { + return { + content: null, + metadata: controlMetadataV1(event), + trusted_envelope: trustedEnvelope(event), + ...overrides, + }; +} + +function unfreezeFor(freeze: ControlEventV1, overrides: Partial = {}): ControlEventV1 { + return createControlEventV1({ + ...freezePayload(), + control_id: freeze.control_id, + fingerprint: freeze.fingerprint, + lifecycle_version: 2, + state: "unfreeze", + issued_at: "2026-07-23T01:00:00.000Z", + expires_at: "2026-07-24T01:00:00.000Z", + unfreeze_of: { + event_id: freeze.event_id, + control_id: freeze.control_id, + fingerprint: freeze.fingerprint, + }, + ...overrides, + }); +} + +function input(observations: readonly ControlObservationV1[]): ControlEvaluationInputV1 { + return { + config: { + mode: "observe_only", + validator_version: CONTROL_VALIDATOR_VERSION, + activation_timestamp: ACTIVATION_TIME, + evaluation_time: EVALUATION_TIME, + }, + target: { + tenant: "tenant:hasna", + authority_domain: "hasna/control-operators", + scope: { kind: "project", ids: ["project:conversations"] }, + operation: "tool.execute", + resource: "repo:hasna/conversations", + }, + backend: { status: "available", observations }, + }; +} + +function withTarget( + value: ControlEvaluationInputV1, + overrides: Partial, +): ControlEvaluationInputV1 { + return { ...value, target: { ...value.target, ...overrides } }; +} + +describe("observe-only control evaluator", () => { + test("returns a non-enforcing hold only for an applicable authenticated freeze", () => { + const freeze = createControlEventV1(freezePayload()); + const result = evaluateControlsV1(input([observation(freeze)])); + + expect(result).toMatchObject({ + decision: "hold", + mode: "observe_only", + enforced: false, + active_control_ids: [CONTROL_ID], + accepted_event_count: 1, + rejected_event_count: 0, + }); + }); + + test("ignores literal control language and preserves generic blocker compatibility", () => { + for (const content of ["[FREEZE] all tools", "UNFREEZE now", "[BLOCKED]", "malformed [FREEZE"] ) { + const result = evaluateControlsV1( + input([ + { + content, + metadata: { severity: "FREEZE", blocking: 1 }, + trusted_envelope: { + authenticated_principal: "agent:legacy", + tenant: "tenant:hasna", + authority_domain: "hasna/control-operators", + permitted_surface: "announcements", + policy_version: "1.0.1", + server_time: "2026-07-23T00:00:01.000Z", + blocking: true, + }, + }, + ]), + ); + expect(result.decision).toBe("allow"); + expect(result.active_control_ids).toEqual([]); + expect(result.diagnostics.map((item) => item.code)).toContain("ordinary_blocker_ignored"); + } + }); + + test("keeps unrelated operations, resources, tenants, domains, and sibling scopes allowed", () => { + const freeze = createControlEventV1(freezePayload()); + const base = input([observation(freeze)]); + const cases = [ + withTarget(base, { operation: "tool.read" }), + withTarget(base, { resource: "repo:hasna/todos" }), + withTarget(base, { tenant: "tenant:other" }), + withTarget(base, { authority_domain: "other/control-operators" }), + withTarget(base, { scope: { kind: "project", ids: ["project:todos"] } }), + withTarget(base, { scope: { kind: "machine", ids: ["machine:station01"] } }), + ]; + for (const candidate of cases) { + expect(evaluateControlsV1(candidate).decision).toBe("allow"); + } + }); + + test("accepts an exact unfreeze and is deterministic when backend rows are reversed", () => { + const freeze = createControlEventV1(freezePayload()); + const unfreeze = unfreezeFor(freeze); + const forward = evaluateControlsV1(input([observation(freeze), observation(unfreeze)])); + const reverse = evaluateControlsV1(input([observation(unfreeze), observation(freeze)])); + + expect(forward.decision).toBe("allow"); + expect(reverse).toEqual(forward); + expect(forward.accepted_event_count).toBe(2); + expect(forward.active_control_ids).toEqual([]); + }); + + test("treats exact replay as idempotent", () => { + const freeze = createControlEventV1(freezePayload()); + const replay = observation(freeze); + const result = evaluateControlsV1(input([replay, replay])); + + expect(result.decision).toBe("hold"); + expect(result.accepted_event_count).toBe(1); + expect(result.rejected_event_count).toBe(0); + expect(result.diagnostics.map((item) => item.code)).toContain("exact_replay_ignored"); + }); + + test("rejects a conflicting duplicate lifecycle version and keeps the first freeze active", () => { + const first = createControlEventV1(freezePayload()); + const second = createControlEventV1( + freezePayload({ + issued_at: "2026-07-23T00:01:00.000Z", + expires_at: "2026-07-24T00:01:00.000Z", + }), + ); + const result = evaluateControlsV1(input([observation(second), observation(first)])); + + expect(result.decision).toBe("hold"); + expect(result.accepted_event_count).toBe(1); + expect(result.rejected_event_count).toBe(1); + expect(result.diagnostics.map((item) => item.code)).toContain("conflicting_duplicate"); + }); + + test("bad release references and mismatched context never clear the active freeze", () => { + const freeze = createControlEventV1(freezePayload()); + const wrongReference = unfreezeFor(freeze, { + unfreeze_of: { + event_id: `sha256:${"b".repeat(64)}`, + control_id: freeze.control_id, + fingerprint: freeze.fingerprint, + }, + }); + const wrongDomain = unfreezeFor(freeze, { + authority_domain: "other/control-operators", + }); + const wrongScope = unfreezeFor(freeze, { + scope: { kind: "project", ids: ["project:other"] }, + }); + const wrongFingerprintValue = `sha256:${"b".repeat(64)}`; + const wrongFingerprint = unfreezeFor(freeze, { + fingerprint: wrongFingerprintValue, + unfreeze_of: { + event_id: freeze.event_id, + control_id: freeze.control_id, + fingerprint: wrongFingerprintValue, + }, + }); + + for (const candidate of [wrongReference, wrongDomain, wrongScope, wrongFingerprint]) { + const result = evaluateControlsV1(input([observation(freeze), observation(candidate)])); + expect(result.decision).toBe("hold"); + expect(result.active_control_ids).toEqual([freeze.control_id]); + expect(result.rejected_event_count).toBe(1); + } + }); + + test("rejects orphaned, stale, and concurrent releases without depending on input order", () => { + const freeze = createControlEventV1(freezePayload()); + const unfreeze = unfreezeFor(freeze); + const orphan = evaluateControlsV1(input([observation(unfreeze)])); + expect(orphan.decision).toBe("indeterminate"); + expect(orphan.diagnostics.map((item) => item.code)).toContain("orphan_or_reordered_unfreeze"); + + const concurrent = unfreezeFor(freeze, { + issued_at: freeze.issued_at, + expires_at: freeze.expires_at, + }); + const concurrentObservation = observation(concurrent, { + trusted_envelope: trustedEnvelope(concurrent, { server_time: trustedEnvelope(freeze).server_time }), + }); + const concurrentResult = evaluateControlsV1( + input([concurrentObservation, observation(freeze)]), + ); + expect(concurrentResult.decision).toBe("hold"); + expect(concurrentResult.diagnostics.map((item) => item.code)).toContain("stale_or_reordered_unfreeze"); + }); + + test("keeps overlapping controls independent and releases only the exact control", () => { + const first = createControlEventV1( + freezePayload({ scope: { kind: "project", ids: ["project:a", "project:b"] } }), + ); + const second = createControlEventV1( + freezePayload({ + control_id: SECOND_CONTROL_ID, + fingerprint: `sha256:${"b".repeat(64)}`, + scope: { kind: "project", ids: ["project:b", "project:c"] }, + }), + ); + const releaseFirst = unfreezeFor(first, { + scope: first.scope, + affected_operations: first.affected_operations, + affected_resources: first.affected_resources, + }); + const targetScope: ControlScopeV1 = { kind: "project", ids: ["project:b"] }; + const result = evaluateControlsV1( + withTarget( + input([observation(first), observation(second), observation(releaseFirst)]), + { scope: targetScope }, + ), + ); + + expect(result.decision).toBe("hold"); + expect(result.active_control_ids).toEqual([SECOND_CONTROL_ID]); + }); + + test("isolates identical control ids across tenants and authority domains", () => { + const otherTenant = createControlEventV1( + freezePayload({ + tenant: "tenant:other", + authority_domain: "other/control-operators", + issued_at: "2026-07-22T23:59:00.000Z", + expires_at: "2026-07-23T23:59:00.000Z", + }), + ); + const targetTenant = createControlEventV1(freezePayload()); + const result = evaluateControlsV1(input([observation(otherTenant), observation(targetTenant)])); + + expect(result.decision).toBe("hold"); + expect(result.accepted_event_count).toBe(2); + expect(result.rejected_event_count).toBe(0); + expect(result.active_control_ids).toEqual([CONTROL_ID]); + }); + + test("expires freezes without resurrecting them or requiring an unfreeze", () => { + const freeze = createControlEventV1( + freezePayload({ expires_at: "2026-07-23T11:59:59.999Z" }), + ); + const result = evaluateControlsV1(input([observation(freeze)])); + expect(result.decision).toBe("allow"); + expect(result.active_control_ids).toEqual([]); + expect(result.diagnostics.map((item) => item.code)).toContain("freeze_expired"); + }); + + test("returns indeterminate, never hold, for backend failure or unsupported validator versions", () => { + const unavailable = input([]); + unavailable.backend = { status: "unavailable" }; + expect(evaluateControlsV1(unavailable)).toMatchObject({ + decision: "indeterminate", + enforced: false, + active_control_ids: [], + }); + + const unsupported = input([]); + unsupported.config.validator_version = "hasna.control/v2"; + expect(evaluateControlsV1(unsupported)).toMatchObject({ + decision: "indeterminate", + enforced: false, + active_control_ids: [], + }); + + const tooMany = input([]); + (tooMany.backend as { status: "available"; observations: ControlObservationV1[] }).observations = + Array.from({ length: MAX_CONTROL_OBSERVATIONS + 1 }, () => ({ + content: null, + metadata: null, + trusted_envelope: trustedEnvelope(createControlEventV1(freezePayload())), + })); + expect(evaluateControlsV1(tooMany)).toMatchObject({ + decision: "indeterminate", + enforced: false, + active_control_ids: [], + diagnostics: [{ code: "observation_limit_exceeded" }], + }); + }); + + test("supports rollback by switching observe-only evaluation off", () => { + const freeze = createControlEventV1(freezePayload()); + const disabled = input([observation(freeze)]); + disabled.config.mode = "off"; + expect(evaluateControlsV1(disabled)).toEqual({ + decision: "allow", + mode: "off", + enforced: false, + active_control_ids: [], + accepted_event_count: 0, + rejected_event_count: 0, + diagnostics: [{ code: "validator_disabled" }], + }); + }); + + test("reports malformed and unsupported metadata as indeterminate without inventing a hold", () => { + const malformed = input([ + { + content: "irrelevant", + metadata: { [CONTROL_METADATA_KEY]: { version: CONTROL_CONTRACT_VERSION } }, + trusted_envelope: trustedEnvelope(createControlEventV1(freezePayload())), + }, + ]); + expect(evaluateControlsV1(malformed)).toMatchObject({ + decision: "indeterminate", + enforced: false, + active_control_ids: [], + rejected_event_count: 1, + }); + + const unsupported = structuredClone(malformed); + (unsupported.backend as { status: "available"; observations: ControlObservationV1[] }).observations[0]!.metadata = { + [CONTROL_METADATA_KEY]: { version: "hasna.control/v2" }, + }; + expect(evaluateControlsV1(unsupported)).toMatchObject({ + decision: "indeterminate", + enforced: false, + active_control_ids: [], + rejected_event_count: 1, + }); + + const ambiguousTarget = input([]) as ControlEvaluationInputV1 & { + target: ControlEvaluationInputV1["target"] & { global?: boolean }; + }; + ambiguousTarget.target.global = true; + expect(evaluateControlsV1(ambiguousTarget)).toMatchObject({ + decision: "indeterminate", + enforced: false, + active_control_ids: [], + diagnostics: [{ code: "invalid_evaluator_input" }], + }); + }); +}); diff --git a/src/lib/control-evaluator.ts b/src/lib/control-evaluator.ts new file mode 100644 index 0000000..711bf47 --- /dev/null +++ b/src/lib/control-evaluator.ts @@ -0,0 +1,362 @@ +import { + CONTROL_VALIDATOR_VERSION, + canonicalJson, + controlTimestampMsV1, + isControlScopeV1, + isControlTokenV1, + validateControlMetadataV1, + type ControlEventV1, + type ControlScopeV1, + type TrustedControlEnvelopeV1, +} from "./control-contract.js"; + +export type ControlDecision = "allow" | "hold" | "indeterminate"; + +export interface ControlObservationV1 { + content: string | null; + metadata: unknown; + trusted_envelope: TrustedControlEnvelopeV1; +} + +export type ControlBackendSnapshotV1 = + | { + status: "available"; + observations: readonly ControlObservationV1[]; + } + | { + status: "unavailable"; + }; + +export interface ControlEvaluatorConfigV1 { + mode: string; + validator_version: string; + activation_timestamp: string; + evaluation_time: string; +} + +export interface ControlEvaluationTargetV1 { + tenant: string; + authority_domain: string; + scope: ControlScopeV1; + operation: string; + resource: string; +} + +export interface ControlEvaluationInputV1 { + config: ControlEvaluatorConfigV1; + target: ControlEvaluationTargetV1; + backend: ControlBackendSnapshotV1; +} + +export interface ControlEvaluationDiagnostic { + code: string; + event_id?: string; + control_id?: string; +} + +export interface ControlEvaluationResultV1 { + decision: ControlDecision; + mode: "off" | "observe_only"; + enforced: false; + active_control_ids: string[]; + accepted_event_count: number; + rejected_event_count: number; + diagnostics: ControlEvaluationDiagnostic[]; +} + +export const MAX_CONTROL_OBSERVATIONS = 4_096; + +interface ValidObservation { + event: ControlEventV1; + canonical_event: string; + trusted_envelope: TrustedControlEnvelopeV1; +} + +interface LifecycleState { + freeze: ValidObservation; + released: boolean; +} + +const CONFIG_KEYS = ["mode", "validator_version", "activation_timestamp", "evaluation_time"] as const; +const TARGET_KEYS = ["tenant", "authority_domain", "scope", "operation", "resource"] as const; + +function hasExactDataKeys(value: unknown, expected: readonly string[]): value is Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return false; + if (Object.getOwnPropertySymbols(value).length > 0) return false; + const keys = Object.keys(value); + if (keys.length !== expected.length || !expected.every((key) => Object.hasOwn(value, key))) return false; + return keys.every((key) => { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return Boolean(descriptor?.enumerable && "value" in descriptor); + }); +} + +function result( + decision: ControlDecision, + mode: "off" | "observe_only", + diagnostics: ControlEvaluationDiagnostic[], + activeControlIds: string[] = [], + acceptedEventCount = 0, + rejectedEventCount = 0, +): ControlEvaluationResultV1 { + return { + decision, + mode, + enforced: false, + active_control_ids: [...activeControlIds].sort(), + accepted_event_count: acceptedEventCount, + rejected_event_count: rejectedEventCount, + diagnostics, + }; +} + +function arraysEqual(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function scopesEqual(left: ControlScopeV1, right: ControlScopeV1): boolean { + return left.kind === right.kind && arraysEqual(left.ids, right.ids); +} + +function scopesOverlap(left: ControlScopeV1, right: ControlScopeV1): boolean { + if (left.kind !== right.kind) return false; + const rightIds = new Set(right.ids); + return left.ids.some((id) => rightIds.has(id)); +} + +function releaseMatches(freeze: ControlEventV1, release: ControlEventV1): boolean { + const reference = release.unfreeze_of; + if (!reference) return false; + return ( + reference.event_id === freeze.event_id && + reference.control_id === freeze.control_id && + reference.fingerprint === freeze.fingerprint && + release.control_id === freeze.control_id && + release.fingerprint === freeze.fingerprint && + release.tenant === freeze.tenant && + release.authority_domain === freeze.authority_domain && + release.policy_version === freeze.policy_version && + release.publisher === freeze.publisher && + release.surface === freeze.surface && + scopesEqual(release.scope, freeze.scope) && + arraysEqual(release.affected_operations, freeze.affected_operations) && + arraysEqual(release.affected_resources, freeze.affected_resources) + ); +} + +function isValidTarget(target: ControlEvaluationTargetV1): boolean { + return ( + isControlTokenV1(target.tenant) && + isControlTokenV1(target.authority_domain) && + isControlScopeV1(target.scope) && + isControlTokenV1(target.operation) && + isControlTokenV1(target.resource) + ); +} + +function appliesToTarget(event: ControlEventV1, target: ControlEvaluationTargetV1): boolean { + return ( + event.tenant === target.tenant && + event.authority_domain === target.authority_domain && + scopesOverlap(event.scope, target.scope) && + event.affected_operations.includes(target.operation) && + event.affected_resources.includes(target.resource) + ); +} + +function lifecycleKey(event: Pick): string { + return `${event.tenant}\u0000${event.authority_domain}\u0000${event.control_id}`; +} + +function compareObservations(left: ValidObservation, right: ValidObservation): number { + const trustedDelta = controlTimestampMsV1(left.trusted_envelope.server_time)! - controlTimestampMsV1(right.trusted_envelope.server_time)!; + if (trustedDelta !== 0) return trustedDelta; + const issuedDelta = controlTimestampMsV1(left.event.issued_at)! - controlTimestampMsV1(right.event.issued_at)!; + if (issuedDelta !== 0) return issuedDelta; + if (left.event.lifecycle_version !== right.event.lifecycle_version) { + return left.event.lifecycle_version - right.event.lifecycle_version; + } + return left.event.event_id < right.event.event_id ? -1 : left.event.event_id > right.event.event_id ? 1 : 0; +} + +function evaluateControlsV1Unsafe(input: ControlEvaluationInputV1): ControlEvaluationResultV1 { + if (!hasExactDataKeys(input.config, CONFIG_KEYS)) { + return result("indeterminate", "observe_only", [{ code: "invalid_evaluator_input" }]); + } + if (input.config.mode === "off") { + return result("allow", "off", [{ code: "validator_disabled" }]); + } + if (input.config.mode !== "observe_only") { + return result("indeterminate", "observe_only", [{ code: "unsupported_evaluator_mode" }]); + } + if (input.config.validator_version !== CONTROL_VALIDATOR_VERSION) { + return result("indeterminate", "observe_only", [{ code: "unsupported_validator_version" }]); + } + + if (!hasExactDataKeys(input.target, TARGET_KEYS)) { + return result("indeterminate", "observe_only", [{ code: "invalid_evaluator_input" }]); + } + + const activationTime = controlTimestampMsV1(input.config.activation_timestamp); + const evaluationTime = controlTimestampMsV1(input.config.evaluation_time); + if (activationTime === null || evaluationTime === null || evaluationTime < activationTime || !isValidTarget(input.target)) { + return result("indeterminate", "observe_only", [{ code: "invalid_evaluator_input" }]); + } + if (input.backend.status === "unavailable") { + return result("indeterminate", "observe_only", [{ code: "backend_unavailable" }]); + } + if (input.backend.observations.length > MAX_CONTROL_OBSERVATIONS) { + return result("indeterminate", "observe_only", [{ code: "observation_limit_exceeded" }]); + } + + const diagnostics: ControlEvaluationDiagnostic[] = []; + const valid: ValidObservation[] = []; + let rejectedEventCount = 0; + + for (const observation of input.backend.observations) { + const validation = validateControlMetadataV1(observation.metadata, { + trusted_envelope: observation.trusted_envelope, + activation_timestamp: input.config.activation_timestamp, + }); + if (validation.status === "absent") { + if (observation.trusted_envelope.blocking === true) diagnostics.push({ code: "ordinary_blocker_ignored" }); + continue; + } + if (validation.status === "invalid") { + rejectedEventCount += 1; + diagnostics.push({ code: validation.diagnostics[0]?.code ?? "invalid_control_metadata" }); + continue; + } + valid.push({ + event: validation.event, + canonical_event: validation.canonical_event, + trusted_envelope: observation.trusted_envelope, + }); + } + + valid.sort(compareObservations); + const seenEvents = new Map(); + const lifecycles = new Map(); + const lifecycleKeysByControlId = new Map>(); + let acceptedEventCount = 0; + + for (const observation of valid) { + const { event } = observation; + const seenCanonical = seenEvents.get(event.event_id); + if (seenCanonical !== undefined) { + if (seenCanonical === observation.canonical_event) { + diagnostics.push({ code: "exact_replay_ignored", event_id: event.event_id, control_id: event.control_id }); + } else { + rejectedEventCount += 1; + diagnostics.push({ code: "conflicting_duplicate", event_id: event.event_id, control_id: event.control_id }); + } + continue; + } + seenEvents.set(event.event_id, observation.canonical_event); + + const key = lifecycleKey(event); + const lifecycle = lifecycles.get(key); + if (event.state === "freeze") { + if (lifecycle) { + rejectedEventCount += 1; + diagnostics.push({ code: "conflicting_duplicate", event_id: event.event_id, control_id: event.control_id }); + continue; + } + lifecycles.set(key, { freeze: observation, released: false }); + const indexedKeys = lifecycleKeysByControlId.get(event.control_id) ?? new Set(); + indexedKeys.add(key); + lifecycleKeysByControlId.set(event.control_id, indexedKeys); + acceptedEventCount += 1; + continue; + } + + if (!lifecycle) { + rejectedEventCount += 1; + diagnostics.push({ + code: lifecycleKeysByControlId.has(event.control_id) + ? "unfreeze_context_mismatch" + : "orphan_or_reordered_unfreeze", + event_id: event.event_id, + control_id: event.control_id, + }); + continue; + } + if (lifecycle.released) { + rejectedEventCount += 1; + diagnostics.push({ code: "stale_or_reordered_unfreeze", event_id: event.event_id, control_id: event.control_id }); + continue; + } + if (!releaseMatches(lifecycle.freeze.event, event)) { + rejectedEventCount += 1; + diagnostics.push({ code: "unfreeze_context_mismatch", event_id: event.event_id, control_id: event.control_id }); + continue; + } + + const freezeIssuedAt = controlTimestampMsV1(lifecycle.freeze.event.issued_at)!; + const freezeIngressAt = controlTimestampMsV1(lifecycle.freeze.trusted_envelope.server_time)!; + const freezeExpiresAt = controlTimestampMsV1(lifecycle.freeze.event.expires_at)!; + const releaseIssuedAt = controlTimestampMsV1(event.issued_at)!; + const releaseIngressAt = controlTimestampMsV1(observation.trusted_envelope.server_time)!; + if ( + releaseIssuedAt <= freezeIssuedAt || + releaseIngressAt <= freezeIngressAt || + releaseIssuedAt >= freezeExpiresAt + ) { + rejectedEventCount += 1; + diagnostics.push({ code: "stale_or_reordered_unfreeze", event_id: event.event_id, control_id: event.control_id }); + continue; + } + + lifecycle.released = true; + acceptedEventCount += 1; + diagnostics.push({ code: "control_released", event_id: event.event_id, control_id: event.control_id }); + } + + const applicableControls: string[] = []; + for (const lifecycle of lifecycles.values()) { + const controlId = lifecycle.freeze.event.control_id; + if (lifecycle.released) continue; + if (controlTimestampMsV1(lifecycle.freeze.event.expires_at)! <= evaluationTime) { + diagnostics.push({ + code: "freeze_expired", + event_id: lifecycle.freeze.event.event_id, + control_id: controlId, + }); + continue; + } + if (appliesToTarget(lifecycle.freeze.event, input.target)) applicableControls.push(controlId); + } + + if (applicableControls.length > 0) { + for (const controlId of [...applicableControls].sort()) diagnostics.push({ code: "active_control", control_id: controlId }); + return result( + "hold", + "observe_only", + diagnostics, + applicableControls, + acceptedEventCount, + rejectedEventCount, + ); + } + if (rejectedEventCount > 0) { + return result( + "indeterminate", + "observe_only", + diagnostics, + [], + acceptedEventCount, + rejectedEventCount, + ); + } + return result("allow", "observe_only", diagnostics, [], acceptedEventCount, rejectedEventCount); +} + +export function evaluateControlsV1(input: ControlEvaluationInputV1): ControlEvaluationResultV1 { + try { + return evaluateControlsV1Unsafe(input); + } catch { + return result("indeterminate", "observe_only", [{ code: "invalid_evaluator_input" }]); + } +} From 116e5c17bfa0de1607e0d5d6d5e98aaae836e6c1 Mon Sep 17 00:00:00 2001 From: andreihasna Date: Thu, 23 Jul 2026 04:52:07 +0300 Subject: [PATCH 2/9] fix: harden control evaluation boundaries --- CHANGELOG.md | 2 +- docs/HASNA-CONTROL-V1-THREAT-MODEL.md | 8 +- docs/HASNA-CONTROL-V1.md | 14 ++- src/lib/control-contract.test.ts | 52 +++++++- src/lib/control-contract.ts | 114 ++++++++++++++--- src/lib/control-evaluator.test.ts | 77 ++++++++++++ src/lib/control-evaluator.ts | 171 ++++++++++++++++++++++---- 7 files changed, 387 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a65f2a3..401a5ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. ## Unreleased ### Added -- Added the repository-internal, observe-only `hasna.control/v1` metadata validator/evaluator, portable conformance and legacy-blocker fixtures, and a test-linked threat model. This slice adds no schema, ingress, live hook, or enforcement. +- Added the repository-internal, observe-only `hasna.control/v1` metadata validator/evaluator, portable conformance and legacy-blocker fixtures, and a test-linked threat model. Runtime inputs are descriptor-snapshotted against proxy TOCTOU, historical evaluation excludes future observations, and malformed backend or mixed evidence remains indeterminate. This slice adds no schema, ingress, live hook, or enforcement. - **Self-hosted HTTP API surface (`conversations-serve`)**: a pure-remote (Amendment A1) service that reads/writes the app's cloud Postgres directly via the vendored `@hasna/contracts` storage kit. Exposes `GET /health`, `/ready`, `/version` (`{status,version,mode}`) and a versioned `/v1` API (messages, channels, projects, agent presence) guarded by `@hasna/contracts` API-key auth (`conversations:read` / `conversations:write` scopes). `GET /v1/openapi.json` serves the OpenAPI document. - **Generated typed SDK** under the `@hasna/conversations/sdk` export, generated from the serve OpenAPI (`bun run sdk:generate`). - **Migration runner** (`src/server/migrate.ts`) that applies the app schema + `api_keys` table via the owner role (idempotent; never clobbers data). diff --git a/docs/HASNA-CONTROL-V1-THREAT-MODEL.md b/docs/HASNA-CONTROL-V1-THREAT-MODEL.md index 7686b9c..cac3d90 100644 --- a/docs/HASNA-CONTROL-V1-THREAT-MODEL.md +++ b/docs/HASNA-CONTROL-V1-THREAT-MODEL.md @@ -37,15 +37,15 @@ Data flow: | --- | --- | --- | --- | --- | --- | --- | | T1 | Metadata → validator | S/E | A caller self-asserts publisher, tenant, domain, surface, or policy and gains control authority. | H×H | Compare every claim to a separately supplied, closed trusted envelope; metadata never supplies envelope values. | Trusted-claim mismatch matrix. | | T2 | Message content | E | Literal `[FREEZE]`, `UNFREEZE`, or `[BLOCKED]` text is interpreted as authority. | H×H | Evaluator never reads content; only the exact metadata key can be a candidate. | Legacy compatibility JSON vectors. | -| T3 | Unfreeze lifecycle | T/E | A stale, wrong-scope, wrong-domain, wrong-ID, or wrong-fingerprint release clears a valid freeze. | H×H | Exact three-field reference, exact context/target equality, strict version and trusted-time ordering; invalid release never mutates active state. | Bad-release matrix and concurrent/reversed-order tests. | +| T3 | Unfreeze lifecycle | T/E | A stale, late, wrong-scope, wrong-domain, wrong-ID, or wrong-fingerprint release clears a valid freeze. | H×H | Exact three-field reference, exact context/target equality, strict version and trusted-time ordering, and release ingress before freeze expiry; invalid release never mutates active state. | Bad-release matrix, post-expiry ingress, and concurrent/reversed-order tests. | | T4 | Cross-tenant state | E/I | Reused control IDs collide across tenants or authority domains. | M×H | Lifecycle key is tenant + authority domain + control ID; target matching repeats tenant/domain checks. | Identical-ID cross-tenant test. | | T5 | Backend ordering/replay | T/R | Reverse ordering, duplicate delivery, or concurrent rows changes the lifecycle result. | H×M | Deterministic trusted ordering, canonical event hash, exact replay dedupe, and conflicting-version rejection. | Forward/reverse fixture equality, replay, duplicate, and concurrency tests. | | T6 | Preseeded rows | E | Metadata inserted before activation becomes a control when the feature is enabled. | M×H | Event issue time and trusted ingress server time must both be at or after activation. | Pre-activation event and pre-activation ingress tests. | | T7 | Scope ambiguity | E/D | Missing scope or wildcard is treated as global, holding unrelated work. | M×H | Closed scope enum, non-empty sorted unique IDs, no wildcard/global kind, exact operation/resource tokens. | Empty/unsorted/duplicate scope matrix and unrelated-target tests. | -| T8 | Backend/version failure | D/E | Failure or unknown version invents a global hold. | M×H | Return `indeterminate`, empty active controls, and `enforced: false`; no fallback hold. | Backend-unavailable and unsupported-version tests. | +| T8 | Backend/version failure | D/E | Failure, malformed status, or unknown version invents a global hold. | M×H | Require a closed backend discriminant and bounded dense observation array; return `indeterminate` and `enforced: false` with no fallback hold. | Unavailable, malformed-status, over-limit, and unsupported-version tests. | | T9 | Secret-bearing input | I | Credential-like material is echoed through an exception, diagnostic, or log. | M×H | Detect high-signal secret shapes before semantic diagnostics; return code only; no logging or rejected values. | Event/envelope secret-shape and non-echo tests. | -| T10 | Hostile JS values | D/I | Getters, proxies, sparse arrays, or non-finite values execute code, crash evaluation, or evade hashing. | M×M | Data-property-only plain objects, dense unaugmented arrays, bounded canonicalization, normalized copies, top-level exception containment. | Accessor/proxy/sparse-array tests. | -| T11 | TTL/order manipulation | T/D | Future, expired, indefinite, or reordered events persist or resurrect holds. | M×H | Canonical timestamps, seven-day maximum TTL, future/expiry checks at ingress, strict release ordering, automatic freeze expiry. | TTL boundary, future/expired ingress, stale release, and expiry tests. | +| T10 | Hostile JS values | D/I | Getters, time-varying proxies, sparse arrays, or non-finite values execute code, crash evaluation, or split the hashed identity from the returned event. | M×M | Copy descriptor values exactly once into bounded plain snapshots before validation/hashing; reject accessors and malformed containers; contain exceptions. | Accessor, proxy TOCTOU, sparse-array, and mutation-after-validation tests. | +| T11 | TTL/order manipulation | T/D | Future, expired, indefinite, late-release, or reordered events persist or resurrect holds. | M×H | Canonical timestamps, seven-day maximum TTL, ingress and evaluation-time future checks, release ingress before freeze expiry, strict ordering, automatic freeze expiry. | TTL boundary, future/expired ingress, historical evaluation, late release, stale release, and expiry tests. | | T12 | Evaluator → future hook | E | An observe-only result blocks a real tool or replaces independent safety containment. | M×H | Result always carries `enforced: false`; only `off` and `observe_only` modes exist; docs require a separate reviewed activation. | Hold-result and rollback tests; integration follow-up gate. | Assumptions and open questions: diff --git a/docs/HASNA-CONTROL-V1.md b/docs/HASNA-CONTROL-V1.md index b31af91..55c2a2d 100644 --- a/docs/HASNA-CONTROL-V1.md +++ b/docs/HASNA-CONTROL-V1.md @@ -40,7 +40,9 @@ in canonical lexical order; the validator never silently normalizes authority. Canonical JSON recursively sorts object keys, preserves validated array order, normalizes negative zero to zero, and rejects non-finite numbers, sparse or augmented arrays, accessors, non-plain objects, excessive nesting, and values -larger than the contract bound. The event ID is: +larger than the contract bound. Proxy-backed input is copied from data-property +descriptors into a plain snapshot before validation or hashing, so later reads +cannot change the identity or trusted time. The event ID is: ```text sha256(utf8(canonical_json(event_without_event_id))) @@ -87,7 +89,10 @@ enabled. The evaluator processes validated observations by trusted ingress time, then event issue time, lifecycle version, and event ID. Backend return order does not -change the result. +change the result. The backend snapshot and each observation are closed runtime +objects; an unknown backend status, accessor, sparse array, or extra backend key +returns `indeterminate`. Observations issued or ingressed after the requested +evaluation time are excluded as future evidence. - An exact replay is idempotent. - A different event at the same control/lifecycle version is rejected. @@ -106,7 +111,10 @@ The result is `allow`, `hold`, or `indeterminate`, always with `enforced: false`. `hold` is an observation for an applicable active freeze, not an enforcement action. Malformed candidates, unsupported versions, invalid evaluator input, or backend failure return `indeterminate` without inventing a -global hold. Independent safety containment remains outside this contract. +global hold. If malformed evidence is mixed with a known active control, the +result remains `indeterminate` while retaining that control ID as diagnostic +state; uncertainty is not disguised as a definitive hold. Independent safety +containment remains outside this contract. Literal or malformed `FREEZE`, `UNFREEZE`, and `BLOCKED` text is never read by the evaluator. The legacy compatibility vectors are in diff --git a/src/lib/control-contract.test.ts b/src/lib/control-contract.test.ts index d14ba9e..202b0dd 100644 --- a/src/lib/control-contract.test.ts +++ b/src/lib/control-contract.test.ts @@ -138,7 +138,7 @@ describe("hasna.control/v1 canonical contract", () => { const operations = [...event.affected_operations] as string[] & { extra?: string }; operations.extra = "not-json-array-data"; expect(invalidCode(metadata({ ...event, affected_operations: operations }))).toBe( - "invalid_sorted_unique_array", + "malformed_control_metadata", ); }); @@ -169,6 +169,56 @@ describe("hasna.control/v1 canonical contract", () => { }); }); + test("snapshots proxy-backed event and trusted-envelope fields before hashing or use", () => { + const event = createControlEventV1(freezePayload()); + const alternateControlId = "223e4567-e89b-42d3-a456-426614174000"; + let eventPropertyReads = 0; + let eventDescriptorReads = 0; + const unstableEvent = new Proxy(event, { + getOwnPropertyDescriptor(target, property) { + const descriptor = Reflect.getOwnPropertyDescriptor(target, property); + if (property !== "control_id" || !descriptor || !("value" in descriptor)) return descriptor; + eventDescriptorReads += 1; + return { + ...descriptor, + value: eventDescriptorReads === 1 ? target.control_id : alternateControlId, + }; + }, + get(target, property, receiver) { + if (property === "control_id") { + eventPropertyReads += 1; + return eventPropertyReads <= 2 ? target.control_id : alternateControlId; + } + return Reflect.get(target, property, receiver); + }, + }); + + let trustedPropertyReads = 0; + const trusted = trustedEnvelope(); + const unstableTrusted = new Proxy(trusted, { + get(target, property, receiver) { + if (property === "server_time") { + trustedPropertyReads += 1; + return trustedPropertyReads <= 2 ? target.server_time : "2026-07-22T00:00:00.000Z"; + } + return Reflect.get(target, property, receiver); + }, + }); + + const result = validateControlMetadataV1( + metadata(unstableEvent), + context(unstableTrusted), + ); + expect(result.status).toBe("valid"); + if (result.status !== "valid") throw new Error("expected valid snapshotted event"); + expect(result.event.control_id).toBe(CONTROL_ID); + expect(result.event.event_id).toBe(deriveControlEventId(freezePayload())); + expect(result.trusted_envelope).toEqual(trusted); + expect(eventPropertyReads).toBe(0); + expect(eventDescriptorReads).toBe(1); + expect(trustedPropertyReads).toBe(0); + }); + test("rejects unsupported versions, enums, UUIDs, fingerprints, and token grammars", () => { const event = createControlEventV1(freezePayload()); expect(invalidCode(metadata(withMutation(event, { version: "hasna.control/v2" })))).toBe( diff --git a/src/lib/control-contract.ts b/src/lib/control-contract.ts index e2ba7ea..a11deb5 100644 --- a/src/lib/control-contract.ts +++ b/src/lib/control-contract.ts @@ -101,6 +101,7 @@ export type ControlValidationResult = | { status: "valid"; event: ControlEventV1; + trusted_envelope: TrustedControlEnvelopeV1; canonical_event: string; canonical_payload: string; diagnostics: ControlValidationDiagnostic[]; @@ -153,6 +154,7 @@ const TRUSTED_ENVELOPE_KEYS = [ "server_time", "blocking", ] as const; +const VALIDATION_CONTEXT_KEYS = ["trusted_envelope", "activation_timestamp"] as const; const SECRET_PATTERNS = [ /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/i, @@ -168,6 +170,73 @@ function invalidCanonicalJson(): never { throw new TypeError("invalid canonical JSON value"); } +function snapshotCanonicalValue(value: unknown): unknown { + let nodeCount = 0; + const ancestors = new WeakSet(); + + function snapshot(node: unknown, depth: number): unknown { + nodeCount += 1; + if (depth > MAX_CANONICAL_DEPTH || nodeCount > MAX_CANONICAL_NODES) { + return invalidCanonicalJson(); + } + if (node === null || typeof node === "boolean" || typeof node === "string") return node; + if (typeof node === "number") { + if (!Number.isFinite(node)) return invalidCanonicalJson(); + return Object.is(node, -0) ? 0 : node; + } + if (typeof node !== "object") return invalidCanonicalJson(); + if (ancestors.has(node)) return invalidCanonicalJson(); + + const prototype = Object.getPrototypeOf(node); + const keys = Reflect.ownKeys(node); + if (keys.length > MAX_CANONICAL_NODES + 1 || keys.some((key) => typeof key === "symbol")) { + return invalidCanonicalJson(); + } + ancestors.add(node); + try { + if (Array.isArray(node)) { + if (prototype !== Array.prototype) return invalidCanonicalJson(); + const lengthDescriptor = Object.getOwnPropertyDescriptor(node, "length"); + if ( + !lengthDescriptor || + !("value" in lengthDescriptor) || + lengthDescriptor.enumerable || + !Number.isSafeInteger(lengthDescriptor.value) || + lengthDescriptor.value < 0 || + lengthDescriptor.value > MAX_CANONICAL_NODES + ) { + return invalidCanonicalJson(); + } + const length = lengthDescriptor.value as number; + if (keys.length !== length + 1 || !keys.includes("length")) return invalidCanonicalJson(); + + const result: unknown[] = []; + for (let index = 0; index < length; index++) { + const key = String(index); + if (!keys.includes(key)) return invalidCanonicalJson(); + const descriptor = Object.getOwnPropertyDescriptor(node, key); + if (!descriptor?.enumerable || !("value" in descriptor)) return invalidCanonicalJson(); + result.push(snapshot(descriptor.value, depth + 1)); + } + return result; + } + + if (prototype !== Object.prototype && prototype !== null) return invalidCanonicalJson(); + const result = Object.create(null) as Record; + for (const key of keys as string[]) { + const descriptor = Object.getOwnPropertyDescriptor(node, key); + if (!descriptor?.enumerable || !("value" in descriptor)) return invalidCanonicalJson(); + result[key] = snapshot(descriptor.value, depth + 1); + } + return result; + } finally { + ancestors.delete(node); + } + } + + return snapshot(value, 0); +} + function isPlainRecord(value: unknown): value is Record { if (value === null || typeof value !== "object" || Array.isArray(value)) return false; const prototype = Object.getPrototypeOf(value); @@ -207,6 +276,7 @@ function readArrayValues(value: unknown): unknown[] | null { } export function canonicalJson(value: unknown): string { + const stableValue = snapshotCanonicalValue(value); let nodeCount = 0; function visit(node: unknown, depth: number): string { @@ -234,7 +304,7 @@ export function canonicalJson(value: unknown): string { return `{${entries.join(",")}}`; } - const canonical = visit(value, 0); + const canonical = visit(stableValue, 0); if (Buffer.byteLength(canonical, "utf8") > MAX_CONTROL_EVENT_BYTES) return invalidCanonicalJson(); return canonical; } @@ -375,9 +445,10 @@ function eventPayload(event: ControlEventV1): ControlEventPayloadV1 { } function safePayload(value: ControlEventPayloadV1): ControlEventPayloadV1 { - const error = intrinsicPayloadError(value); + const stableValue = snapshotCanonicalValue(value); + const error = intrinsicPayloadError(stableValue); if (error) throw new TypeError(`invalid control event payload: ${error}`); - return value; + return stableValue as ControlEventPayloadV1; } export function deriveControlEventId(payload: ControlEventPayloadV1): string { @@ -401,21 +472,19 @@ function validateControlMetadataV1Unsafe( metadata: unknown, context: ControlValidationContextV1, ): ControlValidationResult { - if (!isPlainRecord(metadata)) { + if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) { return { status: "absent", diagnostics: [{ code: "no_control_metadata" }] }; } - if (!hasDataPropertiesOnly(metadata)) { - return Object.hasOwn(metadata, CONTROL_METADATA_KEY) - ? { status: "invalid", diagnostics: [{ code: "malformed_control_metadata" }] } - : { status: "absent", diagnostics: [{ code: "no_control_metadata" }] }; + const metadataPrototype = Object.getPrototypeOf(metadata); + if (metadataPrototype !== Object.prototype && metadataPrototype !== null) { + return { status: "absent", diagnostics: [{ code: "no_control_metadata" }] }; } - const descriptor = Object.getOwnPropertyDescriptor(metadata, CONTROL_METADATA_KEY); if (!descriptor) return { status: "absent", diagnostics: [{ code: "no_control_metadata" }] }; - if (!("value" in descriptor)) { + if (!descriptor?.enumerable || !("value" in descriptor)) { return { status: "invalid", diagnostics: [{ code: "malformed_control_metadata" }] }; } - const candidate = descriptor.value; + const candidate = snapshotCanonicalValue(descriptor.value); if (!isPlainRecord(candidate) || !hasDataPropertiesOnly(candidate)) { return { status: "invalid", diagnostics: [{ code: "malformed_control_metadata" }] }; } @@ -429,10 +498,14 @@ function validateControlMetadataV1Unsafe( return { status: "invalid", diagnostics: [{ code: "secret_shaped_value" }] }; } - if (!hasExactKeys(context.trusted_envelope, TRUSTED_ENVELOPE_KEYS)) { + const stableContext = snapshotCanonicalValue(context); + if (!hasExactKeys(stableContext, VALIDATION_CONTEXT_KEYS)) { return { status: "invalid", diagnostics: [{ code: "invalid_trusted_envelope" }] }; } - const trusted = context.trusted_envelope; + if (!hasExactKeys(stableContext.trusted_envelope, TRUSTED_ENVELOPE_KEYS)) { + return { status: "invalid", diagnostics: [{ code: "invalid_trusted_envelope" }] }; + } + const trusted = stableContext.trusted_envelope; if (containsSecretShapedValue(trusted)) { return { status: "invalid", diagnostics: [{ code: "secret_shaped_value" }] }; } @@ -441,7 +514,8 @@ function validateControlMetadataV1Unsafe( !isToken(trusted.tenant) || !isToken(trusted.authority_domain) || !isToken(trusted.policy_version) || - !CONTROL_SURFACES.includes(trusted.permitted_surface) || + typeof trusted.permitted_surface !== "string" || + !CONTROL_SURFACES.includes(trusted.permitted_surface as ControlSurfaceV1) || typeof trusted.blocking !== "boolean" || parseTimestamp(trusted.server_time) === null ) { @@ -455,7 +529,7 @@ function validateControlMetadataV1Unsafe( return { status: "invalid", diagnostics: [{ code: "invalid_event_id" }] }; } - const activationTime = parseTimestamp(context.activation_timestamp); + const activationTime = parseTimestamp(stableContext.activation_timestamp); const issuedAt = parseTimestamp(candidate.issued_at)!; const expiresAt = parseTimestamp(candidate.expires_at)!; const serverTime = parseTimestamp(trusted.server_time)!; @@ -516,9 +590,19 @@ function validateControlMetadataV1Unsafe( fingerprint: (candidate.unfreeze_of as ControlReferenceV1).fingerprint, }, }; + const trustedEnvelope: TrustedControlEnvelopeV1 = { + authenticated_principal: trusted.authenticated_principal as string, + tenant: trusted.tenant as string, + authority_domain: trusted.authority_domain as string, + permitted_surface: trusted.permitted_surface as ControlSurfaceV1, + policy_version: trusted.policy_version as string, + server_time: trusted.server_time as string, + blocking: trusted.blocking as boolean, + }; return { status: "valid", event, + trusted_envelope: trustedEnvelope, canonical_event: canonicalJson(event), canonical_payload: canonicalJson(payload), diagnostics: [], diff --git a/src/lib/control-evaluator.test.ts b/src/lib/control-evaluator.test.ts index 0e161fe..b5a76d1 100644 --- a/src/lib/control-evaluator.test.ts +++ b/src/lib/control-evaluator.test.ts @@ -266,6 +266,27 @@ describe("observe-only control evaluator", () => { expect(concurrentResult.diagnostics.map((item) => item.code)).toContain("stale_or_reordered_unfreeze"); }); + test("rejects a release observed after the referenced freeze expired", () => { + const freeze = createControlEventV1(freezePayload()); + const unfreeze = unfreezeFor(freeze); + const candidate = input([ + observation(freeze), + observation(unfreeze, { + trusted_envelope: trustedEnvelope(unfreeze, { + server_time: "2026-07-24T00:00:00.001Z", + }), + }), + ]); + candidate.config.evaluation_time = "2026-07-24T00:30:00.000Z"; + + const result = evaluateControlsV1(candidate); + expect(result.decision).toBe("indeterminate"); + expect(result.accepted_event_count).toBe(1); + expect(result.rejected_event_count).toBe(1); + expect(result.diagnostics.map((item) => item.code)).toContain("stale_or_reordered_unfreeze"); + expect(result.diagnostics.map((item) => item.code)).toContain("freeze_expired"); + }); + test("keeps overlapping controls independent and releases only the exact control", () => { const first = createControlEventV1( freezePayload({ scope: { kind: "project", ids: ["project:a", "project:b"] } }), @@ -352,6 +373,44 @@ describe("observe-only control evaluator", () => { active_control_ids: [], diagnostics: [{ code: "observation_limit_exceeded" }], }); + + const malformedStatus = input([observation(createControlEventV1(freezePayload()))]); + malformedStatus.backend = { + status: "degraded", + observations: (malformedStatus.backend as { status: "available"; observations: readonly ControlObservationV1[] }).observations, + } as unknown as ControlEvaluationInputV1["backend"]; + expect(evaluateControlsV1(malformedStatus)).toMatchObject({ + decision: "indeterminate", + enforced: false, + active_control_ids: [], + diagnostics: [{ code: "invalid_backend_snapshot" }], + }); + + const augmentedBackend = input([]) as ControlEvaluationInputV1 & { + backend: ControlEvaluationInputV1["backend"] & { extra?: boolean }; + }; + augmentedBackend.backend.extra = true; + expect(evaluateControlsV1(augmentedBackend)).toMatchObject({ + decision: "indeterminate", + enforced: false, + active_control_ids: [], + diagnostics: [{ code: "invalid_backend_snapshot" }], + }); + }); + + test("rejects observations later than the requested evaluation time", () => { + const freeze = createControlEventV1(freezePayload()); + const historical = input([observation(freeze)]); + historical.config.evaluation_time = "2026-07-22T12:00:00.000Z"; + + expect(evaluateControlsV1(historical)).toMatchObject({ + decision: "indeterminate", + enforced: false, + active_control_ids: [], + accepted_event_count: 0, + rejected_event_count: 1, + diagnostics: [{ code: "observation_from_future", event_id: freeze.event_id, control_id: freeze.control_id }], + }); }); test("supports rollback by switching observe-only evaluation off", () => { @@ -395,6 +454,24 @@ describe("observe-only control evaluator", () => { rejected_event_count: 1, }); + const freeze = createControlEventV1(freezePayload()); + const mixed = input([ + observation(freeze), + ...(malformed.backend as { status: "available"; observations: readonly ControlObservationV1[] }).observations, + ]); + expect(evaluateControlsV1(mixed)).toMatchObject({ + decision: "indeterminate", + enforced: false, + active_control_ids: [freeze.control_id], + accepted_event_count: 1, + rejected_event_count: 1, + }); + const mixedReverse = input([ + ...(malformed.backend as { status: "available"; observations: readonly ControlObservationV1[] }).observations, + observation(freeze), + ]); + expect(evaluateControlsV1(mixedReverse)).toEqual(evaluateControlsV1(mixed)); + const ambiguousTarget = input([]) as ControlEvaluationInputV1 & { target: ControlEvaluationInputV1["target"] & { global?: boolean }; }; diff --git a/src/lib/control-evaluator.ts b/src/lib/control-evaluator.ts index 711bf47..421188f 100644 --- a/src/lib/control-evaluator.ts +++ b/src/lib/control-evaluator.ts @@ -79,18 +79,60 @@ interface LifecycleState { const CONFIG_KEYS = ["mode", "validator_version", "activation_timestamp", "evaluation_time"] as const; const TARGET_KEYS = ["tenant", "authority_domain", "scope", "operation", "resource"] as const; +const INPUT_KEYS = ["config", "target", "backend"] as const; +const AVAILABLE_BACKEND_KEYS = ["status", "observations"] as const; +const UNAVAILABLE_BACKEND_KEYS = ["status"] as const; +const OBSERVATION_KEYS = ["content", "metadata", "trusted_envelope"] as const; -function hasExactDataKeys(value: unknown, expected: readonly string[]): value is Record { - if (value === null || typeof value !== "object" || Array.isArray(value)) return false; +function readDataRecord(value: unknown): Record | null { + if (value === null || typeof value !== "object" || Array.isArray(value)) return null; const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) return false; - if (Object.getOwnPropertySymbols(value).length > 0) return false; + if (prototype !== Object.prototype && prototype !== null) return null; + const keys = Reflect.ownKeys(value); + if (keys.length > 32 || keys.some((key) => typeof key === "symbol")) return null; + const result = Object.create(null) as Record; + for (const key of keys as string[]) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !("value" in descriptor)) return null; + result[key] = descriptor.value; + } + return result; +} + +function hasExactRecordKeys(value: Record, expected: readonly string[]): boolean { const keys = Object.keys(value); - if (keys.length !== expected.length || !expected.every((key) => Object.hasOwn(value, key))) return false; - return keys.every((key) => { + return keys.length === expected.length && expected.every((key) => Object.hasOwn(value, key)); +} + +function readObservationArray( + value: unknown, +): { status: "valid"; values: unknown[] } | { status: "too_many" } | { status: "invalid" } { + if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) return { status: "invalid" }; + const keys = Reflect.ownKeys(value); + if (keys.some((key) => typeof key === "symbol")) return { status: "invalid" }; + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length"); + if ( + !lengthDescriptor || + !("value" in lengthDescriptor) || + lengthDescriptor.enumerable || + !Number.isSafeInteger(lengthDescriptor.value) || + lengthDescriptor.value < 0 + ) { + return { status: "invalid" }; + } + const length = lengthDescriptor.value as number; + if (length > MAX_CONTROL_OBSERVATIONS) return { status: "too_many" }; + if (keys.length !== length + 1 || !keys.includes("length")) return { status: "invalid" }; + + const values: unknown[] = []; + for (let index = 0; index < length; index++) { + const key = String(index); + if (!keys.includes(key)) return { status: "invalid" }; const descriptor = Object.getOwnPropertyDescriptor(value, key); - return Boolean(descriptor?.enumerable && "value" in descriptor); - }); + if (!descriptor?.enumerable || !("value" in descriptor)) return { status: "invalid" }; + values.push(descriptor.value); + } + return { status: "valid", values }; } function result( @@ -101,6 +143,11 @@ function result( acceptedEventCount = 0, rejectedEventCount = 0, ): ControlEvaluationResultV1 { + const sortedDiagnostics = [...diagnostics].sort((left, right) => { + const leftKey = `${left.code}\u0000${left.event_id ?? ""}\u0000${left.control_id ?? ""}`; + const rightKey = `${right.code}\u0000${right.event_id ?? ""}\u0000${right.control_id ?? ""}`; + return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0; + }); return { decision, mode, @@ -108,7 +155,7 @@ function result( active_control_ids: [...activeControlIds].sort(), accepted_event_count: acceptedEventCount, rejected_event_count: rejectedEventCount, - diagnostics, + diagnostics: sortedDiagnostics, }; } @@ -182,57 +229,114 @@ function compareObservations(left: ValidObservation, right: ValidObservation): n } function evaluateControlsV1Unsafe(input: ControlEvaluationInputV1): ControlEvaluationResultV1 { - if (!hasExactDataKeys(input.config, CONFIG_KEYS)) { + const stableInput = readDataRecord(input); + if (!stableInput || !hasExactRecordKeys(stableInput, INPUT_KEYS)) { return result("indeterminate", "observe_only", [{ code: "invalid_evaluator_input" }]); } - if (input.config.mode === "off") { + const config = readDataRecord(stableInput.config); + if (!config || !hasExactRecordKeys(config, CONFIG_KEYS)) { + return result("indeterminate", "observe_only", [{ code: "invalid_evaluator_input" }]); + } + if (config.mode === "off") { return result("allow", "off", [{ code: "validator_disabled" }]); } - if (input.config.mode !== "observe_only") { + if (config.mode !== "observe_only") { return result("indeterminate", "observe_only", [{ code: "unsupported_evaluator_mode" }]); } - if (input.config.validator_version !== CONTROL_VALIDATOR_VERSION) { + if (config.validator_version !== CONTROL_VALIDATOR_VERSION) { return result("indeterminate", "observe_only", [{ code: "unsupported_validator_version" }]); } - if (!hasExactDataKeys(input.target, TARGET_KEYS)) { + const targetRecord = readDataRecord(stableInput.target); + if (!targetRecord || !hasExactRecordKeys(targetRecord, TARGET_KEYS)) { return result("indeterminate", "observe_only", [{ code: "invalid_evaluator_input" }]); } - const activationTime = controlTimestampMsV1(input.config.activation_timestamp); - const evaluationTime = controlTimestampMsV1(input.config.evaluation_time); - if (activationTime === null || evaluationTime === null || evaluationTime < activationTime || !isValidTarget(input.target)) { + const activationTime = controlTimestampMsV1(config.activation_timestamp); + const evaluationTime = controlTimestampMsV1(config.evaluation_time); + let stableScope: unknown; + try { + stableScope = JSON.parse(canonicalJson(targetRecord.scope)); + } catch { + return result("indeterminate", "observe_only", [{ code: "invalid_evaluator_input" }]); + } + const target: ControlEvaluationTargetV1 = { + tenant: targetRecord.tenant as string, + authority_domain: targetRecord.authority_domain as string, + scope: stableScope as ControlScopeV1, + operation: targetRecord.operation as string, + resource: targetRecord.resource as string, + }; + if (activationTime === null || evaluationTime === null || evaluationTime < activationTime || !isValidTarget(target)) { return result("indeterminate", "observe_only", [{ code: "invalid_evaluator_input" }]); } - if (input.backend.status === "unavailable") { + + const backend = readDataRecord(stableInput.backend); + if (!backend) { + return result("indeterminate", "observe_only", [{ code: "invalid_backend_snapshot" }]); + } + if (backend.status === "unavailable" && hasExactRecordKeys(backend, UNAVAILABLE_BACKEND_KEYS)) { return result("indeterminate", "observe_only", [{ code: "backend_unavailable" }]); } - if (input.backend.observations.length > MAX_CONTROL_OBSERVATIONS) { + if (backend.status !== "available" || !hasExactRecordKeys(backend, AVAILABLE_BACKEND_KEYS)) { + return result("indeterminate", "observe_only", [{ code: "invalid_backend_snapshot" }]); + } + const observationArray = readObservationArray(backend.observations); + if (observationArray.status === "too_many") { return result("indeterminate", "observe_only", [{ code: "observation_limit_exceeded" }]); } + if (observationArray.status === "invalid") { + return result("indeterminate", "observe_only", [{ code: "invalid_backend_snapshot" }]); + } const diagnostics: ControlEvaluationDiagnostic[] = []; const valid: ValidObservation[] = []; let rejectedEventCount = 0; + let uncertainObservationCount = 0; - for (const observation of input.backend.observations) { + for (const rawObservation of observationArray.values) { + const observation = readDataRecord(rawObservation); + if ( + !observation || + !hasExactRecordKeys(observation, OBSERVATION_KEYS) || + (observation.content !== null && typeof observation.content !== "string") + ) { + rejectedEventCount += 1; + uncertainObservationCount += 1; + diagnostics.push({ code: "invalid_observation" }); + continue; + } const validation = validateControlMetadataV1(observation.metadata, { - trusted_envelope: observation.trusted_envelope, - activation_timestamp: input.config.activation_timestamp, + trusted_envelope: observation.trusted_envelope as TrustedControlEnvelopeV1, + activation_timestamp: config.activation_timestamp as string, }); if (validation.status === "absent") { - if (observation.trusted_envelope.blocking === true) diagnostics.push({ code: "ordinary_blocker_ignored" }); + const trusted = readDataRecord(observation.trusted_envelope); + if (trusted?.blocking === true) diagnostics.push({ code: "ordinary_blocker_ignored" }); continue; } if (validation.status === "invalid") { rejectedEventCount += 1; + uncertainObservationCount += 1; diagnostics.push({ code: validation.diagnostics[0]?.code ?? "invalid_control_metadata" }); continue; } + const eventIssuedAt = controlTimestampMsV1(validation.event.issued_at)!; + const ingressAt = controlTimestampMsV1(validation.trusted_envelope.server_time)!; + if (eventIssuedAt > evaluationTime || ingressAt > evaluationTime) { + rejectedEventCount += 1; + uncertainObservationCount += 1; + diagnostics.push({ + code: "observation_from_future", + event_id: validation.event.event_id, + control_id: validation.event.control_id, + }); + continue; + } valid.push({ event: validation.event, canonical_event: validation.canonical_event, - trusted_envelope: observation.trusted_envelope, + trusted_envelope: validation.trusted_envelope, }); } @@ -302,7 +406,8 @@ function evaluateControlsV1Unsafe(input: ControlEvaluationInputV1): ControlEvalu if ( releaseIssuedAt <= freezeIssuedAt || releaseIngressAt <= freezeIngressAt || - releaseIssuedAt >= freezeExpiresAt + releaseIssuedAt >= freezeExpiresAt || + releaseIngressAt >= freezeExpiresAt ) { rejectedEventCount += 1; diagnostics.push({ code: "stale_or_reordered_unfreeze", event_id: event.event_id, control_id: event.control_id }); @@ -326,11 +431,23 @@ function evaluateControlsV1Unsafe(input: ControlEvaluationInputV1): ControlEvalu }); continue; } - if (appliesToTarget(lifecycle.freeze.event, input.target)) applicableControls.push(controlId); + if (appliesToTarget(lifecycle.freeze.event, target)) applicableControls.push(controlId); } + for (const controlId of [...applicableControls].sort()) { + diagnostics.push({ code: "active_control", control_id: controlId }); + } + if (uncertainObservationCount > 0) { + return result( + "indeterminate", + "observe_only", + diagnostics, + applicableControls, + acceptedEventCount, + rejectedEventCount, + ); + } if (applicableControls.length > 0) { - for (const controlId of [...applicableControls].sort()) diagnostics.push({ code: "active_control", control_id: controlId }); return result( "hold", "observe_only", From f0bf8a10d68b0380857ef1f3c85f019d8d472024 Mon Sep 17 00:00:00 2001 From: andreihasna Date: Thu, 23 Jul 2026 04:56:52 +0300 Subject: [PATCH 3/9] fix: isolate future control evidence --- docs/HASNA-CONTROL-V1-THREAT-MODEL.md | 2 +- docs/HASNA-CONTROL-V1.md | 4 +++- src/lib/control-evaluator.test.ts | 28 ++++++++++++++++++++++++++- src/lib/control-evaluator.ts | 5 +++-- 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/docs/HASNA-CONTROL-V1-THREAT-MODEL.md b/docs/HASNA-CONTROL-V1-THREAT-MODEL.md index cac3d90..7d15cf1 100644 --- a/docs/HASNA-CONTROL-V1-THREAT-MODEL.md +++ b/docs/HASNA-CONTROL-V1-THREAT-MODEL.md @@ -45,7 +45,7 @@ Data flow: | T8 | Backend/version failure | D/E | Failure, malformed status, or unknown version invents a global hold. | M×H | Require a closed backend discriminant and bounded dense observation array; return `indeterminate` and `enforced: false` with no fallback hold. | Unavailable, malformed-status, over-limit, and unsupported-version tests. | | T9 | Secret-bearing input | I | Credential-like material is echoed through an exception, diagnostic, or log. | M×H | Detect high-signal secret shapes before semantic diagnostics; return code only; no logging or rejected values. | Event/envelope secret-shape and non-echo tests. | | T10 | Hostile JS values | D/I | Getters, time-varying proxies, sparse arrays, or non-finite values execute code, crash evaluation, or split the hashed identity from the returned event. | M×M | Copy descriptor values exactly once into bounded plain snapshots before validation/hashing; reject accessors and malformed containers; contain exceptions. | Accessor, proxy TOCTOU, sparse-array, and mutation-after-validation tests. | -| T11 | TTL/order manipulation | T/D | Future, expired, indefinite, late-release, or reordered events persist or resurrect holds. | M×H | Canonical timestamps, seven-day maximum TTL, ingress and evaluation-time future checks, release ingress before freeze expiry, strict ordering, automatic freeze expiry. | TTL boundary, future/expired ingress, historical evaluation, late release, stale release, and expiry tests. | +| T11 | TTL/order manipulation | T/D | Future, expired, indefinite, late-release, or reordered events persist, suppress, or resurrect holds. | M×H | Canonical timestamps, seven-day maximum TTL, ingress checks, decision-neutral exclusion after evaluation time, release ingress before freeze expiry, strict ordering, and automatic freeze expiry. | TTL boundary, future/expired ingress, active-freeze historical evaluation, late release, stale release, and expiry tests. | | T12 | Evaluator → future hook | E | An observe-only result blocks a real tool or replaces independent safety containment. | M×H | Result always carries `enforced: false`; only `off` and `observe_only` modes exist; docs require a separate reviewed activation. | Hold-result and rollback tests; integration follow-up gate. | Assumptions and open questions: diff --git a/docs/HASNA-CONTROL-V1.md b/docs/HASNA-CONTROL-V1.md index 55c2a2d..b1f68bc 100644 --- a/docs/HASNA-CONTROL-V1.md +++ b/docs/HASNA-CONTROL-V1.md @@ -92,7 +92,9 @@ event issue time, lifecycle version, and event ID. Backend return order does not change the result. The backend snapshot and each observation are closed runtime objects; an unknown backend status, accessor, sparse array, or extra backend key returns `indeterminate`. Observations issued or ingressed after the requested -evaluation time are excluded as future evidence. +evaluation time are excluded as future evidence. They remain counted and +diagnosed as rejected observations for audit, but cannot change the historical +`allow` or `hold` decision. - An exact replay is idempotent. - A different event at the same control/lifecycle version is rejected. diff --git a/src/lib/control-evaluator.test.ts b/src/lib/control-evaluator.test.ts index b5a76d1..4a042dd 100644 --- a/src/lib/control-evaluator.test.ts +++ b/src/lib/control-evaluator.test.ts @@ -404,13 +404,39 @@ describe("observe-only control evaluator", () => { historical.config.evaluation_time = "2026-07-22T12:00:00.000Z"; expect(evaluateControlsV1(historical)).toMatchObject({ - decision: "indeterminate", + decision: "allow", enforced: false, active_control_ids: [], accepted_event_count: 0, rejected_event_count: 1, diagnostics: [{ code: "observation_from_future", event_id: freeze.event_id, control_id: freeze.control_id }], }); + + const futureUnfreeze = unfreezeFor(freeze, { + issued_at: "2026-07-23T13:00:00.000Z", + expires_at: "2026-07-24T13:00:00.000Z", + }); + const historicalActive = input([ + observation(freeze), + observation(futureUnfreeze, { + trusted_envelope: trustedEnvelope(futureUnfreeze, { + server_time: "2026-07-23T13:00:01.000Z", + }), + }), + ]); + const activeResult = evaluateControlsV1(historicalActive); + expect(activeResult).toMatchObject({ + decision: "hold", + enforced: false, + active_control_ids: [freeze.control_id], + accepted_event_count: 1, + rejected_event_count: 1, + }); + expect(activeResult.diagnostics).toContainEqual({ + code: "observation_from_future", + event_id: futureUnfreeze.event_id, + control_id: freeze.control_id, + }); }); test("supports rollback by switching observe-only evaluation off", () => { diff --git a/src/lib/control-evaluator.ts b/src/lib/control-evaluator.ts index 421188f..228a8ea 100644 --- a/src/lib/control-evaluator.ts +++ b/src/lib/control-evaluator.ts @@ -293,6 +293,7 @@ function evaluateControlsV1Unsafe(input: ControlEvaluationInputV1): ControlEvalu const valid: ValidObservation[] = []; let rejectedEventCount = 0; let uncertainObservationCount = 0; + let excludedFutureObservationCount = 0; for (const rawObservation of observationArray.values) { const observation = readDataRecord(rawObservation); @@ -325,7 +326,7 @@ function evaluateControlsV1Unsafe(input: ControlEvaluationInputV1): ControlEvalu const ingressAt = controlTimestampMsV1(validation.trusted_envelope.server_time)!; if (eventIssuedAt > evaluationTime || ingressAt > evaluationTime) { rejectedEventCount += 1; - uncertainObservationCount += 1; + excludedFutureObservationCount += 1; diagnostics.push({ code: "observation_from_future", event_id: validation.event.event_id, @@ -457,7 +458,7 @@ function evaluateControlsV1Unsafe(input: ControlEvaluationInputV1): ControlEvalu rejectedEventCount, ); } - if (rejectedEventCount > 0) { + if (rejectedEventCount > excludedFutureObservationCount) { return result( "indeterminate", "observe_only", From c0d66d72a5fc44319f41daaaff2965867315de30 Mon Sep 17 00:00:00 2001 From: andreihasna Date: Thu, 23 Jul 2026 05:09:27 +0300 Subject: [PATCH 4/9] fix: bound hostile control inputs --- CHANGELOG.md | 2 +- docs/HASNA-CONTROL-V1-THREAT-MODEL.md | 2 +- docs/HASNA-CONTROL-V1.md | 12 +- src/lib/control-contract.test.ts | 97 +++++++++------- src/lib/control-contract.ts | 153 +++++++++++++++----------- src/lib/control-evaluator.test.ts | 29 +++++ src/lib/control-evaluator.ts | 28 +++-- 7 files changed, 206 insertions(+), 117 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 401a5ff..9381fd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. ## Unreleased ### Added -- Added the repository-internal, observe-only `hasna.control/v1` metadata validator/evaluator, portable conformance and legacy-blocker fixtures, and a test-linked threat model. Runtime inputs are descriptor-snapshotted against proxy TOCTOU, historical evaluation excludes future observations, and malformed backend or mixed evidence remains indeterminate. This slice adds no schema, ingress, live hook, or enforcement. +- Added the repository-internal, observe-only `hasna.control/v1` metadata validator/evaluator, portable conformance and legacy-blocker fixtures, and a test-linked threat model. Proxy-backed inputs are rejected before traps, ordinary inputs are descriptor-snapshotted within pre-enumeration and pre-scan bounds, historical evaluation excludes future observations, and malformed backend or mixed evidence remains indeterminate. This slice adds no schema, ingress, live hook, or enforcement. - **Self-hosted HTTP API surface (`conversations-serve`)**: a pure-remote (Amendment A1) service that reads/writes the app's cloud Postgres directly via the vendored `@hasna/contracts` storage kit. Exposes `GET /health`, `/ready`, `/version` (`{status,version,mode}`) and a versioned `/v1` API (messages, channels, projects, agent presence) guarded by `@hasna/contracts` API-key auth (`conversations:read` / `conversations:write` scopes). `GET /v1/openapi.json` serves the OpenAPI document. - **Generated typed SDK** under the `@hasna/conversations/sdk` export, generated from the serve OpenAPI (`bun run sdk:generate`). - **Migration runner** (`src/server/migrate.ts`) that applies the app schema + `api_keys` table via the owner role (idempotent; never clobbers data). diff --git a/docs/HASNA-CONTROL-V1-THREAT-MODEL.md b/docs/HASNA-CONTROL-V1-THREAT-MODEL.md index 7d15cf1..b25235c 100644 --- a/docs/HASNA-CONTROL-V1-THREAT-MODEL.md +++ b/docs/HASNA-CONTROL-V1-THREAT-MODEL.md @@ -44,7 +44,7 @@ Data flow: | T7 | Scope ambiguity | E/D | Missing scope or wildcard is treated as global, holding unrelated work. | M×H | Closed scope enum, non-empty sorted unique IDs, no wildcard/global kind, exact operation/resource tokens. | Empty/unsorted/duplicate scope matrix and unrelated-target tests. | | T8 | Backend/version failure | D/E | Failure, malformed status, or unknown version invents a global hold. | M×H | Require a closed backend discriminant and bounded dense observation array; return `indeterminate` and `enforced: false` with no fallback hold. | Unavailable, malformed-status, over-limit, and unsupported-version tests. | | T9 | Secret-bearing input | I | Credential-like material is echoed through an exception, diagnostic, or log. | M×H | Detect high-signal secret shapes before semantic diagnostics; return code only; no logging or rejected values. | Event/envelope secret-shape and non-echo tests. | -| T10 | Hostile JS values | D/I | Getters, time-varying proxies, sparse arrays, or non-finite values execute code, crash evaluation, or split the hashed identity from the returned event. | M×M | Copy descriptor values exactly once into bounded plain snapshots before validation/hashing; reject accessors and malformed containers; contain exceptions. | Accessor, proxy TOCTOU, sparse-array, and mutation-after-validation tests. | +| T10 | Hostile JS values | D/I | Getters, proxies, oversized containers or strings, sparse arrays, or non-finite values execute code, exhaust resources, crash evaluation, or split the hashed identity from the returned event. | M×M | Reject proxies before invoking traps; check array/string bounds before enumeration or scanning; copy ordinary data descriptors into bounded plain snapshots; reject accessors and malformed containers; contain exceptions. | Accessor/proxy rejection, pre-enumeration/pre-scan bounds, sparse-array, and mutation-after-validation tests. | | T11 | TTL/order manipulation | T/D | Future, expired, indefinite, late-release, or reordered events persist, suppress, or resurrect holds. | M×H | Canonical timestamps, seven-day maximum TTL, ingress checks, decision-neutral exclusion after evaluation time, release ingress before freeze expiry, strict ordering, and automatic freeze expiry. | TTL boundary, future/expired ingress, active-freeze historical evaluation, late release, stale release, and expiry tests. | | T12 | Evaluator → future hook | E | An observe-only result blocks a real tool or replaces independent safety containment. | M×H | Result always carries `enforced: false`; only `off` and `observe_only` modes exist; docs require a separate reviewed activation. | Hold-result and rollback tests; integration follow-up gate. | diff --git a/docs/HASNA-CONTROL-V1.md b/docs/HASNA-CONTROL-V1.md index b1f68bc..9f8cf8c 100644 --- a/docs/HASNA-CONTROL-V1.md +++ b/docs/HASNA-CONTROL-V1.md @@ -39,10 +39,14 @@ in canonical lexical order; the validator never silently normalizes authority. Canonical JSON recursively sorts object keys, preserves validated array order, normalizes negative zero to zero, and rejects non-finite numbers, sparse or -augmented arrays, accessors, non-plain objects, excessive nesting, and values -larger than the contract bound. Proxy-backed input is copied from data-property -descriptors into a plain snapshot before validation or hashing, so later reads -cannot change the identity or trusted time. The event ID is: +JSON-augmented arrays, accessors, proxy-backed or non-plain objects, excessive +nesting, and values larger than the contract bound. Container length and string +length are checked before key enumeration or secret-shape scanning. Accepted +ordinary input is copied from data-property descriptors into a plain snapshot +before validation or hashing, so later mutation cannot change identity or +trusted time. Exact-key checks cover enumerable own string keys—the only keys +that can cross the JSON metadata carrier; symbols and non-enumerable properties +are ignored and never influence validation or hashing. The event ID is: ```text sha256(utf8(canonical_json(event_without_event_id))) diff --git a/src/lib/control-contract.test.ts b/src/lib/control-contract.test.ts index 202b0dd..d1c9c39 100644 --- a/src/lib/control-contract.test.ts +++ b/src/lib/control-contract.test.ts @@ -95,6 +95,28 @@ describe("hasna.control/v1 canonical contract", () => { expect(() => canonicalJson(sparse)).toThrow("invalid canonical JSON value"); }); + test("rejects hostile containers before unbounded key enumeration", () => { + let ownKeysCalled = false; + const oversizedArray = new Proxy(new Array(1_025), { + ownKeys() { + ownKeysCalled = true; + throw new Error("must not enumerate an over-limit container"); + }, + }); + + expect(() => canonicalJson(oversizedArray)).toThrow("invalid canonical JSON value"); + expect(ownKeysCalled).toBe(false); + + const hostileRecord = new Proxy({}, { + ownKeys() { + ownKeysCalled = true; + throw new Error("must not enumerate a proxy-backed record"); + }, + }); + expect(() => canonicalJson(hostileRecord)).toThrow("invalid canonical JSON value"); + expect(ownKeysCalled).toBe(false); + }); + test("derives a stable event id from the canonical payload and verifies it", () => { const payload = freezePayload(); const event = createControlEventV1(payload); @@ -158,8 +180,10 @@ describe("hasna.control/v1 canonical contract", () => { }); expect(accessed).toBe(false); + let hostileTrapCalls = 0; const hostile = new Proxy({}, { getPrototypeOf() { + hostileTrapCalls += 1; throw new Error("hostile trap"); }, }); @@ -167,56 +191,42 @@ describe("hasna.control/v1 canonical contract", () => { status: "invalid", diagnostics: [{ code: "malformed_control_metadata" }], }); + expect(hostileTrapCalls).toBe(0); }); - test("snapshots proxy-backed event and trusted-envelope fields before hashing or use", () => { + test("rejects proxy-backed event and trusted-envelope records without invoking traps", () => { const event = createControlEventV1(freezePayload()); - const alternateControlId = "223e4567-e89b-42d3-a456-426614174000"; - let eventPropertyReads = 0; - let eventDescriptorReads = 0; - const unstableEvent = new Proxy(event, { - getOwnPropertyDescriptor(target, property) { - const descriptor = Reflect.getOwnPropertyDescriptor(target, property); - if (property !== "control_id" || !descriptor || !("value" in descriptor)) return descriptor; - eventDescriptorReads += 1; - return { - ...descriptor, - value: eventDescriptorReads === 1 ? target.control_id : alternateControlId, - }; + let trapCalls = 0; + const hostileHandler: ProxyHandler = { + getPrototypeOf() { + trapCalls += 1; + throw new Error("must reject before prototype access"); }, - get(target, property, receiver) { - if (property === "control_id") { - eventPropertyReads += 1; - return eventPropertyReads <= 2 ? target.control_id : alternateControlId; - } - return Reflect.get(target, property, receiver); + ownKeys() { + trapCalls += 1; + throw new Error("must reject before key enumeration"); }, - }); - - let trustedPropertyReads = 0; - const trusted = trustedEnvelope(); - const unstableTrusted = new Proxy(trusted, { - get(target, property, receiver) { - if (property === "server_time") { - trustedPropertyReads += 1; - return trustedPropertyReads <= 2 ? target.server_time : "2026-07-22T00:00:00.000Z"; - } - return Reflect.get(target, property, receiver); + getOwnPropertyDescriptor() { + trapCalls += 1; + throw new Error("must reject before descriptor access"); }, + }; + + const proxiedEvent = new Proxy(event, hostileHandler as ProxyHandler); + expect(validateControlMetadataV1(metadata(proxiedEvent), context())).toEqual({ + status: "invalid", + diagnostics: [{ code: "malformed_control_metadata" }], }); - const result = validateControlMetadataV1( - metadata(unstableEvent), - context(unstableTrusted), + const proxiedTrusted = new Proxy( + trustedEnvelope(), + hostileHandler as ProxyHandler, ); - expect(result.status).toBe("valid"); - if (result.status !== "valid") throw new Error("expected valid snapshotted event"); - expect(result.event.control_id).toBe(CONTROL_ID); - expect(result.event.event_id).toBe(deriveControlEventId(freezePayload())); - expect(result.trusted_envelope).toEqual(trusted); - expect(eventPropertyReads).toBe(0); - expect(eventDescriptorReads).toBe(1); - expect(trustedPropertyReads).toBe(0); + expect(validateControlMetadataV1(controlMetadataV1(event), context(proxiedTrusted))).toEqual({ + status: "invalid", + diagnostics: [{ code: "malformed_control_metadata" }], + }); + expect(trapCalls).toBe(0); }); test("rejects unsupported versions, enums, UUIDs, fingerprints, and token grammars", () => { @@ -399,5 +409,10 @@ describe("hasna.control/v1 canonical contract", () => { ); expect(trustedResult).toEqual({ status: "invalid", diagnostics: [{ code: "secret_shaped_value" }] }); expect(JSON.stringify(trustedResult)).not.toContain(secretLike); + + const oversizedSecretLike = `${"a".repeat(1_024)} ${secretLike}`; + expect( + invalidCode(metadata(withMutation(event, { publisher: oversizedSecretLike }))), + ).toBe("invalid_field"); }); }); diff --git a/src/lib/control-contract.ts b/src/lib/control-contract.ts index a11deb5..f45a3b8 100644 --- a/src/lib/control-contract.ts +++ b/src/lib/control-contract.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { types as utilTypes } from "node:util"; export const CONTROL_CONTRACT_VERSION = "hasna.control/v1" as const; export const CONTROL_METADATA_KEY = "hasna.control" as const; @@ -170,6 +171,24 @@ function invalidCanonicalJson(): never { throw new TypeError("invalid canonical JSON value"); } +interface DataEntry { + key: string; + value: unknown; +} + +function readBoundedOwnDataEntries(value: object, maxKeys: number): DataEntry[] | null { + if (utilTypes.isProxy(value) || !Number.isSafeInteger(maxKeys) || maxKeys < 0) return null; + const entries: DataEntry[] = []; + for (const key in value) { + if (!Object.hasOwn(value, key)) return null; + if (entries.length >= maxKeys) return null; + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !("value" in descriptor)) return null; + entries.push({ key, value: descriptor.value }); + } + return entries; +} + function snapshotCanonicalValue(value: unknown): unknown { let nodeCount = 0; const ancestors = new WeakSet(); @@ -179,54 +198,36 @@ function snapshotCanonicalValue(value: unknown): unknown { if (depth > MAX_CANONICAL_DEPTH || nodeCount > MAX_CANONICAL_NODES) { return invalidCanonicalJson(); } - if (node === null || typeof node === "boolean" || typeof node === "string") return node; + if (node === null || typeof node === "boolean") return node; + if (typeof node === "string") { + if (node.length > MAX_CONTROL_EVENT_BYTES) return invalidCanonicalJson(); + return node; + } if (typeof node === "number") { if (!Number.isFinite(node)) return invalidCanonicalJson(); return Object.is(node, -0) ? 0 : node; } if (typeof node !== "object") return invalidCanonicalJson(); if (ancestors.has(node)) return invalidCanonicalJson(); + if (utilTypes.isProxy(node)) return invalidCanonicalJson(); const prototype = Object.getPrototypeOf(node); - const keys = Reflect.ownKeys(node); - if (keys.length > MAX_CANONICAL_NODES + 1 || keys.some((key) => typeof key === "symbol")) { - return invalidCanonicalJson(); - } ancestors.add(node); try { if (Array.isArray(node)) { if (prototype !== Array.prototype) return invalidCanonicalJson(); - const lengthDescriptor = Object.getOwnPropertyDescriptor(node, "length"); - if ( - !lengthDescriptor || - !("value" in lengthDescriptor) || - lengthDescriptor.enumerable || - !Number.isSafeInteger(lengthDescriptor.value) || - lengthDescriptor.value < 0 || - lengthDescriptor.value > MAX_CANONICAL_NODES - ) { - return invalidCanonicalJson(); - } - const length = lengthDescriptor.value as number; - if (keys.length !== length + 1 || !keys.includes("length")) return invalidCanonicalJson(); - - const result: unknown[] = []; - for (let index = 0; index < length; index++) { - const key = String(index); - if (!keys.includes(key)) return invalidCanonicalJson(); - const descriptor = Object.getOwnPropertyDescriptor(node, key); - if (!descriptor?.enumerable || !("value" in descriptor)) return invalidCanonicalJson(); - result.push(snapshot(descriptor.value, depth + 1)); - } - return result; + const remainingNodes = MAX_CANONICAL_NODES - nodeCount; + const items = readArrayValues(node, remainingNodes); + if (!items) return invalidCanonicalJson(); + return items.map((item) => snapshot(item, depth + 1)); } if (prototype !== Object.prototype && prototype !== null) return invalidCanonicalJson(); + const entries = readBoundedOwnDataEntries(node, MAX_CANONICAL_NODES - nodeCount); + if (!entries) return invalidCanonicalJson(); const result = Object.create(null) as Record; - for (const key of keys as string[]) { - const descriptor = Object.getOwnPropertyDescriptor(node, key); - if (!descriptor?.enumerable || !("value" in descriptor)) return invalidCanonicalJson(); - result[key] = snapshot(descriptor.value, depth + 1); + for (const entry of entries) { + result[entry.key] = snapshot(entry.value, depth + 1); } return result; } finally { @@ -239,39 +240,57 @@ function snapshotCanonicalValue(value: unknown): unknown { function isPlainRecord(value: unknown): value is Record { if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + if (utilTypes.isProxy(value)) return false; const prototype = Object.getPrototypeOf(value); return prototype === Object.prototype || prototype === null; } function hasDataPropertiesOnly(value: Record): boolean { - const names = Object.getOwnPropertyNames(value); - if (Object.getOwnPropertySymbols(value).length > 0) return false; - return names.every((name) => { - const descriptor = Object.getOwnPropertyDescriptor(value, name); - return Boolean(descriptor?.enumerable && "value" in descriptor); - }); + return readBoundedOwnDataEntries(value, MAX_CANONICAL_NODES) !== null; } function hasExactKeys(value: unknown, expected: readonly string[]): value is Record { - if (!isPlainRecord(value) || !hasDataPropertiesOnly(value)) return false; - const keys = Object.keys(value); - return keys.length === expected.length && expected.every((key) => Object.hasOwn(value, key)); + if (!isPlainRecord(value)) return false; + const entries = readBoundedOwnDataEntries(value, expected.length); + if (!entries || entries.length !== expected.length) return false; + const keys = new Set(entries.map((entry) => entry.key)); + return expected.every((key) => keys.has(key)); } function compareCanonicalStrings(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; } -function readArrayValues(value: unknown): unknown[] | null { - if (!Array.isArray(value) || Object.getOwnPropertySymbols(value).length > 0) return null; - const names = Object.getOwnPropertyNames(value); - if (names.length !== value.length + 1 || !names.includes("length")) return null; +function readArrayValues(value: unknown, maxItems = MAX_CANONICAL_NODES): unknown[] | null { + if (!Array.isArray(value) || utilTypes.isProxy(value)) return null; + if (Object.getPrototypeOf(value) !== Array.prototype) return null; + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length"); + if ( + !lengthDescriptor || + !("value" in lengthDescriptor) || + lengthDescriptor.enumerable || + !Number.isSafeInteger(lengthDescriptor.value) || + lengthDescriptor.value < 0 || + lengthDescriptor.value > maxItems + ) { + return null; + } + const length = lengthDescriptor.value as number; const items: unknown[] = []; - for (let index = 0; index < value.length; index++) { + for (let index = 0; index < length; index++) { const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); if (!descriptor?.enumerable || !("value" in descriptor)) return null; items.push(descriptor.value); } + let enumerableCount = 0; + for (const key in value) { + if (!Object.hasOwn(value, key)) return null; + enumerableCount += 1; + if (enumerableCount > length) return null; + const index = Number(key); + if (!Number.isSafeInteger(index) || index < 0 || index >= length || String(index) !== key) return null; + } + if (enumerableCount !== length) return null; return items; } @@ -293,15 +312,18 @@ export function canonicalJson(value: unknown): string { return JSON.stringify(Object.is(node, -0) ? 0 : node); } if (Array.isArray(node)) { - const items = readArrayValues(node); - if (!items || items.length > MAX_CANONICAL_NODES) return invalidCanonicalJson(); + const items = readArrayValues(node, MAX_CANONICAL_NODES - nodeCount); + if (!items) return invalidCanonicalJson(); return `[${items.map((item) => visit(item, depth + 1)).join(",")}]`; } - if (!isPlainRecord(node) || !hasDataPropertiesOnly(node)) return invalidCanonicalJson(); - - const keys = Object.keys(node).sort(compareCanonicalStrings); - const entries = keys.map((key) => `${JSON.stringify(key)}:${visit(node[key], depth + 1)}`); - return `{${entries.join(",")}}`; + if (!isPlainRecord(node)) return invalidCanonicalJson(); + + const entries = readBoundedOwnDataEntries(node, MAX_CANONICAL_NODES - nodeCount); + if (!entries) return invalidCanonicalJson(); + entries.sort((left, right) => compareCanonicalStrings(left.key, right.key)); + return `{${entries + .map((entry) => `${JSON.stringify(entry.key)}:${visit(entry.value, depth + 1)}`) + .join(",")}}`; } const canonical = visit(stableValue, 0); @@ -311,13 +333,17 @@ export function canonicalJson(value: unknown): string { function containsSecretShapedValue(value: unknown, depth = 0): boolean { if (depth > MAX_CANONICAL_DEPTH) return false; - if (typeof value === "string") return SECRET_PATTERNS.some((pattern) => pattern.test(value)); + if (typeof value === "string") { + if (value.length > MAX_CONTROL_STRING_LENGTH) return false; + return SECRET_PATTERNS.some((pattern) => pattern.test(value)); + } if (Array.isArray(value)) { - const items = readArrayValues(value); + const items = readArrayValues(value, MAX_CONTROL_ARRAY_ITEMS); return items ? items.some((item) => containsSecretShapedValue(item, depth + 1)) : false; } - if (!isPlainRecord(value) || !hasDataPropertiesOnly(value)) return false; - return Object.values(value).some((item) => containsSecretShapedValue(item, depth + 1)); + if (!isPlainRecord(value)) return false; + const entries = readBoundedOwnDataEntries(value, MAX_CANONICAL_NODES); + return entries ? entries.some((entry) => containsSecretShapedValue(entry.value, depth + 1)) : false; } function isToken(value: unknown): value is string { @@ -329,15 +355,15 @@ export function isControlTokenV1(value: unknown): value is string { } function isEventId(value: unknown): value is string { - return typeof value === "string" && EVENT_ID_PATTERN.test(value); + return typeof value === "string" && value.length === 71 && EVENT_ID_PATTERN.test(value); } function isUuid(value: unknown): value is string { - return typeof value === "string" && UUID_PATTERN.test(value); + return typeof value === "string" && value.length === 36 && UUID_PATTERN.test(value); } function parseTimestamp(value: unknown): number | null { - if (typeof value !== "string" || !TIMESTAMP_PATTERN.test(value)) return null; + if (typeof value !== "string" || value.length !== 24 || !TIMESTAMP_PATTERN.test(value)) return null; const timestamp = Date.parse(value); if (!Number.isFinite(timestamp) || new Date(timestamp).toISOString() !== value) return null; return timestamp; @@ -348,8 +374,8 @@ export function controlTimestampMsV1(value: unknown): number | null { } function isSortedUniqueTokenArray(value: unknown): value is string[] { - const items = readArrayValues(value); - if (!items || items.length === 0 || items.length > MAX_CONTROL_ARRAY_ITEMS) return false; + const items = readArrayValues(value, MAX_CONTROL_ARRAY_ITEMS); + if (!items || items.length === 0) return false; let previous: string | undefined; for (const item of items) { if (!isToken(item)) return false; @@ -475,6 +501,9 @@ function validateControlMetadataV1Unsafe( if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) { return { status: "absent", diagnostics: [{ code: "no_control_metadata" }] }; } + if (utilTypes.isProxy(metadata)) { + return { status: "invalid", diagnostics: [{ code: "malformed_control_metadata" }] }; + } const metadataPrototype = Object.getPrototypeOf(metadata); if (metadataPrototype !== Object.prototype && metadataPrototype !== null) { return { status: "absent", diagnostics: [{ code: "no_control_metadata" }] }; diff --git a/src/lib/control-evaluator.test.ts b/src/lib/control-evaluator.test.ts index 4a042dd..1f3d451 100644 --- a/src/lib/control-evaluator.test.ts +++ b/src/lib/control-evaluator.test.ts @@ -374,6 +374,35 @@ describe("observe-only control evaluator", () => { diagnostics: [{ code: "observation_limit_exceeded" }], }); + let ownKeysCalled = false; + const oversizedObservations = new Proxy( + new Array(MAX_CONTROL_OBSERVATIONS + 1), + { + getPrototypeOf() { + ownKeysCalled = true; + throw new Error("must reject before prototype access"); + }, + ownKeys() { + ownKeysCalled = true; + throw new Error("must not enumerate an over-limit backend array"); + }, + }, + ); + const boundedBeforeEnumeration = input([]); + ( + boundedBeforeEnumeration.backend as { + status: "available"; + observations: readonly ControlObservationV1[]; + } + ).observations = oversizedObservations as readonly ControlObservationV1[]; + expect(evaluateControlsV1(boundedBeforeEnumeration)).toMatchObject({ + decision: "indeterminate", + enforced: false, + active_control_ids: [], + diagnostics: [{ code: "invalid_backend_snapshot" }], + }); + expect(ownKeysCalled).toBe(false); + const malformedStatus = input([observation(createControlEventV1(freezePayload()))]); malformedStatus.backend = { status: "degraded", diff --git a/src/lib/control-evaluator.ts b/src/lib/control-evaluator.ts index 228a8ea..6ff7236 100644 --- a/src/lib/control-evaluator.ts +++ b/src/lib/control-evaluator.ts @@ -9,6 +9,7 @@ import { type ControlScopeV1, type TrustedControlEnvelopeV1, } from "./control-contract.js"; +import { types as utilTypes } from "node:util"; export type ControlDecision = "allow" | "hold" | "indeterminate"; @@ -86,12 +87,15 @@ const OBSERVATION_KEYS = ["content", "metadata", "trusted_envelope"] as const; function readDataRecord(value: unknown): Record | null { if (value === null || typeof value !== "object" || Array.isArray(value)) return null; + if (utilTypes.isProxy(value)) return null; const prototype = Object.getPrototypeOf(value); if (prototype !== Object.prototype && prototype !== null) return null; - const keys = Reflect.ownKeys(value); - if (keys.length > 32 || keys.some((key) => typeof key === "symbol")) return null; const result = Object.create(null) as Record; - for (const key of keys as string[]) { + let keyCount = 0; + for (const key in value) { + if (!Object.hasOwn(value, key)) return null; + keyCount += 1; + if (keyCount > 32) return null; const descriptor = Object.getOwnPropertyDescriptor(value, key); if (!descriptor?.enumerable || !("value" in descriptor)) return null; result[key] = descriptor.value; @@ -107,9 +111,8 @@ function hasExactRecordKeys(value: Record, expected: readonly s function readObservationArray( value: unknown, ): { status: "valid"; values: unknown[] } | { status: "too_many" } | { status: "invalid" } { - if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) return { status: "invalid" }; - const keys = Reflect.ownKeys(value); - if (keys.some((key) => typeof key === "symbol")) return { status: "invalid" }; + if (!Array.isArray(value) || utilTypes.isProxy(value)) return { status: "invalid" }; + if (Object.getPrototypeOf(value) !== Array.prototype) return { status: "invalid" }; const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length"); if ( !lengthDescriptor || @@ -122,16 +125,25 @@ function readObservationArray( } const length = lengthDescriptor.value as number; if (length > MAX_CONTROL_OBSERVATIONS) return { status: "too_many" }; - if (keys.length !== length + 1 || !keys.includes("length")) return { status: "invalid" }; const values: unknown[] = []; for (let index = 0; index < length; index++) { const key = String(index); - if (!keys.includes(key)) return { status: "invalid" }; const descriptor = Object.getOwnPropertyDescriptor(value, key); if (!descriptor?.enumerable || !("value" in descriptor)) return { status: "invalid" }; values.push(descriptor.value); } + let enumerableCount = 0; + for (const key in value) { + if (!Object.hasOwn(value, key)) return { status: "invalid" }; + enumerableCount += 1; + if (enumerableCount > length) return { status: "invalid" }; + const index = Number(key); + if (!Number.isSafeInteger(index) || index < 0 || index >= length || String(index) !== key) { + return { status: "invalid" }; + } + } + if (enumerableCount !== length) return { status: "invalid" }; return { status: "valid", values }; } From 70047e5c11c7f2d96aaf5499e75700fc5b920763 Mon Sep 17 00:00:00 2001 From: andreihasna Date: Thu, 23 Jul 2026 05:12:36 +0300 Subject: [PATCH 5/9] test: pin control lifecycle identity --- docs/HASNA-CONTROL-V1-THREAT-MODEL.md | 2 +- src/lib/control-evaluator.test.ts | 31 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/HASNA-CONTROL-V1-THREAT-MODEL.md b/docs/HASNA-CONTROL-V1-THREAT-MODEL.md index b25235c..d8d2edb 100644 --- a/docs/HASNA-CONTROL-V1-THREAT-MODEL.md +++ b/docs/HASNA-CONTROL-V1-THREAT-MODEL.md @@ -39,7 +39,7 @@ Data flow: | T2 | Message content | E | Literal `[FREEZE]`, `UNFREEZE`, or `[BLOCKED]` text is interpreted as authority. | H×H | Evaluator never reads content; only the exact metadata key can be a candidate. | Legacy compatibility JSON vectors. | | T3 | Unfreeze lifecycle | T/E | A stale, late, wrong-scope, wrong-domain, wrong-ID, or wrong-fingerprint release clears a valid freeze. | H×H | Exact three-field reference, exact context/target equality, strict version and trusted-time ordering, and release ingress before freeze expiry; invalid release never mutates active state. | Bad-release matrix, post-expiry ingress, and concurrent/reversed-order tests. | | T4 | Cross-tenant state | E/I | Reused control IDs collide across tenants or authority domains. | M×H | Lifecycle key is tenant + authority domain + control ID; target matching repeats tenant/domain checks. | Identical-ID cross-tenant test. | -| T5 | Backend ordering/replay | T/R | Reverse ordering, duplicate delivery, or concurrent rows changes the lifecycle result. | H×M | Deterministic trusted ordering, canonical event hash, exact replay dedupe, and conflicting-version rejection. | Forward/reverse fixture equality, replay, duplicate, and concurrency tests. | +| T5 | Backend ordering/replay | T/R | Reverse ordering, duplicate delivery, concurrent rows, or reuse of one control ID for another scope changes the lifecycle result. | H×M | Deterministic trusted ordering, canonical event hash, exact replay dedupe, and conflicting-version or control-ID-reuse rejection. One control ID owns one lifecycle; rejected reuse makes the result indeterminate, never a definitive allow. | Forward/reverse fixture equality, replay, duplicate, control-ID reuse, and concurrency tests. | | T6 | Preseeded rows | E | Metadata inserted before activation becomes a control when the feature is enabled. | M×H | Event issue time and trusted ingress server time must both be at or after activation. | Pre-activation event and pre-activation ingress tests. | | T7 | Scope ambiguity | E/D | Missing scope or wildcard is treated as global, holding unrelated work. | M×H | Closed scope enum, non-empty sorted unique IDs, no wildcard/global kind, exact operation/resource tokens. | Empty/unsorted/duplicate scope matrix and unrelated-target tests. | | T8 | Backend/version failure | D/E | Failure, malformed status, or unknown version invents a global hold. | M×H | Require a closed backend discriminant and bounded dense observation array; return `indeterminate` and `enforced: false` with no fallback hold. | Unavailable, malformed-status, over-limit, and unsupported-version tests. | diff --git a/src/lib/control-evaluator.test.ts b/src/lib/control-evaluator.test.ts index 1f3d451..a999dc0 100644 --- a/src/lib/control-evaluator.test.ts +++ b/src/lib/control-evaluator.test.ts @@ -212,6 +212,37 @@ describe("observe-only control evaluator", () => { expect(result.diagnostics.map((item) => item.code)).toContain("conflicting_duplicate"); }); + test("treats control-id reuse across scopes as a conflicting lifecycle, never a sibling control", () => { + const first = createControlEventV1( + freezePayload({ scope: { kind: "project", ids: ["project:a"] } }), + ); + const reusedId = createControlEventV1( + freezePayload({ + fingerprint: `sha256:${"b".repeat(64)}`, + scope: { kind: "project", ids: ["project:b"] }, + issued_at: "2026-07-23T00:01:00.000Z", + expires_at: "2026-07-24T00:01:00.000Z", + }), + ); + const releaseFirst = unfreezeFor(first, { scope: first.scope }); + const result = evaluateControlsV1( + withTarget( + input([observation(first), observation(reusedId), observation(releaseFirst)]), + { scope: { kind: "project", ids: ["project:b"] } }, + ), + ); + + expect(result).toMatchObject({ + decision: "indeterminate", + enforced: false, + active_control_ids: [], + accepted_event_count: 2, + rejected_event_count: 1, + }); + expect(result.diagnostics.map((item) => item.code)).toContain("conflicting_duplicate"); + expect(result.diagnostics.map((item) => item.code)).toContain("control_released"); + }); + test("bad release references and mismatched context never clear the active freeze", () => { const freeze = createControlEventV1(freezePayload()); const wrongReference = unfreezeFor(freeze, { From b4a77d69a8e7b69cd2e482c3685e3be7958a2178 Mon Sep 17 00:00:00 2001 From: andreihasna Date: Thu, 23 Jul 2026 05:51:41 +0300 Subject: [PATCH 6/9] fix: classify future observations before payload validation --- CHANGELOG.md | 1 + docs/HASNA-CONTROL-V1-THREAT-MODEL.md | 4 +- docs/HASNA-CONTROL-V1.md | 11 +- src/lib/control-contract.ts | 96 +++++++++--- src/lib/control-evaluator.test.ts | 211 +++++++++++++++++++++++++- src/lib/control-evaluator.ts | 72 +++++++-- 6 files changed, 350 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9381fd5..c5c426f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to this project will be documented in this file. - Digest payloads include `digest_id`, `message_ids`, `next_cursor`, bounded snippets, and byte length metadata so agents can continue without replaying long channels. ### Changed +- Historical control evaluation now validates trusted ingress envelopes before touching untrusted payloads, so every valid future-ingress row is audit-only and cannot turn an earlier `allow` or `hold` into `indeterminate`. - `digest` is non-destructive by default. Use `--unread` to restrict to unread messages and `--mark-read --from ` when the returned digest should update read state. ## [0.3.0] - 2026-06-24 diff --git a/docs/HASNA-CONTROL-V1-THREAT-MODEL.md b/docs/HASNA-CONTROL-V1-THREAT-MODEL.md index d8d2edb..cf7a8cb 100644 --- a/docs/HASNA-CONTROL-V1-THREAT-MODEL.md +++ b/docs/HASNA-CONTROL-V1-THREAT-MODEL.md @@ -44,8 +44,8 @@ Data flow: | T7 | Scope ambiguity | E/D | Missing scope or wildcard is treated as global, holding unrelated work. | M×H | Closed scope enum, non-empty sorted unique IDs, no wildcard/global kind, exact operation/resource tokens. | Empty/unsorted/duplicate scope matrix and unrelated-target tests. | | T8 | Backend/version failure | D/E | Failure, malformed status, or unknown version invents a global hold. | M×H | Require a closed backend discriminant and bounded dense observation array; return `indeterminate` and `enforced: false` with no fallback hold. | Unavailable, malformed-status, over-limit, and unsupported-version tests. | | T9 | Secret-bearing input | I | Credential-like material is echoed through an exception, diagnostic, or log. | M×H | Detect high-signal secret shapes before semantic diagnostics; return code only; no logging or rejected values. | Event/envelope secret-shape and non-echo tests. | -| T10 | Hostile JS values | D/I | Getters, proxies, oversized containers or strings, sparse arrays, or non-finite values execute code, exhaust resources, crash evaluation, or split the hashed identity from the returned event. | M×M | Reject proxies before invoking traps; check array/string bounds before enumeration or scanning; copy ordinary data descriptors into bounded plain snapshots; reject accessors and malformed containers; contain exceptions. | Accessor/proxy rejection, pre-enumeration/pre-scan bounds, sparse-array, and mutation-after-validation tests. | -| T11 | TTL/order manipulation | T/D | Future, expired, indefinite, late-release, or reordered events persist, suppress, or resurrect holds. | M×H | Canonical timestamps, seven-day maximum TTL, ingress checks, decision-neutral exclusion after evaluation time, release ingress before freeze expiry, strict ordering, and automatic freeze expiry. | TTL boundary, future/expired ingress, active-freeze historical evaluation, late release, stale release, and expiry tests. | +| T10 | Hostile JS values | D/I | Getters, proxies, oversized containers or strings, sparse arrays, or non-finite values execute code, exhaust resources, crash evaluation, or split the hashed identity from the returned event. | M×M | Reject proxies before invoking traps; check array/string bounds before enumeration or scanning; copy ordinary data descriptors into bounded plain snapshots; reject accessors and malformed containers; contain exceptions. Validate and snapshot the trusted envelope before any untrusted payload traversal. | Accessor/proxy rejection, pre-enumeration/pre-scan bounds, future-payload non-traversal, sparse-array, and mutation-after-validation tests. | +| T11 | TTL/order manipulation | T/D | Future, expired, indefinite, late-release, or reordered events persist, suppress, or resurrect holds. | M×H | Canonical timestamps, seven-day maximum TTL, ingress checks, envelope-first decision-neutral exclusion after evaluation time, release ingress before freeze expiry, strict ordering, and automatic freeze expiry. Future rows contribute only a payload-independent audit rejection. | TTL boundary, valid and malformed future ingress, historical allow/active-freeze preservation, late release, stale release, and expiry tests. | | T12 | Evaluator → future hook | E | An observe-only result blocks a real tool or replaces independent safety containment. | M×H | Result always carries `enforced: false`; only `off` and `observe_only` modes exist; docs require a separate reviewed activation. | Hold-result and rollback tests; integration follow-up gate. | Assumptions and open questions: diff --git a/docs/HASNA-CONTROL-V1.md b/docs/HASNA-CONTROL-V1.md index 9f8cf8c..5a450ee 100644 --- a/docs/HASNA-CONTROL-V1.md +++ b/docs/HASNA-CONTROL-V1.md @@ -84,6 +84,13 @@ freeze requires `blocking: true`; an unfreeze requires `blocking: false`. Generic rows with `blocking=1` remain ordinary blockers and do not become controls. +For historical evaluation, the evaluator first validates and snapshots this +bounded envelope without consulting control metadata. A valid `server_time` +after `evaluation_time` classifies the row as audit-only +`observation_from_future`; the evaluator does not parse, scan, or derive +identifiers from that row's untrusted content or metadata. An invalid envelope +remains indeterminate evidence. Metadata cannot self-claim an ingress time. + The evaluator configuration also supplies an activation timestamp. Both the event issue time and trusted ingress time must be at or after activation, so a preseeded metadata row cannot become authoritative after the validator is @@ -98,7 +105,9 @@ objects; an unknown backend status, accessor, sparse array, or extra backend key returns `indeterminate`. Observations issued or ingressed after the requested evaluation time are excluded as future evidence. They remain counted and diagnosed as rejected observations for audit, but cannot change the historical -`allow` or `hold` decision. +`allow` or `hold` decision. Future-ingress diagnostics intentionally omit event +and control IDs because those values would require traversing decision-neutral, +untrusted metadata. - An exact replay is idempotent. - A different event at the same control/lifecycle version is rejected. diff --git a/src/lib/control-contract.ts b/src/lib/control-contract.ts index f45a3b8..0f346ed 100644 --- a/src/lib/control-contract.ts +++ b/src/lib/control-contract.ts @@ -108,6 +108,17 @@ export type ControlValidationResult = diagnostics: ControlValidationDiagnostic[]; }; +export type TrustedControlEnvelopeValidationResultV1 = + | { + status: "invalid"; + diagnostics: ControlValidationDiagnostic[]; + } + | { + status: "valid"; + trusted_envelope: TrustedControlEnvelopeV1; + diagnostics: ControlValidationDiagnostic[]; + }; + export interface ControlValidationContextV1 { trusted_envelope: TrustedControlEnvelopeV1; activation_timestamp: string; @@ -494,6 +505,61 @@ export function controlMetadataV1(event: ControlEventV1): Record; + for (const entry of entries) trusted[entry.key] = entry.value; + if (!hasExactKeys(trusted, TRUSTED_ENVELOPE_KEYS)) { + return { status: "invalid", diagnostics: [{ code: "invalid_trusted_envelope" }] }; + } + if (containsSecretShapedValue(trusted)) { + return { status: "invalid", diagnostics: [{ code: "secret_shaped_value" }] }; + } + if ( + !isToken(trusted.authenticated_principal) || + !isToken(trusted.tenant) || + !isToken(trusted.authority_domain) || + !isToken(trusted.policy_version) || + typeof trusted.permitted_surface !== "string" || + !CONTROL_SURFACES.includes(trusted.permitted_surface as ControlSurfaceV1) || + typeof trusted.blocking !== "boolean" || + parseTimestamp(trusted.server_time) === null + ) { + return { status: "invalid", diagnostics: [{ code: "invalid_trusted_envelope" }] }; + } + return { + status: "valid", + trusted_envelope: { + authenticated_principal: trusted.authenticated_principal, + tenant: trusted.tenant, + authority_domain: trusted.authority_domain, + permitted_surface: trusted.permitted_surface as ControlSurfaceV1, + policy_version: trusted.policy_version, + server_time: trusted.server_time as string, + blocking: trusted.blocking, + }, + diagnostics: [], + }; +} + +export function validateTrustedControlEnvelopeV1( + value: unknown, +): TrustedControlEnvelopeValidationResultV1 { + try { + return validateTrustedControlEnvelopeV1Unsafe(value); + } catch { + return { status: "invalid", diagnostics: [{ code: "invalid_trusted_envelope" }] }; + } +} + function validateControlMetadataV1Unsafe( metadata: unknown, context: ControlValidationContextV1, @@ -531,25 +597,11 @@ function validateControlMetadataV1Unsafe( if (!hasExactKeys(stableContext, VALIDATION_CONTEXT_KEYS)) { return { status: "invalid", diagnostics: [{ code: "invalid_trusted_envelope" }] }; } - if (!hasExactKeys(stableContext.trusted_envelope, TRUSTED_ENVELOPE_KEYS)) { - return { status: "invalid", diagnostics: [{ code: "invalid_trusted_envelope" }] }; - } - const trusted = stableContext.trusted_envelope; - if (containsSecretShapedValue(trusted)) { - return { status: "invalid", diagnostics: [{ code: "secret_shaped_value" }] }; - } - if ( - !isToken(trusted.authenticated_principal) || - !isToken(trusted.tenant) || - !isToken(trusted.authority_domain) || - !isToken(trusted.policy_version) || - typeof trusted.permitted_surface !== "string" || - !CONTROL_SURFACES.includes(trusted.permitted_surface as ControlSurfaceV1) || - typeof trusted.blocking !== "boolean" || - parseTimestamp(trusted.server_time) === null - ) { - return { status: "invalid", diagnostics: [{ code: "invalid_trusted_envelope" }] }; + const trustedValidation = validateTrustedControlEnvelopeV1(stableContext.trusted_envelope); + if (trustedValidation.status === "invalid") { + return trustedValidation; } + const trusted = trustedValidation.trusted_envelope; const payload = eventPayload(candidate as unknown as ControlEventV1); const intrinsicError = intrinsicPayloadError(payload); @@ -620,13 +672,7 @@ function validateControlMetadataV1Unsafe( }, }; const trustedEnvelope: TrustedControlEnvelopeV1 = { - authenticated_principal: trusted.authenticated_principal as string, - tenant: trusted.tenant as string, - authority_domain: trusted.authority_domain as string, - permitted_surface: trusted.permitted_surface as ControlSurfaceV1, - policy_version: trusted.policy_version as string, - server_time: trusted.server_time as string, - blocking: trusted.blocking as boolean, + ...trusted, }; return { status: "valid", diff --git a/src/lib/control-evaluator.test.ts b/src/lib/control-evaluator.test.ts index a999dc0..c2d043e 100644 --- a/src/lib/control-evaluator.test.ts +++ b/src/lib/control-evaluator.test.ts @@ -458,7 +458,208 @@ describe("observe-only control evaluator", () => { }); }); - test("rejects observations later than the requested evaluation time", () => { + test("keeps malformed future-only observations audit-only without changing historical allow", () => { + const event = createControlEventV1(freezePayload()); + const futureMalformed: ControlObservationV1 = { + content: "future malformed metadata", + metadata: { + [CONTROL_METADATA_KEY]: { + version: CONTROL_CONTRACT_VERSION, + server_time: "2026-07-22T00:00:00.000Z", + }, + }, + trusted_envelope: trustedEnvelope(event, { + server_time: "2026-07-23T13:00:00.000Z", + }), + }; + + expect(evaluateControlsV1(input([futureMalformed]))).toEqual({ + decision: "allow", + mode: "observe_only", + enforced: false, + active_control_ids: [], + accepted_event_count: 0, + rejected_event_count: 1, + diagnostics: [{ code: "observation_from_future" }], + }); + }); + + test("keeps an active historical freeze held when malformed future evidence exists", () => { + const freeze = createControlEventV1(freezePayload()); + const futureMalformed: ControlObservationV1 = { + content: null, + metadata: { [CONTROL_METADATA_KEY]: { version: CONTROL_CONTRACT_VERSION } }, + trusted_envelope: trustedEnvelope(freeze, { + server_time: "2026-07-23T13:00:00.000Z", + }), + }; + const result = evaluateControlsV1( + input([observation(freeze), futureMalformed]), + ); + + expect(result).toMatchObject({ + decision: "hold", + enforced: false, + active_control_ids: [freeze.control_id], + accepted_event_count: 1, + rejected_event_count: 1, + }); + expect(result.diagnostics).toContainEqual({ code: "observation_from_future" }); + expect(result.diagnostics).not.toContainEqual({ code: "unexpected_keys" }); + }); + + test("classifies future ingress before parsing hostile or secret-shaped payloads", () => { + const event = createControlEventV1(freezePayload()); + const futureEnvelope = trustedEnvelope(event, { + server_time: "2026-07-23T13:00:00.000Z", + }); + let proxyTrapCalls = 0; + const hostileProxy = new Proxy({}, { + getPrototypeOf() { + proxyTrapCalls += 1; + throw new Error("future payload must not be parsed"); + }, + ownKeys() { + proxyTrapCalls += 1; + throw new Error("future payload must not be enumerated"); + }, + getOwnPropertyDescriptor() { + proxyTrapCalls += 1; + throw new Error("future payload descriptors must not be read"); + }, + }); + let getterCalls = 0; + const accessorMetadata = {} as Record; + Object.defineProperty(accessorMetadata, CONTROL_METADATA_KEY, { + enumerable: true, + get() { + getterCalls += 1; + throw new Error("future payload getter must not run"); + }, + }); + const oversizedMetadata = Object.fromEntries( + Array.from({ length: 1_025 }, (_, index) => [`field_${index}`, index]), + ); + const secretLike = ["AK", "IA", "ABCDEFGHIJKLMNOP"].join(""); + const payloads: unknown[] = [ + hostileProxy, + accessorMetadata, + oversizedMetadata, + controlMetadataV1({ ...event, publisher: "a".repeat(129) }), + controlMetadataV1({ ...event, publisher: secretLike }), + controlMetadataV1({ ...event, server_time: "2026-07-22T00:00:00.000Z" } as unknown as ControlEventV1), + ]; + + let observationGetterCalls = 0; + const accessorObservation = { + content: null, + trusted_envelope: futureEnvelope, + } as unknown as ControlObservationV1; + Object.defineProperty(accessorObservation, "metadata", { + enumerable: true, + get() { + observationGetterCalls += 1; + throw new Error("future observation payload getter must not run"); + }, + }); + + const futureObservations = [ + ...payloads.map((metadata) => ({ content: null, metadata, trusted_envelope: futureEnvelope })), + accessorObservation, + ]; + for (const candidate of futureObservations) { + expect( + evaluateControlsV1(input([candidate])), + ).toEqual({ + decision: "allow", + mode: "observe_only", + enforced: false, + active_control_ids: [], + accepted_event_count: 0, + rejected_event_count: 1, + diagnostics: [{ code: "observation_from_future" }], + }); + } + expect(proxyTrapCalls).toBe(0); + expect(getterCalls).toBe(0); + expect(observationGetterCalls).toBe(0); + }); + + test("validates trusted envelopes safely before future classification", () => { + const event = createControlEventV1(freezePayload()); + const baseEnvelope = trustedEnvelope(event, { + server_time: "2026-07-23T13:00:00.000Z", + }); + let envelopeTrapCalls = 0; + const proxyEnvelope = new Proxy(baseEnvelope, { + getPrototypeOf() { + envelopeTrapCalls += 1; + throw new Error("trusted envelope proxy trap must not run"); + }, + ownKeys() { + envelopeTrapCalls += 1; + throw new Error("trusted envelope proxy must not be enumerated"); + }, + }); + let getterCalls = 0; + const getterEnvelope = { ...baseEnvelope } as Record; + Object.defineProperty(getterEnvelope, "server_time", { + enumerable: true, + get() { + getterCalls += 1; + throw new Error("trusted envelope getter must not run"); + }, + }); + const oversizedEnvelope = { + ...baseEnvelope, + ...Object.fromEntries( + Array.from({ length: 1_025 }, (_, index) => [`extra_${index}`, index]), + ), + }; + const secretLike = ["AK", "IA", "ABCDEFGHIJKLMNOP"].join(""); + const cases: Array<{ envelope: unknown; code: string }> = [ + { envelope: proxyEnvelope, code: "invalid_trusted_envelope" }, + { envelope: getterEnvelope, code: "invalid_trusted_envelope" }, + { envelope: oversizedEnvelope, code: "invalid_trusted_envelope" }, + { + envelope: { ...baseEnvelope, server_time: "2026-07-23T13:00:00Z" }, + code: "invalid_trusted_envelope", + }, + { + envelope: { ...baseEnvelope, authenticated_principal: "a".repeat(129) }, + code: "invalid_trusted_envelope", + }, + { + envelope: { ...baseEnvelope, authenticated_principal: secretLike }, + code: "secret_shaped_value", + }, + ]; + + for (const candidate of cases) { + const result = evaluateControlsV1( + input([ + { + content: null, + metadata: controlMetadataV1(event), + trusted_envelope: candidate.envelope as TrustedControlEnvelopeV1, + }, + ]), + ); + expect(result).toMatchObject({ + decision: "indeterminate", + enforced: false, + active_control_ids: [], + accepted_event_count: 0, + rejected_event_count: 1, + diagnostics: [{ code: candidate.code }], + }); + expect(JSON.stringify(result)).not.toContain(secretLike); + } + expect(envelopeTrapCalls).toBe(0); + expect(getterCalls).toBe(0); + }); + + test("keeps valid future rows decision-neutral", () => { const freeze = createControlEventV1(freezePayload()); const historical = input([observation(freeze)]); historical.config.evaluation_time = "2026-07-22T12:00:00.000Z"; @@ -469,7 +670,7 @@ describe("observe-only control evaluator", () => { active_control_ids: [], accepted_event_count: 0, rejected_event_count: 1, - diagnostics: [{ code: "observation_from_future", event_id: freeze.event_id, control_id: freeze.control_id }], + diagnostics: [{ code: "observation_from_future" }], }); const futureUnfreeze = unfreezeFor(freeze, { @@ -492,11 +693,7 @@ describe("observe-only control evaluator", () => { accepted_event_count: 1, rejected_event_count: 1, }); - expect(activeResult.diagnostics).toContainEqual({ - code: "observation_from_future", - event_id: futureUnfreeze.event_id, - control_id: freeze.control_id, - }); + expect(activeResult.diagnostics).toContainEqual({ code: "observation_from_future" }); }); test("supports rollback by switching observe-only evaluation off", () => { diff --git a/src/lib/control-evaluator.ts b/src/lib/control-evaluator.ts index 6ff7236..4155264 100644 --- a/src/lib/control-evaluator.ts +++ b/src/lib/control-evaluator.ts @@ -5,6 +5,7 @@ import { isControlScopeV1, isControlTokenV1, validateControlMetadataV1, + validateTrustedControlEnvelopeV1, type ControlEventV1, type ControlScopeV1, type TrustedControlEnvelopeV1, @@ -85,6 +86,33 @@ const AVAILABLE_BACKEND_KEYS = ["status", "observations"] as const; const UNAVAILABLE_BACKEND_KEYS = ["status"] as const; const OBSERVATION_KEYS = ["content", "metadata", "trusted_envelope"] as const; +function readTrustedEnvelopeFromObservation( + value: unknown, +): { status: "valid"; trusted_envelope: unknown } | { status: "invalid" } { + if (value === null || typeof value !== "object" || Array.isArray(value) || utilTypes.isProxy(value)) { + return { status: "invalid" }; + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return { status: "invalid" }; + + const keys: string[] = []; + for (const key in value) { + if (!Object.hasOwn(value, key) || keys.length >= OBSERVATION_KEYS.length) { + return { status: "invalid" }; + } + keys.push(key); + } + if ( + keys.length !== OBSERVATION_KEYS.length || + !OBSERVATION_KEYS.every((key) => keys.includes(key)) + ) { + return { status: "invalid" }; + } + const descriptor = Object.getOwnPropertyDescriptor(value, "trusted_envelope"); + if (!descriptor?.enumerable || !("value" in descriptor)) return { status: "invalid" }; + return { status: "valid", trusted_envelope: descriptor.value }; +} + function readDataRecord(value: unknown): Record | null { if (value === null || typeof value !== "object" || Array.isArray(value)) return null; if (utilTypes.isProxy(value)) return null; @@ -308,24 +336,49 @@ function evaluateControlsV1Unsafe(input: ControlEvaluationInputV1): ControlEvalu let excludedFutureObservationCount = 0; for (const rawObservation of observationArray.values) { + const observationEnvelope = readTrustedEnvelopeFromObservation(rawObservation); + if (observationEnvelope.status === "invalid") { + rejectedEventCount += 1; + uncertainObservationCount += 1; + diagnostics.push({ code: "invalid_observation" }); + continue; + } + + const trustedValidation = validateTrustedControlEnvelopeV1(observationEnvelope.trusted_envelope); + if (trustedValidation.status === "invalid") { + rejectedEventCount += 1; + uncertainObservationCount += 1; + diagnostics.push({ code: trustedValidation.diagnostics[0]?.code ?? "invalid_trusted_envelope" }); + continue; + } + const trustedEnvelope = trustedValidation.trusted_envelope; + const ingressAt = controlTimestampMsV1(trustedEnvelope.server_time)!; + if (ingressAt > evaluationTime) { + rejectedEventCount += 1; + excludedFutureObservationCount += 1; + diagnostics.push({ code: "observation_from_future" }); + continue; + } + const observation = readDataRecord(rawObservation); - if ( - !observation || - !hasExactRecordKeys(observation, OBSERVATION_KEYS) || - (observation.content !== null && typeof observation.content !== "string") - ) { + if (!observation || !hasExactRecordKeys(observation, OBSERVATION_KEYS)) { + rejectedEventCount += 1; + uncertainObservationCount += 1; + diagnostics.push({ code: "invalid_observation" }); + continue; + } + if (observation.content !== null && typeof observation.content !== "string") { rejectedEventCount += 1; uncertainObservationCount += 1; diagnostics.push({ code: "invalid_observation" }); continue; } const validation = validateControlMetadataV1(observation.metadata, { - trusted_envelope: observation.trusted_envelope as TrustedControlEnvelopeV1, + trusted_envelope: trustedEnvelope, activation_timestamp: config.activation_timestamp as string, }); if (validation.status === "absent") { - const trusted = readDataRecord(observation.trusted_envelope); - if (trusted?.blocking === true) diagnostics.push({ code: "ordinary_blocker_ignored" }); + if (trustedEnvelope.blocking) diagnostics.push({ code: "ordinary_blocker_ignored" }); continue; } if (validation.status === "invalid") { @@ -335,8 +388,7 @@ function evaluateControlsV1Unsafe(input: ControlEvaluationInputV1): ControlEvalu continue; } const eventIssuedAt = controlTimestampMsV1(validation.event.issued_at)!; - const ingressAt = controlTimestampMsV1(validation.trusted_envelope.server_time)!; - if (eventIssuedAt > evaluationTime || ingressAt > evaluationTime) { + if (eventIssuedAt > evaluationTime) { rejectedEventCount += 1; excludedFutureObservationCount += 1; diagnostics.push({ From 15b55ddf51f7203aad414b8b41496ec07e25ee8b Mon Sep 17 00:00:00 2001 From: andreihasna Date: Thu, 23 Jul 2026 12:11:25 +0300 Subject: [PATCH 7/9] fix: isolate MCP agent test storage --- src/lib/db.ts | 13 ++- src/lib/messages.ts | 9 +- src/lib/presence.ts | 42 +++++--- src/lib/sessions.ts | 8 +- .../tools/agents.storage-isolation.test.ts | 97 +++++++++++++++++++ src/mcp/tools/agents.test.ts | 50 ++++++---- src/mcp/tools/agents.ts | 57 +++++++---- 7 files changed, 213 insertions(+), 63 deletions(-) create mode 100644 src/mcp/tools/agents.storage-isolation.test.ts diff --git a/src/lib/db.ts b/src/lib/db.ts index bc4e69b..80c8d10 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -610,13 +610,10 @@ function dropMessagesFts(db: Database): void { safeExec(db, "DROP TABLE IF EXISTS messages_fts"); } -export function getDb(): Database { - if (db) return db; - - const dbPath = getDbPath(); +export function openDatabase(dbPath: string): Database { mkdirSync(dirname(dbPath), { recursive: true }); - db = new ConversationsDatabase(dbPath); + const db = new ConversationsDatabase(dbPath); db.exec("PRAGMA journal_mode = WAL"); db.exec("PRAGMA busy_timeout = 5000"); @@ -1081,6 +1078,12 @@ export function getDb(): Database { return db; } +export function getDb(): Database { + if (db) return db; + db = openDatabase(getDbPath()); + return db; +} + export function closeDb(): void { if (db) { db.close(); diff --git a/src/lib/messages.ts b/src/lib/messages.ts index 12150e9..3d98efc 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -1,4 +1,5 @@ import { getDb, getDataDir } from "./db.js"; +import type { Database } from "./db.js"; import type { Message, Attachment, SendMessageOptions, ReadMessagesOptions, SearchMessagesOptions, SearchResult } from "../types.js"; import { createHash, randomUUID } from "crypto"; import { mkdirSync, copyFileSync, statSync, existsSync, realpathSync } from "fs"; @@ -955,8 +956,12 @@ export function getPinnedMessages(opts?: { channel?: string; session_id?: string return rows.map(parseMessage); } -export function getUnreadBlockers(agent: string, opts?: { limit?: number; offset?: number }): Message[] { - const db = getDb(); +export function getUnreadBlockers( + agent: string, + opts?: { limit?: number; offset?: number }, + database: Database = getDb(), +): Message[] { + const db = database; const safeLimit = Number.isFinite(opts?.limit) && (opts!.limit as number) > 0 ? Math.floor(opts!.limit as number) : 0; diff --git a/src/lib/presence.ts b/src/lib/presence.ts index 39baf25..a247cb6 100644 --- a/src/lib/presence.ts +++ b/src/lib/presence.ts @@ -1,4 +1,5 @@ import { getDb } from "./db.js"; +import type { Database } from "./db.js"; import type { AgentPresence, AgentConflictError, RegisterAgentResult } from "../types.js"; const ONLINE_THRESHOLD_SECONDS = 60; @@ -83,9 +84,10 @@ export function registerAgent( name: string, sessionId: string, role?: string, - projectId?: string + projectId?: string, + database: Database = getDb(), ): RegisterAgentResult | AgentConflictError { - const db = getDb(); + const db = database; const normalizedName = normalizeAgentName(name); // BEGIN IMMEDIATE acquires write lock at start — eliminates TOCTOU race @@ -170,8 +172,9 @@ export function heartbeat( metadata?: Record, sessionId?: string, projectId?: string | null, + database: Database = getDb(), ): void { - const db = getDb(); + const db = database; const metadataJson = metadata ? JSON.stringify(metadata) : null; const resolvedStatus = status || "online"; const normalizedAgent = normalizeAgentName(agent); @@ -217,15 +220,18 @@ export function heartbeat( }); } -export function getPresence(agent: string): AgentPresence | null { - const db = getDb(); +export function getPresence(agent: string, database: Database = getDb()): AgentPresence | null { + const db = database; const normalizedAgent = normalizeAgentName(agent); const row = getPresenceByAgent(db, normalizedAgent); return row ? parsePresence(row) : null; } -export function listAgents(opts?: { online_only?: boolean }): AgentPresence[] { - const db = getDb(); +export function listAgents( + opts?: { online_only?: boolean }, + database: Database = getDb(), +): AgentPresence[] { + const db = database; let query = "SELECT * FROM agent_presence"; @@ -239,15 +245,19 @@ export function listAgents(opts?: { online_only?: boolean }): AgentPresence[] { return rows.map(parsePresence); } -export function removePresence(agent: string): boolean { - const db = getDb(); +export function removePresence(agent: string, database: Database = getDb()): boolean { + const db = database; const normalizedAgent = normalizeAgentName(agent); const result = db.prepare("DELETE FROM agent_presence WHERE LOWER(agent) = ?").run(normalizedAgent); return result.changes > 0; } -export function renameAgent(oldName: string, newName: string): boolean { - const db = getDb(); +export function renameAgent( + oldName: string, + newName: string, + database: Database = getDb(), +): boolean { + const db = database; const normalizedOld = normalizeAgentName(oldName); const normalizedNew = normalizeAgentName(newName); @@ -261,14 +271,18 @@ export function renameAgent(oldName: string, newName: string): boolean { return true; } -export function setPresenceProject(agent: string, projectId: string | null): void { - const db = getDb(); +export function setPresenceProject( + agent: string, + projectId: string | null, + database: Database = getDb(), +): void { + const db = database; const normalizedAgent = normalizeAgentName(agent); const desiredProjectId = toStoredProjectId(projectId); const latest = getPresenceByAgent(db, normalizedAgent); if (!latest) { - heartbeat(normalizedAgent, "online", undefined, undefined, projectId); + heartbeat(normalizedAgent, "online", undefined, undefined, projectId, db); return; } diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts index 90a4154..3fec39b 100644 --- a/src/lib/sessions.ts +++ b/src/lib/sessions.ts @@ -1,4 +1,5 @@ import { getDb } from "./db.js"; +import type { Database } from "./db.js"; import type { Session } from "../types.js"; export interface SessionActivity { @@ -85,8 +86,11 @@ export function getSession(sessionId: string): Session | null { /** * Get activity metrics for a session — velocity, engagement, trending status. */ -export function getSessionActivity(sessionId: string): SessionActivity | null { - const db = getDb(); +export function getSessionActivity( + sessionId: string, + database: Database = getDb(), +): SessionActivity | null { + const db = database; const exists = db.prepare("SELECT 1 FROM messages WHERE session_id = ? LIMIT 1").get(sessionId); if (!exists) return null; diff --git a/src/mcp/tools/agents.storage-isolation.test.ts b/src/mcp/tools/agents.storage-isolation.test.ts new file mode 100644 index 0000000..0c25758 --- /dev/null +++ b/src/mcp/tools/agents.storage-isolation.test.ts @@ -0,0 +1,97 @@ +import { afterEach, expect, test } from "bun:test"; +import { Database as BunDatabase } from "bun:sqlite"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { openDatabase } from "../../lib/db.js"; + +const cleanupPaths: string[] = []; + +afterEach(() => { + for (const path of cleanupPaths.splice(0)) { + rmSync(path, { force: true, recursive: true }); + } +}); + +test("agent MCP fixtures cannot fall back to inherited production storage", async () => { + const root = mkdtempSync(join(tmpdir(), "conversations-agent-storage-isolation-")); + cleanupPaths.push(root); + + const productionDbPath = join(root, "production.db"); + const configPath = join(root, "production-config.json"); + const configContents = JSON.stringify({ sentinel: "unchanged" }); + writeFileSync(configPath, configContents); + + const productionDb = openDatabase(productionDbPath); + productionDb.prepare(` + INSERT INTO agent_presence ( + id, agent, session_id, role, project_id, status, last_seen_at, created_at + ) VALUES (?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%f', 'now'), strftime('%Y-%m-%dT%H:%M:%f', 'now')) + `).run("sentinel", "production-sentinel", "sentinel-session", "agent", "", "online"); + productionDb.close(); + + const runs = Array.from({ length: 4 }, async (_, index) => { + const runRoot = join(root, `run-${index}`); + const home = join(runRoot, "home"); + const temp = join(runRoot, "tmp"); + const intendedTestDbPath = join(runRoot, "intended-test.db"); + const agentIdPath = join(home, ".hasna", "conversations", "agent-id"); + mkdirSync(join(home, ".hasna", "conversations"), { recursive: true }); + mkdirSync(temp, { recursive: true }); + const seededIdentity = index % 2 === 0; + if (seededIdentity) { + writeFileSync(agentIdPath, "production-identity\n"); + } + + const subprocess = Bun.spawn( + [process.execPath, "test", "src/mcp/tools/agents.test.ts"], + { + cwd: process.cwd(), + env: { + ...process.env, + HOME: home, + TMPDIR: temp, + HASNA_CONVERSATIONS_DB_PATH: productionDbPath, + CONVERSATIONS_DB_PATH: intendedTestDbPath, + CONVERSATIONS_CONFIG_PATH: configPath, + HASNA_CONVERSATIONS_STORAGE_MODE: "local", + HASNA_CONVERSATIONS_API_URL: "", + HASNA_CONVERSATIONS_API_KEY: "", + CONVERSATIONS_API_URL: "", + CONVERSATIONS_API_KEY: "", + }, + stdout: "pipe", + stderr: "pipe", + }, + ); + + const [exitCode, stdout, stderr] = await Promise.all([ + subprocess.exited, + new Response(subprocess.stdout).text(), + new Response(subprocess.stderr).text(), + ]); + + expect( + { exitCode, stdout, stderr }, + `isolated agent MCP test run ${index} failed`, + ).toMatchObject({ exitCode: 0 }); + expect(existsSync(intendedTestDbPath)).toBe(false); + if (seededIdentity) { + expect(readFileSync(agentIdPath, "utf8")).toBe("production-identity\n"); + } else { + expect(existsSync(agentIdPath)).toBe(false); + } + }); + + await Promise.all(runs); + + const readonlyProductionDb = new BunDatabase(productionDbPath, { readonly: true }); + const agents = readonlyProductionDb + .query("SELECT agent FROM agent_presence ORDER BY agent") + .all() as Array<{ agent: string }>; + readonlyProductionDb.close(); + + expect(agents).toEqual([{ agent: "production-sentinel" }]); + expect(agents.some(({ agent }) => agent === "rename-old" || agent === "rename-new")).toBe(false); + expect(readFileSync(configPath, "utf8")).toBe(configContents); +}); diff --git a/src/mcp/tools/agents.test.ts b/src/mcp/tools/agents.test.ts index 340bf05..5e504eb 100644 --- a/src/mcp/tools/agents.test.ts +++ b/src/mcp/tools/agents.test.ts @@ -1,29 +1,38 @@ -import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from "bun:test"; +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerAgentTools } from "./agents"; -import { closeDb } from "../../lib/db"; -import { resolveIdentity, _resetAutoName } from "../../lib/identity"; +import { openDatabase } from "../../lib/db"; +import type { Database } from "../../lib/db"; import { unlinkSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; -const TEST_DB = join(tmpdir(), `conversations-test-agents-mcp-${Date.now()}.db`); +const TEST_DB = join(tmpdir(), `conversations-test-agents-mcp-${process.pid}-${crypto.randomUUID()}.db`); describe("agent MCP tools", () => { let client: Client; + let database: Database; let agentFocus: Map; + let sessionAgent: string | null; const getAgentFocus = (agentId: string) => agentFocus.get(agentId)?.project_id ?? null; beforeAll(async () => { - process.env.CONVERSATIONS_DB_PATH = TEST_DB; - delete process.env.CONVERSATIONS_AGENT_ID; - closeDb(); + database = openDatabase(TEST_DB); const server = new McpServer({ name: "test-agents-mcp", version: "0.0.1" }); agentFocus = new Map(); - registerAgentTools(server, agentFocus, getAgentFocus); + sessionAgent = null; + registerAgentTools(server, agentFocus, getAgentFocus, { + database, + resolveIdentity: (explicit) => explicit?.trim() || "test-auto-agent", + setSessionAgent: (agent) => { + sessionAgent = agent; + }, + setClaudeSessionId: () => {}, + updateCachedAutoName: () => {}, + }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); client = new Client({ name: "test-client", version: "1.0.0" }); @@ -32,8 +41,7 @@ describe("agent MCP tools", () => { }); afterAll(async () => { - delete process.env.CONVERSATIONS_DB_PATH; - closeDb(); + database.close(); try { unlinkSync(TEST_DB); } catch {} try { unlinkSync(TEST_DB + "-wal"); } catch {} try { unlinkSync(TEST_DB + "-shm"); } catch {} @@ -96,6 +104,7 @@ describe("agent MCP tools", () => { }) as any) as any; expect(result.agent).toBe("heartbeat-explicit"); expect(result.heartbeat).toBe(true); + expect(sessionAgent).toBe("heartbeat-explicit"); }); test("heartbeat with name alias", async () => { @@ -285,15 +294,18 @@ describe("agent MCP tools", () => { }); test("returns blocking messages when they exist", async () => { - // Send a blocking message to our agent - const { sendMessage: sendMsg } = await import("../../lib/messages"); - sendMsg({ - from: "blocker-sender", - to: "blocker-target", - content: "BLOCK: fix this now", - priority: "urgent", - blocking: true, - }); + database.prepare(` + INSERT INTO messages (uuid, session_id, from_agent, to_agent, content, priority, blocking) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run( + crypto.randomUUID().replace(/-/g, ""), + "blocker-sender-blocker-target", + "blocker-sender", + "blocker-target", + "BLOCK: fix this now", + "urgent", + 1, + ); const result = parseResult(await client.callTool({ name: "get_blockers", diff --git a/src/mcp/tools/agents.ts b/src/mcp/tools/agents.ts index 9aa7b48..d8adae4 100644 --- a/src/mcp/tools/agents.ts +++ b/src/mcp/tools/agents.ts @@ -11,12 +11,27 @@ import { setSessionAgent, setClaudeSessionId } from "../channel.js"; import { getSessionActivity } from "../../lib/sessions.js"; import { getUnreadBlockers } from "../../lib/messages.js"; import { compactQueriedMessages, compactWindowedAgents, jsonText, resolveMcpWindow } from "../compact.js"; +import type { Database } from "../../lib/db.js"; + +export interface AgentToolDependencies { + database?: Database; + resolveIdentity?: typeof resolveIdentity; + setSessionAgent?: typeof setSessionAgent; + setClaudeSessionId?: typeof setClaudeSessionId; + updateCachedAutoName?: typeof updateCachedAutoName; +} export function registerAgentTools( server: McpServer, agentFocus: Map, getAgentFocus: (agentId: string) => string | null, + dependencies: AgentToolDependencies = {}, ): void { + const database = dependencies.database; + const resolveAgentIdentity = dependencies.resolveIdentity ?? resolveIdentity; + const rememberSessionAgent = dependencies.setSessionAgent ?? setSessionAgent; + const rememberClaudeSessionId = dependencies.setClaudeSessionId ?? setClaudeSessionId; + const rememberAutoName = dependencies.updateCachedAutoName ?? updateCachedAutoName; server.registerTool("register_agent", { description: "Register an agent. Just provide the name — session_id is auto-detected.", @@ -36,9 +51,9 @@ export function registerAgentTools( const claudeSid = process.env.CONVERSATIONS_SESSION_ID || null; const session_id = manualSid || claudeSid || `${name}-${Date.now()}`; try { - const result = registerAgent(name, session_id, role, project_id); - setSessionAgent(name); // Bridge now knows who we are - if (claudeSid) setClaudeSessionId(claudeSid); // Track for channel bridge polling + const result = registerAgent(name, session_id, role, project_id, database); + rememberSessionAgent(name); // Bridge now knows who we are + if (claudeSid) rememberClaudeSessionId(claudeSid); // Track for channel bridge polling return { content: [{ type: "text", text: JSON.stringify(result) }], }; @@ -63,9 +78,9 @@ export function registerAgentTools( }, }, async (args: Record) => { const { from: fromParam, name: nameParam, agent_name, status } = args; - const agent = resolveIdentity(fromParam || nameParam || agent_name); - heartbeat(agent, status); - setSessionAgent(agent); // Bridge now knows who we are + const agent = resolveAgentIdentity(fromParam || nameParam || agent_name); + heartbeat(agent, status, undefined, undefined, undefined, database); + rememberSessionAgent(agent); // Bridge now knows who we are return { content: [{ type: "text", text: JSON.stringify({ agent, status: status || "online", heartbeat: true }) }], @@ -82,7 +97,7 @@ export function registerAgentTools( }, }, async (args: Record) => { const { online_only } = args; - const agents = listAgents({ online_only }); + const agents = listAgents({ online_only }, database); return { content: [{ type: "text", text: jsonText(args.verbose ? agents : compactWindowedAgents(agents, args)) }], @@ -97,10 +112,9 @@ export function registerAgentTools( }, }, async (args: Record) => { const { from: fromParam, agent: targetAgent } = args; - const self = resolveIdentity(fromParam); - const agent = targetAgent?.trim() || self; + const agent = targetAgent?.trim() || resolveAgentIdentity(fromParam); - const removed = removePresence(agent); + const removed = removePresence(agent, database); if (!removed) { return { content: [{ type: "text", text: `agent "${agent}" not found` }], @@ -121,7 +135,7 @@ export function registerAgentTools( }, }, async (args: Record) => { const { from: fromParam, new_name } = args; - const oldName = resolveIdentity(fromParam); + const oldName = resolveAgentIdentity(fromParam); const newName = new_name.trim(); if (!newName) { @@ -132,7 +146,7 @@ export function registerAgentTools( } try { - const renamed = renameAgent(oldName, newName); + const renamed = renameAgent(oldName, newName, database); if (!renamed) { return { content: [{ type: "text", text: `agent "${oldName}" not found` }], @@ -142,7 +156,7 @@ export function registerAgentTools( // Update cached identity so subsequent calls resolve to the new name if (!fromParam) { - updateCachedAutoName(newName); + rememberAutoName(newName); } return { @@ -164,11 +178,11 @@ export function registerAgentTools( }, }, async (args: Record) => { const { project_id, from: fromParam } = args; - const agent = resolveIdentity(fromParam); + const agent = resolveAgentIdentity(fromParam); agentFocus.set(agent, { project_id }); // Also persist to DB - setPresenceProject(agent, project_id); + setPresenceProject(agent, project_id, database); return { content: [{ type: "text", text: JSON.stringify({ agent, focused: true, project_id }) }], @@ -181,9 +195,9 @@ export function registerAgentTools( from: z.string().optional(), }, }, async (args: Record) => { - const agent = resolveIdentity(args.from); + const agent = resolveAgentIdentity(args.from); const sessionFocus = agentFocus.get(agent) ?? null; - const presence = getPresence(agent); + const presence = getPresence(agent, database); const effective = getAgentFocus(agent); return { @@ -205,11 +219,11 @@ export function registerAgentTools( from: z.string().optional(), }, }, async (args: Record) => { - const agent = resolveIdentity(args.from); + const agent = resolveAgentIdentity(args.from); agentFocus.delete(agent); // Clear from DB too - setPresenceProject(agent, null); + setPresenceProject(agent, null, database); return { content: [{ type: "text", text: JSON.stringify({ agent, focused: false, project_id: null }) }], @@ -222,7 +236,7 @@ export function registerAgentTools( session_id: z.string(), }, }, async (args: Record) => { - const activity = getSessionActivity(args.session_id); + const activity = getSessionActivity(args.session_id, database); if (!activity) { return { content: [{ type: "text", text: `session "${args.session_id}" not found` }], isError: true }; } @@ -239,11 +253,12 @@ export function registerAgentTools( }, }, async (args: Record) => { const { from: fromParam } = args; - const agent = resolveIdentity(fromParam); + const agent = resolveAgentIdentity(fromParam); const window = resolveMcpWindow(args); const blockers = getUnreadBlockers( agent, args.verbose ? undefined : { limit: window.limit + 1, offset: window.offset }, + database, ); return { From d8542fcf702bdb44be6132055598cb33d8a588be Mon Sep 17 00:00:00 2001 From: andreihasna Date: Thu, 23 Jul 2026 12:39:20 +0300 Subject: [PATCH 8/9] test: verify injected agent effects --- src/mcp/tools/agents.test.ts | 37 ++++++++++++++++++++++++++++++------ src/mcp/tools/agents.ts | 5 ++++- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/mcp/tools/agents.test.ts b/src/mcp/tools/agents.test.ts index 5e504eb..699d603 100644 --- a/src/mcp/tools/agents.test.ts +++ b/src/mcp/tools/agents.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { describe, test, expect, beforeAll, afterAll, mock } from "bun:test"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -16,6 +16,11 @@ describe("agent MCP tools", () => { let database: Database; let agentFocus: Map; let sessionAgent: string | null; + const setSessionAgentSpy = mock((agent: string) => { + sessionAgent = agent; + }); + const setClaudeSessionIdSpy = mock((_sessionId: string) => {}); + const updateCachedAutoNameSpy = mock((_newName: string) => {}); const getAgentFocus = (agentId: string) => agentFocus.get(agentId)?.project_id ?? null; beforeAll(async () => { @@ -27,11 +32,10 @@ describe("agent MCP tools", () => { registerAgentTools(server, agentFocus, getAgentFocus, { database, resolveIdentity: (explicit) => explicit?.trim() || "test-auto-agent", - setSessionAgent: (agent) => { - sessionAgent = agent; - }, - setClaudeSessionId: () => {}, - updateCachedAutoName: () => {}, + resolveClaudeSessionId: () => "test-claude-session", + setSessionAgent: setSessionAgentSpy, + setClaudeSessionId: setClaudeSessionIdSpy, + updateCachedAutoName: updateCachedAutoNameSpy, }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -55,11 +59,18 @@ describe("agent MCP tools", () => { describe("register_agent", () => { test("registers agent with name and auto-detects session_id", async () => { + setSessionAgentSpy.mockClear(); + setClaudeSessionIdSpy.mockClear(); const result = parseResult(await client.callTool({ name: "register_agent", arguments: { name: "test-reg-agent" }, }) as any) as any; expect(result.agent.agent).toBe("test-reg-agent"); + expect(result.agent.session_id).toBe("test-claude-session"); + expect(setSessionAgentSpy).toHaveBeenCalledTimes(1); + expect(setSessionAgentSpy).toHaveBeenCalledWith("test-reg-agent"); + expect(setClaudeSessionIdSpy).toHaveBeenCalledTimes(1); + expect(setClaudeSessionIdSpy).toHaveBeenCalledWith("test-claude-session"); }); test("accepts agent_name alias", async () => { @@ -219,6 +230,20 @@ describe("agent MCP tools", () => { }); expect((result as any).isError).toBe(true); }); + + test("updates the injected auto-name cache for an implicit identity", async () => { + updateCachedAutoNameSpy.mockClear(); + await client.callTool({ name: "heartbeat", arguments: {} }); + const result = parseResult(await client.callTool({ + name: "rename_agent", + arguments: { new_name: "test-auto-agent-renamed" }, + }) as any) as any; + expect(result.renamed).toBe(true); + expect(result.old_name).toBe("test-auto-agent"); + expect(result.new_name).toBe("test-auto-agent-renamed"); + expect(updateCachedAutoNameSpy).toHaveBeenCalledTimes(1); + expect(updateCachedAutoNameSpy).toHaveBeenCalledWith("test-auto-agent-renamed"); + }); }); describe("focus mode tools", () => { diff --git a/src/mcp/tools/agents.ts b/src/mcp/tools/agents.ts index d8adae4..b18a303 100644 --- a/src/mcp/tools/agents.ts +++ b/src/mcp/tools/agents.ts @@ -16,6 +16,7 @@ import type { Database } from "../../lib/db.js"; export interface AgentToolDependencies { database?: Database; resolveIdentity?: typeof resolveIdentity; + resolveClaudeSessionId?: () => string | null; setSessionAgent?: typeof setSessionAgent; setClaudeSessionId?: typeof setClaudeSessionId; updateCachedAutoName?: typeof updateCachedAutoName; @@ -29,6 +30,8 @@ export function registerAgentTools( ): void { const database = dependencies.database; const resolveAgentIdentity = dependencies.resolveIdentity ?? resolveIdentity; + const resolveClaudeSessionId = dependencies.resolveClaudeSessionId + ?? (() => process.env.CONVERSATIONS_SESSION_ID || null); const rememberSessionAgent = dependencies.setSessionAgent ?? setSessionAgent; const rememberClaudeSessionId = dependencies.setClaudeSessionId ?? setClaudeSessionId; const rememberAutoName = dependencies.updateCachedAutoName ?? updateCachedAutoName; @@ -48,7 +51,7 @@ export function registerAgentTools( const name = nameParam || agent_name || agent_id; if (!name) return { content: [{ type: "text", text: "Error: name is required" }], isError: true }; // Auto-detect session_id from environment (set by agent-claude MCP subprocess) - const claudeSid = process.env.CONVERSATIONS_SESSION_ID || null; + const claudeSid = resolveClaudeSessionId(); const session_id = manualSid || claudeSid || `${name}-${Date.now()}`; try { const result = registerAgent(name, session_id, role, project_id, database); From 5c662eb8bfc74003e79876b9262c7afaacaad99a Mon Sep 17 00:00:00 2001 From: andreihasna Date: Thu, 23 Jul 2026 12:47:02 +0300 Subject: [PATCH 9/9] test: isolate identity storage fixtures --- src/lib/identity.test.ts | 309 +++++++++++++++++++++++---------------- 1 file changed, 185 insertions(+), 124 deletions(-) diff --git a/src/lib/identity.test.ts b/src/lib/identity.test.ts index fe9344c..236defe 100644 --- a/src/lib/identity.test.ts +++ b/src/lib/identity.test.ts @@ -1,141 +1,202 @@ -import { describe, test, expect, afterEach, beforeEach } from "bun:test"; -import { resolveIdentity, requireIdentity, getAutoName, _resetAutoName } from "./identity"; -import { AGENT_NAMES } from "./names"; -import { unlinkSync, readFileSync } from "fs"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { createHash } from "crypto"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; import { join } from "path"; -import { getDataDir } from "./db"; - -const AGENT_ID_FILE = join(getDataDir(), "agent-id"); -const savedEnv = process.env.CONVERSATIONS_AGENT_ID; -let savedAgentId: string | null = null; - -beforeEach(() => { - // Save existing agent-id file if present - try { - savedAgentId = readFileSync(AGENT_ID_FILE, "utf-8"); - } catch { - savedAgentId = null; - } - _resetAutoName(); -}); - -afterEach(() => { - // Restore env - if (savedEnv !== undefined) { - process.env.CONVERSATIONS_AGENT_ID = savedEnv; - } else { - delete process.env.CONVERSATIONS_AGENT_ID; - } - - // Restore agent-id file - if (savedAgentId !== null) { - const { writeFileSync } = require("fs"); - writeFileSync(AGENT_ID_FILE, savedAgentId, "utf-8"); - } - _resetAutoName(); -}); - -describe("resolveIdentity", () => { - test("returns explicit value when provided", () => { - expect(resolveIdentity("alice")).toBe("alice"); - }); - - test("returns env var when no explicit value", () => { - process.env.CONVERSATIONS_AGENT_ID = "env-agent"; - expect(resolveIdentity()).toBe("env-agent"); - }); - - test("explicit takes priority over env", () => { - process.env.CONVERSATIONS_AGENT_ID = "env-agent"; - expect(resolveIdentity("explicit")).toBe("explicit"); - }); - - test("falls back to auto-generated name when nothing set", () => { - delete process.env.CONVERSATIONS_AGENT_ID; - // Remove the persisted file so a fresh name is generated - try { unlinkSync(AGENT_ID_FILE); } catch {} - _resetAutoName(); - const name = resolveIdentity(); - expect(name).not.toBe("user"); - expect(AGENT_NAMES).toContain(name as any); - }); - - test("auto-generated name is consistent across calls", () => { - delete process.env.CONVERSATIONS_AGENT_ID; - try { unlinkSync(AGENT_ID_FILE); } catch {} - _resetAutoName(); - const name1 = resolveIdentity(); - const name2 = resolveIdentity(); - expect(name1).toBe(name2); - }); -}); - -describe("getAutoName", () => { - test("returns a name from the pool", () => { - try { unlinkSync(AGENT_ID_FILE); } catch {} - _resetAutoName(); - const name = getAutoName(); - expect(AGENT_NAMES).toContain(name as any); - }); - - test("persists name to file", () => { - try { unlinkSync(AGENT_ID_FILE); } catch {} - _resetAutoName(); - const name = getAutoName(); - const persisted = readFileSync(AGENT_ID_FILE, "utf-8").trim(); - expect(persisted).toBe(name); +import { fileURLToPath } from "url"; + +const ISOLATED_CHILD_ENV = "CONVERSATIONS_IDENTITY_TEST_CHILD"; + +function fingerprint(path: string): string { + if (!existsSync(path)) return "absent"; + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +if (process.env[ISOLATED_CHILD_ENV] !== "1") { + test("identity contract cannot mutate inherited production state", async () => { + const realHome = process.env.HOME || process.env.USERPROFILE; + if (!realHome) throw new Error("HOME or USERPROFILE is required"); + + const root = mkdtempSync(join(tmpdir(), "conversations-identity-isolation-")); + const home = join(root, "home"); + const temp = join(root, "tmp"); + const productionDbPath = join(root, "production.db"); + const configPath = join(root, "production-config.json"); + const alternateDbPath = join(root, "alternate.db"); + const realAgentIdPath = join(realHome, ".hasna", "conversations", "agent-id"); + mkdirSync(home, { recursive: true }); + mkdirSync(temp, { recursive: true }); + writeFileSync(productionDbPath, "production-database-sentinel"); + writeFileSync(configPath, "production-config-sentinel"); + + const before = { + agentId: fingerprint(realAgentIdPath), + database: fingerprint(productionDbPath), + config: fingerprint(configPath), + }; + + try { + const subprocess = Bun.spawn( + [process.execPath, "test", fileURLToPath(import.meta.url)], + { + cwd: process.cwd(), + env: { + ...process.env, + [ISOLATED_CHILD_ENV]: "1", + HOME: home, + USERPROFILE: home, + TMPDIR: temp, + HASNA_CONVERSATIONS_DB_PATH: productionDbPath, + CONVERSATIONS_DB_PATH: alternateDbPath, + CONVERSATIONS_CONFIG_PATH: configPath, + HASNA_CONVERSATIONS_STORAGE_MODE: "self_hosted", + HASNA_CONVERSATIONS_API_URL: "http://127.0.0.1:1", + HASNA_CONVERSATIONS_API_KEY: "identity-test-key", + }, + stdout: "pipe", + stderr: "pipe", + }, + ); + + const [exitCode, stdout, stderr] = await Promise.all([ + subprocess.exited, + new Response(subprocess.stdout).text(), + new Response(subprocess.stderr).text(), + ]); + + expect( + { exitCode, stdout, stderr }, + "isolated identity contract failed", + ).toMatchObject({ exitCode: 0 }); + expect({ + agentId: fingerprint(realAgentIdPath), + database: fingerprint(productionDbPath), + config: fingerprint(configPath), + }).toEqual(before); + expect(existsSync(alternateDbPath)).toBe(false); + } finally { + rmSync(root, { force: true, recursive: true }); + } }); - - test("reads persisted name on subsequent calls", () => { - const { writeFileSync, mkdirSync } = require("fs"); - const { dirname } = require("path"); - mkdirSync(dirname(AGENT_ID_FILE), { recursive: true }); - writeFileSync(AGENT_ID_FILE, "custom-persisted-name\n", "utf-8"); +} else { + const { + resolveIdentity, + requireIdentity, + getAutoName, + _resetAutoName, + } = await import("./identity.js"); + const { AGENT_NAMES } = await import("./names.js"); + const agentIdFile = join(process.env.HOME!, ".hasna", "conversations", "agent-id"); + const inheritedAgentId = process.env.CONVERSATIONS_AGENT_ID; + + beforeEach(() => { + rmSync(agentIdFile, { force: true }); + if (inheritedAgentId !== undefined) { + process.env.CONVERSATIONS_AGENT_ID = inheritedAgentId; + } else { + delete process.env.CONVERSATIONS_AGENT_ID; + } _resetAutoName(); - const name = getAutoName(); - expect(name).toBe("custom-persisted-name"); }); - test("is cached in memory after first call", () => { - try { unlinkSync(AGENT_ID_FILE); } catch {} + afterEach(() => { + rmSync(agentIdFile, { force: true }); + if (inheritedAgentId !== undefined) { + process.env.CONVERSATIONS_AGENT_ID = inheritedAgentId; + } else { + delete process.env.CONVERSATIONS_AGENT_ID; + } _resetAutoName(); - const name1 = getAutoName(); - // Even if we delete the file, cached value persists - try { unlinkSync(AGENT_ID_FILE); } catch {} - const name2 = getAutoName(); - expect(name1).toBe(name2); }); -}); -describe("AGENT_NAMES", () => { - test("has at least 200 names", () => { - expect(AGENT_NAMES.length).toBeGreaterThanOrEqual(200); + describe("resolveIdentity", () => { + test("returns explicit value when provided", () => { + expect(resolveIdentity("alice")).toBe("alice"); + }); + + test("returns env var when no explicit value", () => { + process.env.CONVERSATIONS_AGENT_ID = "env-agent"; + expect(resolveIdentity()).toBe("env-agent"); + }); + + test("explicit takes priority over env", () => { + process.env.CONVERSATIONS_AGENT_ID = "env-agent"; + expect(resolveIdentity("explicit")).toBe("explicit"); + }); + + test("falls back to auto-generated name when nothing set", () => { + delete process.env.CONVERSATIONS_AGENT_ID; + const name = resolveIdentity(); + expect(name).not.toBe("user"); + expect(AGENT_NAMES).toContain(name as any); + }); + + test("auto-generated name is consistent across calls", () => { + delete process.env.CONVERSATIONS_AGENT_ID; + const name1 = resolveIdentity(); + const name2 = resolveIdentity(); + expect(name1).toBe(name2); + }); }); - test("all names are unique", () => { - const unique = new Set(AGENT_NAMES); - expect(unique.size).toBe(AGENT_NAMES.length); + describe("getAutoName", () => { + test("returns a name from the pool", () => { + const name = getAutoName(); + expect(AGENT_NAMES).toContain(name as any); + }); + + test("persists name to file", () => { + const name = getAutoName(); + const persisted = readFileSync(agentIdFile, "utf-8").trim(); + expect(persisted).toBe(name); + }); + + test("reads persisted name on subsequent calls", () => { + mkdirSync(join(process.env.HOME!, ".hasna", "conversations"), { recursive: true }); + writeFileSync(agentIdFile, "custom-persisted-name\n", "utf-8"); + _resetAutoName(); + const name = getAutoName(); + expect(name).toBe("custom-persisted-name"); + }); + + test("is cached in memory after first call", () => { + const name1 = getAutoName(); + rmSync(agentIdFile, { force: true }); + const name2 = getAutoName(); + expect(name1).toBe(name2); + }); }); - test("all names are lowercase kebab-case", () => { - for (const name of AGENT_NAMES) { - expect(name).toMatch(/^[a-z]+-[a-z]+$/); - } + describe("AGENT_NAMES", () => { + test("has at least 200 names", () => { + expect(AGENT_NAMES.length).toBeGreaterThanOrEqual(200); + }); + + test("all names are unique", () => { + const unique = new Set(AGENT_NAMES); + expect(unique.size).toBe(AGENT_NAMES.length); + }); + + test("all names are lowercase kebab-case", () => { + for (const name of AGENT_NAMES) { + expect(name).toMatch(/^[a-z]+-[a-z]+$/); + } + }); }); -}); -describe("requireIdentity", () => { - test("returns explicit value when provided", () => { - expect(requireIdentity("alice")).toBe("alice"); - }); + describe("requireIdentity", () => { + test("returns explicit value when provided", () => { + expect(requireIdentity("alice")).toBe("alice"); + }); - test("returns env var when no explicit value", () => { - process.env.CONVERSATIONS_AGENT_ID = "env-agent"; - expect(requireIdentity()).toBe("env-agent"); - }); + test("returns env var when no explicit value", () => { + process.env.CONVERSATIONS_AGENT_ID = "env-agent"; + expect(requireIdentity()).toBe("env-agent"); + }); - test("throws when no identity available", () => { - delete process.env.CONVERSATIONS_AGENT_ID; - expect(() => requireIdentity()).toThrow("Agent identity required"); + test("throws when no identity available", () => { + delete process.env.CONVERSATIONS_AGENT_ID; + expect(() => requireIdentity()).toThrow("Agent identity required"); + }); }); -}); +}