Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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).
Expand All @@ -15,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 <agent>` when the returned digest should update read state.

## [0.3.0] - 2026-06-24
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions docs/HASNA-CONTROL-V1-THREAT-MODEL.md
Original file line number Diff line number Diff line change
@@ -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, 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, 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. |
| 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. 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:

- 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`.
164 changes: 164 additions & 0 deletions docs/HASNA-CONTROL-V1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# `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
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)))
```

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.

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
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. 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. They remain counted and
diagnosed as rejected observations for audit, but cannot change the historical
`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.
- 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. 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
`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.
Loading