proof-harness 0.1.0-next.8 → 0.1.0-next.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/COMPATIBILITY.md CHANGED
@@ -11,7 +11,7 @@ Consumers must pin an exact prerelease version, for example:
11
11
  ```json
12
12
  {
13
13
  "devDependencies": {
14
- "proof-harness": "0.1.0-next.7"
14
+ "proof-harness": "0.1.0-next.8"
15
15
  }
16
16
  }
17
17
  ```
@@ -27,11 +27,118 @@ versions rather than guessing how to interpret them.
27
27
 
28
28
  ## Prerelease migration notes
29
29
 
30
+ ### 0.1.0-next.9 — anonymous actions and control sensitivity (release candidate)
31
+
32
+ Prepared for release; not yet published. After publication, pin exactly
33
+ `proof-harness@0.1.0-next.9` and regenerate capabilities and baseline traces.
34
+ Trace/mission/health versions remain unchanged. Authentication refusal and
35
+ control-sensitivity output contracts use protocol/schema version 1.
36
+
37
+ #### Anonymous self-service actions (#23)
38
+
39
+ `assert.authorization` accepts `actor: "anonymous"` for **action probes only**.
40
+ It sends the protected POST invoker request in a fresh API context, without
41
+ login cookies or Authorization, and disables redirect following. It does not
42
+ clear or reuse the authenticated page session. Use the Playwright project's
43
+ `use.baseURL`, an explicit `action.baseURL`, or an already navigated page URL.
44
+
45
+ ```ts
46
+ await assert.authorization({
47
+ actor: "anonymous", page,
48
+ action: {
49
+ module: "user-management", name: "selfDeleteAccount", inputParams: {},
50
+ expectedAuthRedirect: "/sign-in",
51
+ },
52
+ });
53
+ ```
54
+
55
+ The consumer must update its **guarded invoker**, after request validation and
56
+ registered-handler lookup. In the catch around actual handler execution:
57
+
58
+ ```ts
59
+ import { authenticationRedirectResponse } from "proof-harness/server";
60
+ // Existing proofGuard, body validation, registry lookup, and handler call stay.
61
+ const refusal = authenticationRedirectResponse(error, request, {
62
+ module: body.module, action: body.action, allowedRedirects: ["/sign-in"],
63
+ });
64
+ if (refusal) return refusal;
65
+ // Keep the existing failure response for all other exceptions.
66
+ ```
67
+
68
+ Choose the exact authentication destination from the consumer's own auth
69
+ middleware. The adapter recognizes Next.js redirect errors only, requires no
70
+ Cookie/Authorization request headers, and matches an exact allowlisted relative
71
+ URL. It returns HTTP 401 with `{ protocolVersion: 1,
72
+ type: "proof_action_auth_refusal", module, action,
73
+ reason: "authentication_required", redirect }`. The client binds that envelope
74
+ to the invoked action and the assertion checks `expectedAuthRedirect`.
75
+ A generic 401/500, `action_threw`, invalid-form rejection, unexpected redirect,
76
+ malformed result, or missing registry entry is incomplete evidence. Anonymous
77
+ success is a failed authorization assertion. No seeded denied role is needed.
78
+
79
+ Use `assert.actionSucceeds` with a disposable authenticated actor for the
80
+ allowed-path control; account-deletion fixtures remain consumer-owned. The
81
+ adapter's destination list and placement are protected trust inputs: it must
82
+ only normalize authentication redirects from an executed registered action,
83
+ not invoker setup errors or unrelated redirects. This is not automatic proof
84
+ of an arbitrary application's authentication semantics.
85
+
86
+ Workspace scope discovery now follows local caller-input bindings and accesses,
87
+ including destructuring aliases and direct parse/safeParse results. It no
88
+ longer counts comments, SQL/output keys, or generated/result workspace IDs.
89
+ Unknown user-input dataflow used as workspace scope and dynamic input keys
90
+ remain unassessed. Internal `_BOT.ts` actions retain their existing exemption
91
+ from caller-boundary coverage. This is bounded local analysis, not intermodule
92
+ schema inference. In particular `createTeamWorkspace` no longer inherits a
93
+ cross-tenant requirement merely because it returns a new workspace ID. Declare
94
+ any additional product invariants and add their proofs in the consumer.
95
+
96
+ #### Separate positive-control sensitivity (#24)
97
+
98
+ Move the old `TENANT-deny-all` entry out of the primary mutation catalog into
99
+ a separate protected module named by `controlSensitivityCatalog` in
100
+ `proof.config.mjs`. Keep its existing subject, apply SQL and spec; add:
101
+
102
+ ```js
103
+ claims: [],
104
+ controls: [{
105
+ kind: "tenant_isolation", target: "workspace_members", operation: "select",
106
+ emittedBy: "assert.tenantIsolation",
107
+ }],
108
+ expectedFailureCode: "tenant_isolation_control",
109
+ ```
110
+
111
+ Run `proof-harness controls --inventory`, then `proof-harness controls` (or
112
+ `--only TENANT-deny-all`). Both require fresh passing baseline evidence for
113
+ every mapped package-origin **control** in the selected spec. The initial
114
+ contract supports tenant-isolation SELECT controls emitted by
115
+ `assert.tenantIsolation` or `assert.authorization`, with the exact failure code
116
+ `tenant_isolation_control`. Other sensitivity categories remain unsupported.
117
+
118
+ The runner reuses mutation planting/readback, isolated execution, restoration,
119
+ and recovery checks. A successful test needs a non-timeout failed proof, a
120
+ failed mapped control assertion of the same helper origin, and a step error
121
+ beginning with that exact failure code. Setup errors, incomplete/skipped
122
+ assertions, another spec, primary-only failures, or a missing failed control do
123
+ not count. Unexpected owner-control database errors are setup failures rather
124
+ than control rejection; explicit permission denial or zero visible owner rows
125
+ can establish the intended anti-vacuity failure.
126
+
127
+ Results live separately in `artifacts.controlSensitivity` (default
128
+ `.proof/control-sensitivity`), with `mode: "control-sensitivity"`,
129
+ `controlRejected`/`controlsTurnedRed`, and `detected: false`. They remain planted
130
+ traces and cannot satisfy baseline claims or primary mutation coverage. Use
131
+ `proof-harness controls --recover` for an interrupted run against the same
132
+ local stack; the shared recovery journal blocks either runner until restored.
133
+ Primary `mutate` still rejects `claims: []` with no resolved primary baseline.
134
+ A failed control can no longer count as a failed primary claim of the same
135
+ kind/target/operation. Wire both commands into consumer CI if both properties
136
+ are required.
137
+
30
138
  ### 0.1.0-next.8
