diff --git a/Docs/evidence/OBSERVATION_DISCLOSURE_DIGEST_SCOPE_PHASE4_PROPOSAL.md b/Docs/evidence/OBSERVATION_DISCLOSURE_DIGEST_SCOPE_PHASE4_PROPOSAL.md new file mode 100644 index 00000000..0e124671 --- /dev/null +++ b/Docs/evidence/OBSERVATION_DISCLOSURE_DIGEST_SCOPE_PHASE4_PROPOSAL.md @@ -0,0 +1,87 @@ +# ObservationEvidence disclosure and digest scope — Phase 4 proposal + +> **Status: DRAFT / REVIEW-ONLY / NOT AN APPROVED CONTRACT** +> Parent RFC: [#196](https://github.com/Logorythmus-org/Semantiq/issues/196). Defect: [#192](https://github.com/Logorythmus-org/Semantiq/issues/192). Baseline main: `89cfdcb10ab407f5c1ef84b8cda05894b5dd4a2a`. +> This document is a candidate design for maintainer discussion. It changes no runtime, schema, hash profile registry, release, or historical digest. + +## 1. Evidence source and observed risk + +`packages/evidence-normalizer/src/types.ts` defines `ObservationEvidence` with context, interpretation, decision, action, result, consequence, provenance, and a string `evidenceDigest`. +In `packages/evidence-normalizer/src/normalizer.ts`, `context.environmentVariables` copies the source environment map and `decision.commandArray` copies raw command arguments. `decision.workingDirectory` and file paths can expose private paths. `result.stdoutSanitized`, `result.stderrSanitized`, `consequence.filesModified[].diffUnified`, and optional `interpretation.rawThoughtLog` undergo pattern-based redaction, which is *not* an exhaustive proof of secrecy. A digest of disclosed secrets does not make disclosure safe; lower-entropy secret values can also be vulnerable to guessing by digest comparison. + +The legacy `evidenceDigest` array-replacer preimage fails to bind many nested values. The independent prospective digest test [PR #197](https://github.com/Logorythmus-org/Semantiq/pull/197) does **not** establish a safe disclosure contract. The canonicalization migration matrix classifies this hash surface as `IDENTITY_CRITICAL` / `NOT_SAFE_TO_MIGRATE_YET`. + +## 2. Data scopes — proposed, not existing implementation + +| Scope | Candidate content | Disclosure and integrity semantics | +| --- | --- | --- | +| `local-raw` | Original execution bundle, if retention is necessary | Restricted local capture; **must not** be exported through a public evidence pathway; no public seal implied | +| `internal-sanitized` | Reviewed redacted observation with authorized fields | Restricted review, provenance and access controls required; exact allowed-field contract and verification are **not yet specified** | +| `public-summary` | An explicit minimum-information projection | Separately identified digest binds **only** the disclosed summary, not a full run or the omitted data | + +These are proposed vocabulary values, not registered schema identifiers. Publishing a redacted full `ObservationEvidence` must not be inferred from the presence of text-redaction methods. + +## 3. Field candidate matrix for public disclosure + +| Observation fields | Candidate public treatment | Reason / review question | +| --- | --- | --- | +| `evidenceId`, `taskId`, `normalizedAt` | Withhold by default; issue a purpose-specific random public correlation ID if authorized | Identity, timing, and correlation can themselves disclose private data | +| `context.environmentVariables` | Withhold values and names by default | Environment maps routinely carry secrets; any allowlist needs reviewed semantics | +| `context.baseImageDigest`, other context | Withhold by default | Image or sandbox identifiers may reveal proprietary state | +| `decision.commandArray`, `workingDirectory` | Withhold entirely by default | Argument values and paths may be secret or personal | +| `action.dispatchedAt`, `timeoutConfiguredMs` | Withhold pending policy | Timing and resource budgets can be sensitive | +| `result.exitCode`, `timedOut`, `oomKilled`, truncated flags | Potentially disclose after security review | Operational outcomes; cannot establish behavioral goal completion | +| `result.executionDurationMs`, `peakMemoryBytes` | Candidate aggregate only; require bounded disclosure | Detailed timing/resources may fingerprint hosts or private workloads | +| `result.stdoutSanitized`, `stderrSanitized` | Withhold text by default | Pattern redaction is insufficient to guarantee privacy | +| `consequence.totalMutationsCount`, `spawnedProcessCount` | Candidate bounded count only | Counts do not prove correct task execution | +| `consequence.filesCreated/Modified/Deleted` | Withhold raw paths, diff text and content hashes by default | Private paths, confidential code and guessable low-entropy content | +| `interpretation.rawThoughtLog`, `structuredIntent` | Withhold by default | Sensitive reasoning and user/agent-provided text | +| `recovery`, `provenance` | Withhold pending separate protocol review | May leak infrastructure and source links | +| `evidenceDigest` | Never recursively include in its own digest subject | Avoid circular preimage; always declare profile and scope | + +This matrix is an **unapproved candidate** for discussion, not a guarantee that the listed fields are safe to publish. + +## 4. Proposed isolated public-summary fixture + +A test-only fixture uses an explicitly supplied **synthetic** `publicRunId` and an allowlisted subset of execution results: `exitCode`, `timedOut`, and `totalMutationsCount`. All private source strings and paths are excluded in the synthetic fixture, and the disclosed subset is self-identified with a fixed domain, a prospective projection identifier, and **an exhaustive positive list of fields committed by the prototype**. That list does not assert anything about unlisted private source data. + +Its digest subject conceptually binds: + +```text +{ + domain: "semantiq.observation.public-summary", + projectionVersion: "proposal-0", + publicRunId: , + result: { exitCode, timedOut }, + consequence: { totalMutationsCount }, + includedFields: ["publicRunId", "result.exitCode", "result.timedOut", "consequence.totalMutationsCount"] +} +``` + +The **scope is part of the hashed subject**, not only external metadata, to prevent confusing a summary digest with an internal evidence digest. The summary digest is named `semantiq-canonical-json-v1` for serialization, but this alone does not register a product-specific observation profile. + +If `exitCode` changes, the public-summary digest must change. If an omitted command argument or environment value changes, this digest is intentionally unchanged. A successful recomputation only shows **digest consistency for the included public-summary fields**. If an attacker can modify the summary and its unhashed/untrusted digest together, they can replace both undetected. Integrity relative to an independent *trusted reference digest*, authenticated registry, or approved digital signature would require a separate trust-anchor design. The prototype does **not** authenticate origin or completeness. It cannot validate raw execution, internal redaction quality, goal success, or omitted values. + +## 5. Profile and version verification requirements + +- An approved envelope must carry explicit `envelopeVersion`, `digestProfile`, `disclosureScope`, `projectionVersion`, `digest`, and its committed public subject. Exact wire names are not approved. +- Hash with the repository's existing `semantiq-canonical-json-v1` utility on a deterministic, explicitly projected object. Unsupported Unicode, non-integer/negative-zero numeric values, nested `undefined`, cycles, and non-JSON types must fail closed. Do not auto-normalize with lossy JSON round-trips. +- Unknown profile, scope, projection version, malformed digest, or absent metadata cannot produce a verified verdict. Unknown historical origin is `NOT_ESTABLISHED`, not a fallback. +- A distinct immutable historical `observation-top-level-replacer-v0` profile must reproduce original bytes without claiming omitted nested fields were sealed; it is **not** `legacy-ts-v0`. +- Published summaries need an explicit provenance/disclosure statement and security review. A matching hash without an independently trusted digest/signature means **consistency only, not authenticity or tamper resistance against a rewriting attacker**. An authenticated provenance/trust-anchor protocol remains unapproved. +- TS/Python vector parity and historical consumer inventory remain required before any production schema/generation change. +- No silent rewrite of stored `evidenceDigest`, schema `1.0.0`, alpha release identity, or migration-matrix flags. + +## 6. Review blockers + +1. Establish real consumers, storage paths, old-artifact provenance and verifier APIs for `ObservationEvidence`; a repository-code finding does not establish downstream adoption or public export. +2. Agree on what counts as meaningful and safe operational evidence. Proposed fields may leak workload information and require threat-model acceptance. +3. Specify how `publicRunId` is generated and how authorized reviewers can link it to internal runs without exposing private identifiers. +4. Decide whether a public-summary seal is needed at all and whether the product needs a separate internal-sanitized V1 seal; do not conflate the two. +5. Accept RFC #196 via normal review and obtain CODEOWNER security and Core approval before implementation. + +## 7. Acceptance evidence plan + +Test-only synthetic cases should demonstrate: omitted synthetic source values absent from the public subject; an included result mutation changes its digest; an omitted private value does not; an exact positive field-inclusion list; the scope and domain cannot be substituted; missing/unknown/malformed profile metadata fails closed; no network or real credentials are involved. The suite cannot establish safety for arbitrary text, external runtime input, or malicious replacement of both summary and digest. + +**Scientific boundary:** `Observed ≠ Inferred`; `Absence ≠ Counterevidence`; `Bundle integrity ≠ truth`. diff --git a/tests/unit/observation-disclosure-scope-prototype.test.ts b/tests/unit/observation-disclosure-scope-prototype.test.ts new file mode 100644 index 00000000..19f4a9b1 --- /dev/null +++ b/tests/unit/observation-disclosure-scope-prototype.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from "vitest"; +import { + hashCanonical, + SHARED_CANONICALIZATION_PROFILE +} from "../../packages/sandbox-contracts/src/index.js"; + +/** + * RFC #196 Phase 4: synthetic PUBLIC-SUMMARY privacy contract sketch. + * + * Test-only. This is intentionally NOT a whole ObservationEvidence digest, + * an approved disclosure policy, or an authorized production verifier. + */ +const DISCLOSURE_SCOPE = "public-summary" as const; +const PROJECTION_VERSION = "proposal-0" as const; +const DOMAIN = "semantiq.observation.public-summary" as const; +const INCLUDED_FIELDS = [ + "publicRunId", + "result.exitCode", + "result.timedOut", + "consequence.totalMutationsCount" +] as const; + +interface PrivateFixture { + publicRunId: string; + context: { environmentVariables: Record }; + decision: { commandArray: string[]; workingDirectory: string }; + interpretation: { rawThoughtLog: string }; + result: { + exitCode: number; + timedOut: boolean; + stdoutSanitized: string; + stderrSanitized: string; + }; + consequence: { + totalMutationsCount: number; + filesModified: { path: string; diffUnified: string }[]; + }; +} + +function sample(): PrivateFixture { + return { + publicRunId: "synthetic-public-run-01", + context: { environmentVariables: { PRIVATE_TOKEN: "fixture-sensitive-env" } }, + decision: { + commandArray: ["runner", "--token=fixture-sensitive-arg"], + workingDirectory: "/private/fixture-directory" + }, + interpretation: { rawThoughtLog: "fixture-sensitive-reasoning" }, + result: { + exitCode: 0, + timedOut: false, + stdoutSanitized: "fixture-sensitive-output", + stderrSanitized: "fixture-sensitive-error" + }, + consequence: { + totalMutationsCount: 1, + filesModified: [{ path: "/private/fixture-file", diffUnified: "fixture-sensitive-diff" }] + } + }; +} + +function publicSubject(observation: PrivateFixture) { + // Explicit projection; never spread raw/private input into public evidence. + return { + domain: DOMAIN, + projectionVersion: PROJECTION_VERSION, + publicRunId: observation.publicRunId, + result: { + exitCode: observation.result.exitCode, + timedOut: observation.result.timedOut + }, + consequence: { + totalMutationsCount: observation.consequence.totalMutationsCount + }, + includedFields: INCLUDED_FIELDS + }; +} + +function sealPublicSummary(observation: PrivateFixture) { + const subject = publicSubject(observation); + const digest = hashCanonical(subject, { + profile: SHARED_CANONICALIZATION_PROFILE + }).sha256; + + return { + envelopeVersion: "proposal-0", + digestProfile: SHARED_CANONICALIZATION_PROFILE, + disclosureScope: DISCLOSURE_SCOPE, + projectionVersion: PROJECTION_VERSION, + subject, + digest: `sha256:${digest}` + }; +} + +type PublicEnvelope = ReturnType; + +function verifyPublicSummary(envelope: PublicEnvelope): boolean { + if ( + envelope.envelopeVersion !== "proposal-0" || + envelope.digestProfile !== SHARED_CANONICALIZATION_PROFILE || + envelope.disclosureScope !== DISCLOSURE_SCOPE || + envelope.projectionVersion !== PROJECTION_VERSION || + envelope.subject.domain !== DOMAIN || + envelope.subject.projectionVersion !== PROJECTION_VERSION + ) { + throw new TypeError("Unknown or inconsistent summary profile/scope/version"); + } + if (!/^sha256:[a-f0-9]{64}$/.test(envelope.digest)) { + throw new TypeError("Malformed summary digest"); + } + const computed = hashCanonical(envelope.subject, { + profile: SHARED_CANONICALIZATION_PROFILE + }).sha256; + return envelope.digest === `sha256:${computed}`; +} + +describe("RFC #196 Phase 4 — isolated public summary disclosure (test-only)", () => { + it("never includes synthetic private fields in the public summary subject", () => { + const subjectJson = JSON.stringify(sealPublicSummary(sample())); + for (const privateValue of [ + "fixture-sensitive-env", + "fixture-sensitive-arg", + "fixture-sensitive-reasoning", + "fixture-sensitive-output", + "fixture-sensitive-error", + "fixture-sensitive-diff", + "/private/fixture" + ]) { + expect(subjectJson).not.toContain(privateValue); + } + expect(subjectJson).toContain("public-summary"); + expect(subjectJson).toContain("result.exitCode"); + }); + + it("binds exactly the disclosed operational outcome, not hidden source data", () => { + const original = sample(); + const changedResult = { + ...original, + result: { ...original.result, exitCode: 1 } + }; + const changedWithheld = { + ...original, + context: { environmentVariables: { PRIVATE_TOKEN: "different-private-value" } }, + decision: { + commandArray: ["runner", "--token=different-private-arg"], + workingDirectory: "/different-private-path" + } + }; + + const originalDigest = sealPublicSummary(original).digest; + const changedResultDigest = sealPublicSummary(changedResult).digest; + const changedWithheldDigest = sealPublicSummary(changedWithheld).digest; + + expect(changedResultDigest).not.toBe(originalDigest); + // Intentional: this is a public-subset seal, NOT full evidence integrity. + expect(changedWithheldDigest).toBe(originalDigest); + }); + + it("rejects unknown versions and scopes rather than falling back", () => { + const envelope = sealPublicSummary(sample()); + expect(verifyPublicSummary(envelope)).toBe(true); + + const changedScope = { ...envelope, disclosureScope: "internal" }; + const changedProfile = { ...envelope, digestProfile: "legacy-ts-v0" }; + const changedVersion = { ...envelope, projectionVersion: "proposal-999" }; + const malformed = { ...envelope, digest: "sha256:bad" }; + + expect(() => verifyPublicSummary(changedScope as PublicEnvelope)).toThrow(TypeError); + expect(() => verifyPublicSummary(changedProfile as PublicEnvelope)).toThrow(TypeError); + expect(() => verifyPublicSummary(changedVersion as PublicEnvelope)).toThrow(TypeError); + expect(() => verifyPublicSummary(malformed)).toThrow("Malformed summary digest"); + }); + + it("detects tampering of included summary fields", () => { + const envelope = sealPublicSummary(sample()); + const changedSubject = { + ...envelope.subject, + consequence: { totalMutationsCount: 7 } + }; + const changedEnvelope = { ...envelope, subject: changedSubject }; + + expect(verifyPublicSummary(changedEnvelope)).toBe(false); + }); + + it("rejects unsupported numeric values without coercing them into JSON", () => { + const original = sample(); + for (const unsafeValue of [1.5, Number.NaN, -0, Number.POSITIVE_INFINITY]) { + const mutated = { + ...original, + result: { ...original.result, exitCode: unsafeValue } + }; + expect(() => sealPublicSummary(mutated)).toThrow(TypeError); + } + }); +});