proof-harness 0.1.0-next.7 → 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 +247 -3
- package/PROOF_SDK_CONTRACT.md +46 -1
- package/README.md +18 -6
- package/cli/config.mjs +4 -0
- package/cli/engines/parse_proof_schema.mjs +5 -78
- package/cli/engines/proof_control_sensitivity.mjs +95 -0
- package/cli/engines/proof_coverage.mjs +18 -5
- package/cli/engines/proof_drift.mjs +15 -4
- package/cli/engines/proof_input_scope.mjs +177 -0
- package/cli/engines/proof_mutation_check.mjs +132 -57
- package/cli/engines/proof_schema_classification.mjs +75 -0
- package/cli/engines/proof_schema_provider.mjs +192 -0
- package/cli/engines/scan_proof_capabilities.mjs +135 -38
- package/cli/proof-harness.mjs +3 -1
- package/dist/{action-types-C3OmHRyH.d.ts → action-types-Cw-dmSKj.d.ts} +11 -1
- package/dist/node.d.ts +33 -1
- package/dist/node.js +110 -1
- package/dist/node.js.map +1 -1
- package/dist/playwright.d.ts +13 -2
- package/dist/playwright.js +274 -32
- package/dist/playwright.js.map +1 -1
- package/dist/server.d.ts +8 -1
- package/dist/server.js +21 -0
- package/dist/server.js.map +1 -1
- package/dist/shared.d.ts +1 -1
- package/dist/shared.js +8 -0
- package/dist/shared.js.map +1 -1
- package/package.json +3 -1
- package/proof-consumer-fixtures/action-invocation/auth-refusal.json +8 -0
- package/proof-consumer-fixtures/capabilities/auth-admin.json +15 -0
- package/proof-consumer-fixtures/control-sensitivity/catalog.json +14 -0
- package/proof-consumer-fixtures/schema-provider/catalog.json +13 -0
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.
|
|
14
|
+
"proof-harness": "0.1.0-next.8"
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
```
|
|
@@ -27,9 +27,253 @@ versions rather than guessing how to interpret them.
|
|
|
27
27
|
|
|
28
28
|
## Prerelease migration notes
|
|
29
29
|
|
|
30
|
-
###
|
|
30
|
+
### 0.1.0-next.9 — anonymous actions and control sensitivity (release candidate)
|
|
31
31
|
|
|
32
|
-
|
|
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
|
+
|
|
138
|
+
### 0.1.0-next.8
|
|
139
|
+
|
|
140
|
+
Published release `0.1.0-next.8`. Consumers pin exactly
|
|
141
|
+
`proof-harness@0.1.0-next.8` when adopting these features.
|
|
142
|
+
|
|
143
|
+
#### Direct RLS clients and Auth administration (#18)
|
|
144
|
+
|
|
145
|
+
The scanner recognizes immutable local bindings returned by
|
|
146
|
+
`createSupabaseRLSClient`, including named-import aliases and awaited calls.
|
|
147
|
+
Their table writes are assessed as RLS writes, never recorded as service-role
|
|
148
|
+
writes. Binding identity is local to its scope: shadowed identifiers,
|
|
149
|
+
reassignment, escaped clients/builders, and unresolved privilege still fail.
|
|
150
|
+
This remains convention-based discovery of the named consumer factories, not
|
|
151
|
+
verification of their implementation or arbitrary wrapper/dataflow analysis.
|
|
152
|
+
|
|
153
|
+
Direct `serviceClient.auth.admin.deleteUser(...)` is recorded in the additive
|
|
154
|
+
capability field `serviceRoleAuthOperations: ["deleteUser"]`, separate from
|
|
155
|
+
`serviceRoleMutations`. This does not pretend the Auth API is a SQL DELETE
|
|
156
|
+
(and does not infer whether its optional soft-delete argument is true).
|
|
157
|
+
Other administrative methods, dynamic method names, detached calls, and
|
|
158
|
+
unresolved administrative privilege remain unassessed.
|
|
159
|
+
|
|
160
|
+
Regenerate capabilities. Changes to Auth operations participate in drift under
|
|
161
|
+
`service_role_mutations_changed`. Coverage requires a package-origin denial
|
|
162
|
+
assertion and an allowed-path control for a user-facing Auth-admin action,
|
|
163
|
+
even when it has no workspace input or voluntary invariant declaration.
|
|
164
|
+
Existing explicit action-gap policy handling remains unchanged; a recognized
|
|
165
|
+
operation alone is not passing authorization evidence. Trace, mission and
|
|
166
|
+
health protocol versions are unchanged; capability schema remains additive v1.
|
|
167
|
+
|
|
168
|
+
#### Catalog providers and rollback INSERT controls (#19, #20)
|
|
169
|
+
|
|
170
|
+
These changes are not in the published `0.1.0-next.7`. They are opt-in; the
|
|
171
|
+
default SQL parser and REST INSERT/DELETE control remain available. No trace,
|
|
172
|
+
mission, or health protocol version changes. Pin exactly `0.1.0-next.8` only
|
|
173
|
+
after it is released.
|
|
174
|
+
|
|
175
|
+
#### Catalog assessment
|
|
176
|
+
|
|
177
|
+
Set `schemaProvider: "scripts/proof/catalog-provider.mjs"` in the protected
|
|
178
|
+
`proof.config.mjs`. `parse`, the parse step of `build`, and both sides of
|
|
179
|
+
`drift` use it instead of the conservative SQL parser. ALTER and DROP are then
|
|
180
|
+
evaluated by PostgreSQL, not inferred from historical CREATE statements.
|
|
181
|
+
|
|
182
|
+
The module must export a default async function receiving:
|
|
183
|
+
|
|
184
|
+
- `protocolVersion: 1`, `rootDir` for the tree being assessed;
|
|
185
|
+
- `migrations`: ordered `{ path, sql, sha256 }` entries from the configured
|
|
186
|
+
migration directory (top-level `.sql` files), and their combined `inputHash`;
|
|
187
|
+
- `withLocalPostgres(connectionUrl, callback)` and
|
|
188
|
+
`readPostgresCatalog(client, schemas = ["public"])` from `proof-harness/node`.
|
|
189
|
+
These are injected so the archived base needs no installed dependencies to
|
|
190
|
+
import these helpers. Keep the provider's own imports dependency-free.
|
|
191
|
+
|
|
192
|
+
The consumer provider must create an empty disposable database, apply its
|
|
193
|
+
versioned platform bootstrap and every supplied migration in order, read the
|
|
194
|
+
catalog, and drop the database in `finally`. Fail on migration or cleanup
|
|
195
|
+
errors. Return `{ protocolVersion: 1, assessed: true, issues: [], tables }` only
|
|
196
|
+
after successful evaluation and cleanup; `tables` is the helper's result.
|
|
197
|
+
The connection helper restricts connections to loopback hosts. The consumer
|
|
198
|
+
owns the local database URL, creation privileges, platform bootstrap, migration
|
|
199
|
+
ordering, schema selection, and any required role setup. Never use the live
|
|
200
|
+
head database as the base catalog, clone an application-populated database,
|
|
201
|
+
or catch a failed migration and return partial facts.
|
|
202
|
+
|
|
203
|
+
Catalog table facts contain `schema`, `name`, `columns` (names), `rls_enabled`,
|
|
204
|
+
`rls_forced`, and `policies`. Each policy contains `name`, `command`, `roles`,
|
|
205
|
+
`mode`, `using`, and `check`. Classification is package-owned. Drift compares
|
|
206
|
+
column names, RLS state including FORCE, and full policy facts; this does not
|
|
207
|
+
add drift coverage for column types, defaults, functions, triggers, or grants.
|
|
208
|
+
Malformed envelopes/facts and changed migration inputs fail assessment and
|
|
209
|
+
replace stale successful output with an unassessed artifact.
|
|
210
|
+
|
|
211
|
+
The schema artifact adds `assessment` with provider protocol/path/hash,
|
|
212
|
+
migration paths/hashes, combined input hash, and repository source hash. This
|
|
213
|
+
is source identity, not a signature. The provider, its platform bootstrap,
|
|
214
|
+
configuration, and installed harness remain protected trust inputs.
|
|
215
|
+
|
|
216
|
+
For generated migrations, retain `driftPrepare` to rebuild them in each tree.
|
|
217
|
+
**Adoption requires a preparatory base commit:** register the provider, its
|
|
218
|
+
platform bootstrap, and config in the consumer before the comparison that
|
|
219
|
+
upgrades the package. Both base and head must declare a provider. Drift rejects
|
|
220
|
+
switching assessment modes across the comparison and never substitutes head
|
|
221
|
+
configuration for a base without the provider. Existing next.7 ignores the new
|
|
222
|
+
keys; registering them does not repair its conservative parser. A consumer
|
|
223
|
+
already blocked on next.7 needs to land this trusted setup separately before
|
|
224
|
+
the candidate's first successful drift run.
|
|
225
|
+
|
|
226
|
+
#### Append-only INSERT controls
|
|
227
|
+
|
|
228
|
+
Configure the protected consumer config explicitly, using your local ports:
|
|
229
|
+
|
|
230
|
+
```js
|
|
231
|
+
insertControl: {
|
|
232
|
+
mode: "rollback",
|
|
233
|
+
databaseUrlEnv: "PROOF_DATABASE_URL",
|
|
234
|
+
role: "service_role",
|
|
235
|
+
supabaseUrl: "http://127.0.0.1:54321",
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Supply the corresponding local PostgreSQL URL through that environment
|
|
240
|
+
variable; never commit its password. The configured REST origin must match
|
|
241
|
+
`NEXT_PUBLIC_SUPABASE_URL`. The database URL must refer to the same stack,
|
|
242
|
+
schema, and migrations as REST; the harness validates the configured origin
|
|
243
|
+
but cannot discover which database a REST server actually serves.
|
|
244
|
+
|
|
245
|
+
The denied INSERT still executes through the authenticated Supabase client.
|
|
246
|
+
Only after an explicit `42501` denial and a service read confirming no write
|
|
247
|
+
does the harness validate the same payload on one PostgreSQL connection:
|
|
248
|
+
BEGIN, SET LOCAL ROLE, INSERT, force deferred constraints, ROLLBACK. The role
|
|
249
|
+
must have the intended INSERT permission. Missing columns keep their database
|
|
250
|
+
defaults; the harness supplies no tenancy values. An invalid payload, failed
|
|
251
|
+
role change, database error, or unacknowledged rollback fails as incomplete
|
|
252
|
+
evidence. The harness records the control assertion only after success and a
|
|
253
|
+
REST reread confirming no matching row remains. There is no consumer callback
|
|
254
|
+
that can stamp a passing control.
|
|
255
|
+
|
|
256
|
+
No DELETE privilege is needed for this control. Transactional trigger writes
|
|
257
|
+
also roll back, but sequence advancement and external side effects do not;
|
|
258
|
+
use only a disposable local stack. This control checks database validity under
|
|
259
|
+
the configured role, not REST middleware or an authenticated user's JWT
|
|
260
|
+
context. Templates whose privileged insert depends on that context must supply
|
|
261
|
+
a suitable database design/control strategy rather than treat this mode as
|
|
262
|
+
equivalent. Product authorization errors must still use SQLSTATE `42501`;
|
|
263
|
+
generic exceptions such as `P0001` are not accepted as authorization evidence.
|
|
264
|
+
|
|
265
|
+
For maintainers, `PROOF_DATABASE_URL=... node tests/postgres-integration.mjs`
|
|
266
|
+
after `pnpm build` exercises a separate temporary database against PostgreSQL,
|
|
267
|
+
including immediate and deferred failures and catalog ALTER/DROP behavior.
|
|
268
|
+
|
|
269
|
+
### 0.1.0-next.7 — audit hardening
|
|
270
|
+
|
|
271
|
+
Published release `0.1.0-next.7` tightens acceptance behavior. Trace
|
|
272
|
+
<a id="unreleased-audit-hardening"></a>
|
|
273
|
+
|
|
274
|
+
### 0.1.0-next.7
|
|
275
|
+
|
|
276
|
+
Published September 7, 2026. This release tightens acceptance behavior. Trace
|
|
33
277
|
schema remains 2, with additive source/execution identity fields. Version 1
|
|
34
278
|
and unversioned trace shapes remain explicitly readable, but CLI acceptance
|
|
35
279
|
requires current provenance. Required consumer changes:
|
package/PROOF_SDK_CONTRACT.md
CHANGED
|
@@ -24,7 +24,7 @@ instead; this document is for anyone _consuming_ the template.
|
|
|
24
24
|
|
|
25
25
|
## Current acceptance semantics
|
|
26
26
|
|
|
27
|
-
The
|
|
27
|
+
The `0.1.0-next.7` audit hardening in [COMPATIBILITY.md](COMPATIBILITY.md#010-next7)
|
|
28
28
|
supersedes older template-specific command examples below. Trace consumers
|
|
29
29
|
must use `decodeTrace`/`decodeTraceBundle` for structural validation and
|
|
30
30
|
`readTraceDirectory` for CLI-equivalent freshness checks. Evidence counts only
|
|
@@ -1221,3 +1221,48 @@ a trace version bump.
|
|
|
1221
1221
|
`.proof/coverage-policy.json` carries its own `schemaVersion` (currently `1`).
|
|
1222
1222
|
It is authored, not derived, and is read only by `pnpm proof:coverage`; consumers
|
|
1223
1223
|
do not need to parse it.
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
## Catalog provider and rollback controls (0.1.0-next.8)
|
|
1227
|
+
|
|
1228
|
+
The protected consumer config may opt into `schemaProvider` protocol 1 and
|
|
1229
|
+
`insertControl.mode: "rollback"`. See [the setup and migration contract](COMPATIBILITY.md)
|
|
1230
|
+
for the exact provider envelope, base-commit requirements, and control configuration.
|
|
1231
|
+
`proof-harness/node` exports `withLocalPostgres`, `readPostgresCatalog`,
|
|
1232
|
+
`rollbackInsertControl`, and the `PostgresCatalogTable`/`PostgresCatalogPolicy` types.
|
|
1233
|
+
Providers own database materialization; the harness validates catalog facts,
|
|
1234
|
+
classifies policies, binds source identity, and compares both trees. A rollback
|
|
1235
|
+
INSERT control is package-executed and can only record passing control evidence
|
|
1236
|
+
after its insert, deferred checks, rollback, and absence reread succeed. The
|
|
1237
|
+
original authenticated request remains the primary authorization assertion.
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
## Direct Auth administration (0.1.0-next.8)
|
|
1241
|
+
|
|
1242
|
+
Capability schema v1 adds optional `serviceRoleAuthOperations`, currently a
|
|
1243
|
+
closed list containing only `"deleteUser"`. It describes direct service-client
|
|
1244
|
+
Auth API calls separately from `serviceRoleMutations` (SQL table writes).
|
|
1245
|
+
Such user-facing actions require denial and allowed-control evidence in
|
|
1246
|
+
coverage even without a workspace input. Changes use the existing
|
|
1247
|
+
`service_role_mutations_changed` drift facet. See `COMPATIBILITY.md` for
|
|
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,13 +17,13 @@ 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.
|
|
20
|
+
pnpm add proof-harness@0.1.0-next.8
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
The package exposes environment-specific entry points:
|
|
24
24
|
|
|
25
25
|
- `proof-harness/shared` — portable types and vocabularies
|
|
26
|
-
- `proof-harness/node` — Node mission validation helpers
|
|
26
|
+
- `proof-harness/node` — Node mission validation and local PostgreSQL helpers
|
|
27
27
|
- `proof-harness/playwright` — Playwright proof assertions and traces
|
|
28
28
|
- `proof-harness/server` — server-side fixtures, guards, and service clients
|
|
29
29
|
- `proof-harness/portable-vocabulary` — dependency-free runtime constants
|
|
@@ -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 `
|
|
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
|
|
@@ -92,16 +92,28 @@ The package is released from this public repository with npm provenance.
|
|
|
92
92
|
Prerelease consumers must pin an exact version rather than using `next`, `^`, or
|
|
93
93
|
`~`; see [COMPATIBILITY.md](./COMPATIBILITY.md) for the compatibility policy.
|
|
94
94
|
|
|
95
|
-
## Audit hardening (0.1.0-next.7
|
|
95
|
+
## Audit hardening (0.1.0-next.7)
|
|
96
96
|
|
|
97
|
-
|
|
97
|
+
This release adds shared fail-closed trace ingestion, source freshness,
|
|
98
98
|
stronger authorization probes, isolated mutation evidence and interruption
|
|
99
99
|
recovery, conservative discovery, and package-owned drift regeneration.
|
|
100
|
-
See [required migration changes](COMPATIBILITY.md#
|
|
100
|
+
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 `0.1.0-next.8` adds direct RLS client and Auth-admin discovery,
|
|
105
|
+
a consumer-owned catalog provider for full SQL histories, and transactional
|
|
106
|
+
INSERT controls for append-only tables. Pin exactly
|
|
107
|
+
`proof-harness@0.1.0-next.8`. See [compatibility and setup](COMPATIBILITY.md#010-next8)
|
|
108
|
+
for the required consumer changes.
|
|
109
|
+
|
|
104
110
|
The harness establishes specific tested claims. Acceptance still requires
|
|
105
111
|
consumer-owned requirements and a trusted CI job that controls the harness,
|
|
106
112
|
policies, and evidence. A passing trace is not a general proof of code quality
|
|
107
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
|
|
@@ -46,8 +47,11 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
46
47
|
// Optional consumer-owned argv used to prepare generated migration inputs
|
|
47
48
|
// in both the working tree and the archived base before parsing.
|
|
48
49
|
driftPrepare: null,
|
|
50
|
+
schemaProvider: null,
|
|
51
|
+
insertControl: null,
|
|
49
52
|
driftSources: ["app", "package.json"],
|
|
50
53
|
mutationCatalog: null,
|
|
54
|
+
controlSensitivityCatalog: null,
|
|
51
55
|
});
|
|
52
56
|
|
|
53
57
|
function mergeConfig(config = {}) {
|
|
@@ -40,6 +40,8 @@ import fs from "node:fs";
|
|
|
40
40
|
import path from "node:path";
|
|
41
41
|
import { pathToFileURL } from "node:url";
|
|
42
42
|
|
|
43
|
+
import { assessWithProvider } from "./proof_schema_provider.mjs";
|
|
44
|
+
import { classifyTable } from "./proof_schema_classification.mjs";
|
|
43
45
|
import { loadProofConfig } from "../config.mjs";
|
|
44
46
|
|
|
45
47
|
const CONFIG = await loadProofConfig();
|
|
@@ -283,87 +285,12 @@ function parsePolicies(sql, sourceFile) {
|
|
|
283
285
|
// Classification
|
|
284
286
|
// ---------------------------------------------------------------------------
|
|
285
287
|
|
|
286
|
-
function policyIndicatesWorkspace(policy) {
|
|
287
|
-
const body = `${policy.using} ${policy.check}`.toLowerCase();
|
|
288
|
-
if (body.includes("get_user_workspace_ids(")) return true;
|
|
289
|
-
if (
|
|
290
|
-
/workspace_id\s+in\s*\(\s*select\s+workspace_id\s+from\s+workspace_members/i.test(
|
|
291
|
-
`${policy.using} ${policy.check}`,
|
|
292
|
-
)
|
|
293
|
-
) {
|
|
294
|
-
return true;
|
|
295
|
-
}
|
|
296
|
-
return false;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
function policyIndicatesUser(policy) {
|
|
300
|
-
const body = `${policy.using} ${policy.check}`;
|
|
301
|
-
// user_id = auth.uid() OR auth.uid() = user_id OR owner/created_by variants
|
|
302
|
-
const patterns = [
|
|
303
|
-
/\b(user_id|owner_id|created_by|invited_by)\s*=\s*\(?\s*(select\s+)?auth\.uid\(\)/i,
|
|
304
|
-
/\bauth\.uid\(\)\s*=\s*(user_id|owner_id|created_by|invited_by)\b/i,
|
|
305
|
-
/\(\s*select\s+auth\.uid\(\)\s*\)\s*=\s*(user_id|owner_id|created_by|invited_by)\b/i,
|
|
306
|
-
/\b(user_id|owner_id|created_by|invited_by)\s*=\s*\(\s*select\s+auth\.uid\(\)\s*\)/i,
|
|
307
|
-
];
|
|
308
|
-
return patterns.some((re) => re.test(body));
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
function policyIsPublicRead(policy) {
|
|
312
|
-
if (policy.command !== "SELECT") return false;
|
|
313
|
-
const body = policy.using.trim().toLowerCase();
|
|
314
|
-
return body === "true";
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
function classifyTable(policies) {
|
|
318
|
-
// No policies at all → admin_only (assumes RLS-enabled Supabase table
|
|
319
|
-
// without authenticated access).
|
|
320
|
-
if (policies.length === 0) return "admin_only";
|
|
321
|
-
|
|
322
|
-
// A policy with no TO clause defaults to `TO public` in Postgres, so
|
|
323
|
-
// treat an empty `roles` list as implicit public (i.e. authenticated
|
|
324
|
-
// users are subject to it). This matches the common
|
|
325
|
-
// `CREATE POLICY foo ON t FOR SELECT USING (...)` pattern.
|
|
326
|
-
const isAuthFacing = (p) =>
|
|
327
|
-
p.roles.length === 0 ||
|
|
328
|
-
p.roles.some((r) => r === "authenticated" || r === "public");
|
|
329
|
-
const authPolicies = policies.filter(isAuthFacing);
|
|
330
|
-
const serviceOnly =
|
|
331
|
-
authPolicies.length === 0 &&
|
|
332
|
-
policies.length > 0 &&
|
|
333
|
-
policies.every(
|
|
334
|
-
(p) => p.roles.length === 1 && p.roles[0] === "service_role",
|
|
335
|
-
);
|
|
336
|
-
|
|
337
|
-
if (serviceOnly) return "service_only";
|
|
338
|
-
|
|
339
|
-
// Check workspace first (most specific).
|
|
340
|
-
if (authPolicies.some(policyIndicatesWorkspace)) return "workspace_scoped";
|
|
341
|
-
if (authPolicies.some(policyIndicatesUser)) return "user_scoped";
|
|
342
|
-
|
|
343
|
-
// public_read: every authenticated-accessible policy is SELECT with
|
|
344
|
-
// USING (true). (Don't classify as public_read if any write policy
|
|
345
|
-
// targets authenticated, that's a different pattern.)
|
|
346
|
-
const readPolicies = authPolicies.filter(
|
|
347
|
-
(p) => p.command === "SELECT" || p.command === "ALL",
|
|
348
|
-
);
|
|
349
|
-
if (
|
|
350
|
-
readPolicies.length > 0 &&
|
|
351
|
-
readPolicies.every(policyIsPublicRead) &&
|
|
352
|
-
authPolicies.every((p) => p.command === "SELECT")
|
|
353
|
-
) {
|
|
354
|
-
return "public_read";
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
if (authPolicies.length === 0) return "admin_only";
|
|
358
|
-
|
|
359
|
-
return "unclassified";
|
|
360
|
-
}
|
|
361
|
-
|
|
362
288
|
// ---------------------------------------------------------------------------
|
|
363
289
|
// Main
|
|
364
290
|
// ---------------------------------------------------------------------------
|
|
365
291
|
|
|
366
|
-
export function main() {
|
|
292
|
+
export async function main() {
|
|
293
|
+
if (CONFIG.schemaProvider) return assessWithProvider(CONFIG);
|
|
367
294
|
const sources = readAllMigrations();
|
|
368
295
|
const unsupported = [];
|
|
369
296
|
const enabledTables = new Set();
|
|
@@ -487,5 +414,5 @@ const invokedDirectly =
|
|
|
487
414
|
import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
488
415
|
|
|
489
416
|
if (invokedDirectly) {
|
|
490
|
-
main();
|
|
417
|
+
await main();
|
|
491
418
|
}
|
|
@@ -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
|
+
}
|
|
@@ -352,6 +352,8 @@ function buildReport() {
|
|
|
352
352
|
// user_scoped by their owner policy, but a service-role action accepting an
|
|
353
353
|
// arbitrary workspaceId still needs an action-layer cross-tenant probe.
|
|
354
354
|
//
|
|
355
|
+
// Auth administrative operations require this boundary independently of
|
|
356
|
+
// workspace inputs because their target may be another user.
|
|
355
357
|
// A denial alone is not enough. A broken/unregistered action also refuses
|
|
356
358
|
// everyone, so the same action target needs a passing allowed-path control.
|
|
357
359
|
const denialKinds = new Set(["authorization", "tenant_isolation"]);
|
|
@@ -366,14 +368,17 @@ function buildReport() {
|
|
|
366
368
|
`${a.module}:${a.name}`.localeCompare(`${b.module}:${b.name}`),
|
|
367
369
|
)) {
|
|
368
370
|
if (
|
|
369
|
-
capability.acceptsWorkspaceId !== true
|
|
371
|
+
(capability.acceptsWorkspaceId !== true &&
|
|
372
|
+
!(capability.serviceRoleAuthOperations ?? []).length) ||
|
|
370
373
|
capability.internalOnly === true
|
|
371
374
|
) {
|
|
372
375
|
continue;
|
|
373
376
|
}
|
|
374
377
|
|
|
375
378
|
const workspaceMutations = capability.serviceRoleMutations ?? [];
|
|
376
|
-
|
|
379
|
+
const authOperations = capability.serviceRoleAuthOperations ?? [];
|
|
380
|
+
if (workspaceMutations.length === 0 && authOperations.length === 0)
|
|
381
|
+
continue;
|
|
377
382
|
|
|
378
383
|
const action = `${capability.module}:${capability.name}`;
|
|
379
384
|
const denialEvidence = assertions.filter(
|
|
@@ -404,6 +409,7 @@ function buildReport() {
|
|
|
404
409
|
action,
|
|
405
410
|
tables: [...new Set(workspaceMutations.map((m) => m.table))].sort(),
|
|
406
411
|
mutations: workspaceMutations,
|
|
412
|
+
authOperations,
|
|
407
413
|
denial: denialProven ? "proven" : "gap",
|
|
408
414
|
control: controlProven ? "proven" : "gap",
|
|
409
415
|
status: proven ? "proven" : acceptance ? "accepted_gap" : "gap",
|
|
@@ -593,13 +599,20 @@ function printReport(report) {
|
|
|
593
599
|
);
|
|
594
600
|
heading(
|
|
595
601
|
"SERVICE-ROLE ACTION BOUNDARIES",
|
|
596
|
-
"Derived from
|
|
602
|
+
"Derived from workspace-targeted service-role writes or privileged Auth operations. Each action needs both an unauthorized/cross-tenant refusal and an allowed-path control; withProof metadata is not consulted.",
|
|
597
603
|
);
|
|
598
604
|
console.log("");
|
|
599
605
|
printTable(
|
|
600
606
|
[
|
|
601
607
|
{ header: "action", get: (r) => r.action },
|
|
602
|
-
{
|
|
608
|
+
{
|
|
609
|
+
header: "effects",
|
|
610
|
+
get: (r) =>
|
|
611
|
+
[
|
|
612
|
+
...r.tables,
|
|
613
|
+
...r.authOperations.map((op) => `auth.admin.${op}`),
|
|
614
|
+
].join(","),
|
|
615
|
+
},
|
|
603
616
|
{ header: "denial", get: (r) => r.denial },
|
|
604
617
|
{ header: "control", get: (r) => r.control },
|
|
605
618
|
{ header: "status", get: (r) => r.status },
|
|
@@ -767,7 +780,7 @@ gap or an unproven action requirement.`);
|
|
|
767
780
|
if (serviceRoleActionGaps.length > 0) {
|
|
768
781
|
problems.push({
|
|
769
782
|
line:
|
|
770
|
-
`[PROOF_FAIL] service_role_action_gap: ${serviceRoleActionGaps.length}
|
|
783
|
+
`[PROOF_FAIL] service_role_action_gap: ${serviceRoleActionGaps.length} privileged action(s) lack mandatory action evidence: ` +
|
|
771
784
|
serviceRoleActionGaps
|
|
772
785
|
.map((g) => {
|
|
773
786
|
const missing = [
|