31
139
 
32
- Release candidate prepared for publication. Publish from `main` with workflow
33
- input `0.1.0-next.8` after this version change is merged. Consumers can pin
34
- `proof-harness@0.1.0-next.8` after the publish workflow succeeds.
140
+ Published release `0.1.0-next.8`. Consumers pin exactly
141
+ `proof-harness@0.1.0-next.8` when adopting these features.
35
142
 
36
143
  #### Direct RLS clients and Auth administration (#18)
37
144
 
@@ -1246,3 +1246,23 @@ Such user-facing actions require denial and allowed-control evidence in
1246
1246
  coverage even without a workspace input. Changes use the existing
1247
1247
  `service_role_mutations_changed` drift facet. See `COMPATIBILITY.md` for
1248
1248
  supported factory/binding shapes and required consumer regeneration.
1249
+
1250
+
1251
+ ## Anonymous invocations and control sensitivity (0.1.0-next.9 candidate)
1252
+
1253
+ The versioned anonymous refusal envelope is `ActionAuthenticationRefusal`
1254
+ (protocol 1), validated by `isActionAuthenticationRefusal` from the shared
1255
+ entry point. The server exports `authenticationRedirectResponse`; Playwright
1256
+ exports `actAsUser.invokeAnonymousAction` and accepts `actor: "anonymous"` in
1257
+ `assert.authorization` action probes. Only an action-bound, expected
1258
+ authentication redirect can establish a passing anonymous denial; generic
1259
+ exceptions are not evidence. See `COMPATIBILITY.md` for the required protected
1260
+ invoker integration and allowed authenticated control.
1261
+
1262
+ `proof-harness controls` consumes `controlSensitivityCatalog` independently
1263
+ from primary mutation inventory and writes `mode: "control-sensitivity"`
1264
+ results under `artifacts.controlSensitivity`. Its mapped failed controls never
1265
+ count as primary mutation detection. The initial contract is limited to
1266
+ tenant-isolation SELECT anti-vacuity checks with `tenant_isolation_control`;
1267
+ see `COMPATIBILITY.md` for exact selectors, fresh baseline requirements and
1268
+ recovery. Consumer claims and product fixtures remain consumer-owned.
package/README.md CHANGED
@@ -17,7 +17,7 @@ Public prereleases are published under the npm `next` tag. Pin an exact
17
17
  prerelease version while the API is stabilizing:
18
18
 
19
19
  ```sh
20
- pnpm add proof-harness@0.1.0-next.7
20
+ pnpm add proof-harness@0.1.0-next.8
21
21
  ```
22
22
 
23
23
  The package exposes environment-specific entry points:
@@ -61,7 +61,7 @@ code via `allowProofServiceHosts`; there is deliberately no environment
61
61
  override.
62
62
 
63
63
  The package also provides one `proof-harness` executable with `scan`, `parse`,
64
- `registry`, `build`, `verify`, `coverage`, `inventory`, `drift`, and `mutate`
64
+ `registry`, `build`, `verify`, `coverage`, `inventory`, `drift`, `mutate`, and `controls`
65
65
  subcommands. Commands read repository paths from
66
66
  `proof.config.mjs`; without one they use a conventional application layout.
67
67
  Product migration aggregation and explicit mutation definitions remain
@@ -101,9 +101,9 @@ See [required migration changes](COMPATIBILITY.md#010-next7--audit-hardening)
101
101
  before upgrading a consumer. In particular, existing traces must be rerun and
102
102
  unsupported SQL now blocks assessment.
103
103
 
104
- Release candidate `0.1.0-next.8` adds direct RLS client and Auth-admin discovery,
104
+ Release `0.1.0-next.8` adds direct RLS client and Auth-admin discovery,
105
105
  a consumer-owned catalog provider for full SQL histories, and transactional
106
- INSERT controls for append-only tables. After publication, pin exactly
106
+ INSERT controls for append-only tables. Pin exactly
107
107
  `proof-harness@0.1.0-next.8`. See [compatibility and setup](COMPATIBILITY.md#010-next8)
108
108
  for the required consumer changes.
109
109
 
@@ -111,3 +111,9 @@ The harness establishes specific tested claims. Acceptance still requires
111
111
  consumer-owned requirements and a trusted CI job that controls the harness,
112
112
  policies, and evidence. A passing trace is not a general proof of code quality
113
113
  or a defense against an executor that can rewrite the acceptance gate.
114
+
115
+
116
+ Release candidate `0.1.0-next.9` adds isolated anonymous action probes and a
117
+ separate positive-control sensitivity runner. It also distinguishes caller
118
+ workspace inputs from generated result IDs. See [next.9 setup and migration](COMPATIBILITY.md)
119
+ before upgrading; consumer invoker and catalog changes are required.
package/cli/config.mjs CHANGED
@@ -9,6 +9,7 @@ const DEFAULT_CONFIG = Object.freeze({
9
9
  traces: ".proof/traces",
10
10
  drift: ".proof/drift.json",
11
11
  mutations: ".proof/mutations",
12
+ controlSensitivity: ".proof/control-sensitivity",
12
13
  },
13
14
  // The module-descriptor system (module.meta.ts → modules.json and its
14
15
  // checker) moved to the consumer template in 0.1.0-next.6: it serves the
@@ -50,6 +51,7 @@ const DEFAULT_CONFIG = Object.freeze({
50
51
  insertControl: null,
51
52
  driftSources: ["app", "package.json"],
52
53
  mutationCatalog: null,
54
+ controlSensitivityCatalog: null,
53
55
  });
54
56
 
55
57
  function mergeConfig(config = {}) {
@@ -0,0 +1,95 @@
1
+ const assertionHelpers = new Set([
2
+ "assert.tenantIsolation",
3
+ "assert.authorization",
4
+ "assert.actionSucceeds",
5
+ "assert.httpResponse",
6
+ ]);
7
+ const helpers = new Set(["assert.tenantIsolation", "assert.authorization"]);
8
+ const primary = (a) =>
9
+ a.role === undefined ||
10
+ a.role === null ||
11
+ ["primary", "negative"].includes(a.role);
12
+ const control = (a) => ["control", "positive_control"].includes(a.role);
13
+ const decisive = (a) => a.status !== "incomplete" && a.status !== "skipped";
14
+ const matches = (a, s) =>
15
+ a.kind === s.kind &&
16
+ a.target === s.target &&
17
+ a.operation === s.operation &&
18
+ (!s.emittedBy || a.emittedBy === s.emittedBy);
19
+
20
+ export function validateControlBaseline(mutation, traces) {
21
+ if (
22
+ !Array.isArray(mutation.claims) ||
23
+ mutation.claims.length ||
24
+ !Array.isArray(mutation.controls) ||
25
+ !mutation.controls.length ||
26
+ mutation.expectedFailureCode !== "tenant_isolation_control"
27
+ )
28
+ throw new Error(
29
+ `[PROOF_FAIL] control_contract: ${mutation.id} requires claims: [], nonempty controls, and expectedFailureCode: tenant_isolation_control`,
30
+ );
31
+ for (const selector of mutation.controls) {
32
+ if (
33
+ selector.kind !== "tenant_isolation" ||
34
+ selector.operation !== "select" ||
35
+ typeof selector.target !== "string" ||
36
+ !selector.target ||
37
+ !helpers.has(selector.emittedBy)
38
+ )
39
+ throw new Error(
40
+ `[PROOF_FAIL] control_contract: ${mutation.id} has an unsupported control selector`,
41
+ );
42
+ const exists = traces.some(
43
+ (t) =>
44
+ t.specFile === mutation.spec &&
45
+ t.passed === true &&
46
+ !t.mutation &&
47
+ t.steps.some(
48
+ (step) =>
49
+ step.passed === true &&
50
+ (step.assertions ?? []).some(
51
+ (a) =>
52
+ a.passed === true &&
53
+ decisive(a) &&
54
+ control(a) &&
55
+ matches(a, selector),
56
+ ),
57
+ ),
58
+ );
59
+ if (!exists)
60
+ throw new Error(
61
+ `[PROOF_FAIL] control_baseline_missing: ${mutation.id} has no fresh passing package-origin control for ${selector.target}`,
62
+ );
63
+ }
64
+ return mutation.controls;
65
+ }
66
+
67
+ /** Control failures cannot satisfy primary claim detection, even for the same key. */
68
+ export function assertionSelectorsTurnedRed(
69
+ traces,
70
+ selectors,
71
+ role,
72
+ expectedFailureCode,
73
+ ) {
74
+ if (!selectors.length) return false;
75
+ return selectors.every((selector) =>
76
+ traces.some(
77
+ (t) =>
78
+ t.passed === false &&
79
+ t.steps.some(
80
+ (step) =>
81
+ step.passed === false &&
82
+ (!expectedFailureCode ||
83
+ step.error?.startsWith(`[PROOF_FAIL] ${expectedFailureCode}:`)) &&
84
+ (step.assertions ?? []).some(
85
+ (a) =>
86
+ a.passed === false &&
87
+ decisive(a) &&
88
+ (role === "control" ? control(a) : primary(a)) &&
89
+ assertionHelpers.has(a.emittedBy) &&
90
+ matches(a, selector),
91
+ ),
92
+ ),
93
+ ),
94
+ );
95
+ }
@@ -0,0 +1,177 @@
1
+ import ts from "typescript";
2
+ const workspace = (name) => /^(workspaceId|workspace_id)$/.test(name);
3
+ function visit(node, fn) {
4
+ fn(node);
5
+ ts.forEachChild(node, (child) => visit(child, fn));
6
+ }
7
+ function unwrap(node) {
8
+ while (
9
+ node &&
10
+ (ts.isParenthesizedExpression(node) ||
11
+ ts.isAwaitExpression(node) ||
12
+ ts.isAsExpression(node))
13
+ )
14
+ node = node.expression;
15
+ return node;
16
+ }
17
+
18
+ /** Trace caller input bindings, never result fields, comments or SQL keys. */
19
+ export function actionAcceptsWorkspaceInput(
20
+ source,
21
+ checker,
22
+ exportName,
23
+ problems,
24
+ ) {
25
+ let owner;
26
+ visit(source, (node) => {
27
+ if (
28
+ (ts.isFunctionDeclaration(node) || ts.isVariableDeclaration(node)) &&
29
+ node.name?.getText(source) === exportName
30
+ )
31
+ owner = node;
32
+ });
33
+ if (owner && ts.isVariableDeclaration(owner))
34
+ owner = unwrap(owner.initializer);
35
+ if (
36
+ !owner ||
37
+ !(
38
+ ts.isFunctionDeclaration(owner) ||
39
+ ts.isArrowFunction(owner) ||
40
+ ts.isFunctionExpression(owner)
41
+ )
42
+ )
43
+ return false;
44
+ const inputs = new Map();
45
+ const containers = new Set();
46
+ let found = false;
47
+ const symbol = (node) => checker.getSymbolAtLocation(node);
48
+ const typeHasWorkspace = (type, seen = new Set()) => {
49
+ if (!type || seen.has(type)) return false;
50
+ seen.add(type);
51
+ return checker
52
+ .getPropertiesOfType(type)
53
+ .some(
54
+ (p) =>
55
+ workspace(p.name) ||
56
+ (p.name !== "contextParams" &&
57
+ p.valueDeclaration &&
58
+ typeHasWorkspace(
59
+ checker.getTypeOfSymbolAtLocation(p, p.valueDeclaration),
60
+ seen,
61
+ )),
62
+ );
63
+ };
64
+ function bind(name, state) {
65
+ if (ts.isIdentifier(name)) {
66
+ inputs.set(symbol(name), state);
67
+ return;
68
+ }
69
+ if (ts.isObjectBindingPattern(name))
70
+ for (const element of name.elements) {
71
+ const key = (element.propertyName ?? element.name)
72
+ .getText(source)
73
+ .replace(/["']/g, "");
74
+ if (workspace(key)) {
75
+ if (state === "known") found = true;
76
+ else
77
+ problems.push("workspace input passes through unresolved dataflow");
78
+ }
79
+ bind(element.name, state);
80
+ }
81
+ }
82
+ for (const parameter of owner.parameters) {
83
+ if (ts.isIdentifier(parameter.name)) {
84
+ if (parameter.name.text === "contextParams") continue;
85
+ if (workspace(parameter.name.text)) found = true;
86
+ const type = checker.getTypeAtLocation(parameter);
87
+ const nested = type.getProperty("inputParams");
88
+ if (nested) {
89
+ containers.add(symbol(parameter.name));
90
+ if (
91
+ typeHasWorkspace(checker.getTypeOfSymbolAtLocation(nested, parameter))
92
+ )
93
+ found = true;
94
+ } else {
95
+ bind(parameter.name, "known");
96
+ if (typeHasWorkspace(type)) found = true;
97
+ }
98
+ } else if (ts.isObjectBindingPattern(parameter.name)) {
99
+ for (const element of parameter.name.elements) {
100
+ const key = (element.propertyName ?? element.name).getText(source);
101
+ if (key === "contextParams") continue;
102
+ bind(element.name, "known");
103
+ if (
104
+ workspace(key) ||
105
+ typeHasWorkspace(checker.getTypeAtLocation(element))
106
+ )
107
+ found = true;
108
+ }
109
+ }
110
+ }
111
+ function state(node) {
112
+ node = unwrap(node);
113
+ if (!node) return undefined;
114
+ if (ts.isIdentifier(node)) return inputs.get(symbol(node));
115
+ if (
116
+ ts.isPropertyAccessExpression(node) ||
117
+ ts.isElementAccessExpression(node)
118
+ ) {
119
+ const key = ts.isPropertyAccessExpression(node)
120
+ ? node.name.text
121
+ : node.argumentExpression?.text;
122
+ if (
123
+ ts.isIdentifier(node.expression) &&
124
+ containers.has(symbol(node.expression))
125
+ )
126
+ return key === "inputParams" ? "known" : undefined;
127
+ return state(node.expression);
128
+ }
129
+ if (ts.isCallExpression(node)) {
130
+ const fromInput = node.arguments.map(state).find(Boolean);
131
+ if (fromInput)
132
+ return ts.isPropertyAccessExpression(node.expression) &&
133
+ ["parse", "safeParse"].includes(node.expression.name.text)
134
+ ? fromInput
135
+ : "unknown";
136
+ }
137
+ return undefined;
138
+ }
139
+ const declarations = [];
140
+ visit(owner, (node) => {
141
+ if (ts.isVariableDeclaration(node)) declarations.push(node);
142
+ });
143
+ for (let pass = 0; pass <= declarations.length; pass++)
144
+ for (const node of declarations) {
145
+ const input = state(node.initializer);
146
+ if (input) {
147
+ bind(
148
+ node.name,
149
+ node.parent.flags & ts.NodeFlags.Const ? input : "unknown",
150
+ );
151
+ }
152
+ }
153
+ visit(owner, (node) => {
154
+ if (
155
+ !(
156
+ ts.isPropertyAccessExpression(node) ||
157
+ ts.isElementAccessExpression(node)
158
+ )
159
+ )
160
+ return;
161
+ const key = ts.isPropertyAccessExpression(node)
162
+ ? node.name.text
163
+ : node.argumentExpression?.text;
164
+ const input = state(node.expression);
165
+ if (workspace(key) && input) {
166
+ if (input === "known") found = true;
167
+ else problems.push("workspace input passes through unresolved dataflow");
168
+ }
169
+ if (
170
+ ts.isElementAccessExpression(node) &&
171
+ input &&
172
+ !ts.isStringLiteral(node.argumentExpression)
173
+ )
174
+ problems.push("dynamic caller input key cannot be assessed");
175
+ });
176
+ return found;
177
+ }