@vantageos/backend-doctor 0.1.14 → 0.2.0
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/README.md +19 -2
- package/dist/args.d.ts +16 -0
- package/dist/args.js +54 -0
- package/dist/build-stamp.d.ts +13 -0
- package/dist/build-stamp.js +76 -0
- package/dist/build-stamp.json +4 -0
- package/dist/caller-resolution.d.ts +222 -0
- package/dist/caller-resolution.js +1195 -0
- package/dist/cli.js +91 -8
- package/dist/coverage-cli.js +8 -0
- package/dist/detectors/oracle-axis1.d.ts +5 -3
- package/dist/detectors/oracle-axis1.js +24 -15
- package/dist/detectors/oracle-axis2.js +5 -6
- package/dist/detectors/predicates.d.ts +3 -0
- package/dist/detectors/predicates.js +81 -16
- package/dist/detectors/shared.js +5 -5
- package/dist/detectors/source/backfill.js +9 -1
- package/dist/detectors/source/guard-helper-flow.d.ts +112 -0
- package/dist/detectors/source/guard-helper-flow.js +1173 -0
- package/dist/detectors/source/identity.d.ts +51 -2
- package/dist/detectors/source/identity.js +815 -14
- package/dist/detectors/source/isolation.d.ts +11 -1
- package/dist/detectors/source/isolation.js +106 -25
- package/dist/detectors/source/jobs.js +165 -2
- package/dist/detectors/source/readbudget.d.ts +1 -0
- package/dist/detectors/source/readbudget.js +67 -4
- package/dist/detectors/source/security.d.ts +16 -0
- package/dist/detectors/source/security.js +225 -7
- package/dist/detectors/source/write-isolation.d.ts +8 -1
- package/dist/detectors/source/write-isolation.js +64 -30
- package/dist/detectors/standard/r46-returns-validator.d.ts +8 -0
- package/dist/detectors/standard/r46-returns-validator.js +45 -14
- package/dist/detectors/structural.js +14 -10
- package/dist/oracle.d.ts +92 -3
- package/dist/oracle.js +229 -12
- package/dist/registry.d.ts +7 -0
- package/dist/registry.js +48 -6
- package/dist/report.d.ts +11 -1
- package/dist/report.js +15 -2
- package/dist/state-rank.d.ts +5 -0
- package/dist/state-rank.js +9 -0
- package/dist/tally.js +1 -7
- package/dist/target.d.ts +32 -0
- package/dist/target.js +173 -0
- package/fixtures/cr1-classes/_generated/api.d.ts +24 -0
- package/fixtures/cr1-classes/surface.ts +206 -0
- package/fixtures/cr1-empty/nothing.ts +1 -0
- package/fixtures/cr1-refusal-on-resolved/_generated/api.d.ts +24 -0
- package/fixtures/cr1-refusal-on-resolved/doors.ts +180 -0
- package/fixtures/cr1-refusal-on-resolved/lib/auth.ts +104 -0
- package/fixtures/r34-vp-auth-30e821b/auth-805-812.ts.txt +8 -0
- package/fixtures/r34-vp-auth-30e821b/auth-878-890.ts.txt +13 -0
- package/fixtures/vp-writer-tier-1e4ab36.csv +55 -0
- package/package.json +2 -2
package/dist/target.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// TARGET RESOLUTION — the run judges the same backend whichever path names it.
|
|
2
|
+
//
|
|
3
|
+
// The defect this closes: the engine's loadTree computes every file's `rel`
|
|
4
|
+
// relative to the path the CLI was given, so the SAME repository produced two
|
|
5
|
+
// different trees under the two documented invocations:
|
|
6
|
+
// - `backend-doctor <repo>/convex` → rels `messages.ts` (no `convex/` segment)
|
|
7
|
+
// - `backend-doctor <repo>` → rels `convex/messages.ts`, PLUS every other
|
|
8
|
+
// `.ts` file in the repository, including archived copies such as
|
|
9
|
+
// `_archive-v0.2/<shell>/convex/*.ts` that a `(^|/)convex/` predicate then
|
|
10
|
+
// judged as live backend source.
|
|
11
|
+
// Detectors keyed on the `convex/` prefix skipped every file in the first form and
|
|
12
|
+
// over-counted in the second; R-36 looked for the lockfile beside `convex/`; CR-1
|
|
13
|
+
// looked for `_generated/api.d.ts` beside the repository root. Measured on vCRM
|
|
14
|
+
// d47a6d4: R-16 2 vs 50 violations, R-6 could-not-judge vs 19 violations.
|
|
15
|
+
//
|
|
16
|
+
// The fix is ONE normalisation at the entry, never a per-detector patch:
|
|
17
|
+
// repoRoot nearest ancestor-or-self holding package.json or .git (a `convex`
|
|
18
|
+
// dir itself is never the repo root, even when it holds a package.json)
|
|
19
|
+
// convexDir the `convex` segment the argument names, else <repoRoot>/convex
|
|
20
|
+
// scanRoots convexDir and <repoRoot>/mcp-server/src (the two source surfaces the
|
|
21
|
+
// detectors' own predicates name), each loaded by the ENGINE's loader
|
|
22
|
+
// rel relative to repoRoot, so every convex file carries `convex/`
|
|
23
|
+
// tree.root repoRoot, so repository co-artifacts (lockfile, workflows) are found
|
|
24
|
+
// Both invocations therefore reduce to the same (repoRoot, convexDir, files) triple.
|
|
25
|
+
import { existsSync, statSync } from "node:fs";
|
|
26
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
27
|
+
import { runDoctor } from "@vantageos/mcp-doctor";
|
|
28
|
+
function isDir(p) {
|
|
29
|
+
try {
|
|
30
|
+
return statSync(p).isDirectory();
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function hasRepoMarker(dir) {
|
|
37
|
+
return existsSync(join(dir, "package.json")) || existsSync(join(dir, ".git"));
|
|
38
|
+
}
|
|
39
|
+
/** The `convex` dir the argument is (or sits inside), found walking UP from it and
|
|
40
|
+
* stopping at the first repository marker — so a repository that merely lives
|
|
41
|
+
* under some `/…/convex/…` host path is never mistaken for a convex dir. */
|
|
42
|
+
function namedConvexDir(p) {
|
|
43
|
+
for (let dir = p;; dir = dirname(dir)) {
|
|
44
|
+
if (basename(dir) === "convex")
|
|
45
|
+
return dir;
|
|
46
|
+
if (hasRepoMarker(dir) || dirname(dir) === dir)
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Resolve a CLI argument to the backend it names. Pure over the filesystem. */
|
|
51
|
+
export function resolveTarget(arg) {
|
|
52
|
+
const input = resolve(arg);
|
|
53
|
+
const named = namedConvexDir(input);
|
|
54
|
+
const start = named ? dirname(named) : input;
|
|
55
|
+
let repoRoot = start;
|
|
56
|
+
let repoRootBy = "no marker (argument used as-is)";
|
|
57
|
+
for (let dir = start;; dir = dirname(dir)) {
|
|
58
|
+
if (existsSync(join(dir, "package.json"))) {
|
|
59
|
+
repoRoot = dir;
|
|
60
|
+
repoRootBy = "package.json";
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
if (existsSync(join(dir, ".git"))) {
|
|
64
|
+
repoRoot = dir;
|
|
65
|
+
repoRootBy = ".git";
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
if (dirname(dir) === dir)
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
const convexDir = named && isDir(named)
|
|
72
|
+
? named
|
|
73
|
+
: isDir(join(repoRoot, "convex"))
|
|
74
|
+
? join(repoRoot, "convex")
|
|
75
|
+
: undefined;
|
|
76
|
+
const scanRoots = [];
|
|
77
|
+
if (convexDir) {
|
|
78
|
+
scanRoots.push(convexDir);
|
|
79
|
+
const mcpSrc = join(repoRoot, "mcp-server", "src");
|
|
80
|
+
if (isDir(mcpSrc))
|
|
81
|
+
scanRoots.push(mcpSrc);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
// No convex dir anywhere: judge exactly what was named, never a guess.
|
|
85
|
+
scanRoots.push(input);
|
|
86
|
+
}
|
|
87
|
+
return { input, repoRoot, repoRootBy, convexDir, scanRoots };
|
|
88
|
+
}
|
|
89
|
+
/** Load one directory through the ENGINE's own loader (never a second traversal
|
|
90
|
+
* with its own skip list): a capture detector receives the tree runDoctor built. */
|
|
91
|
+
function engineTree(root) {
|
|
92
|
+
let captured;
|
|
93
|
+
runDoctor(root, [
|
|
94
|
+
{
|
|
95
|
+
id: "target-capture",
|
|
96
|
+
run: (tree) => {
|
|
97
|
+
captured = tree;
|
|
98
|
+
return {
|
|
99
|
+
id: "target-capture",
|
|
100
|
+
anchor: root,
|
|
101
|
+
state: "present",
|
|
102
|
+
verdict: "captured",
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
]);
|
|
107
|
+
if (captured === undefined) {
|
|
108
|
+
throw new Error(`backend-doctor: the engine loaded no tree for ${root} — refusing to judge a surface that was never read`);
|
|
109
|
+
}
|
|
110
|
+
return captured;
|
|
111
|
+
}
|
|
112
|
+
/** The ONE tree every detector judges: the scan roots' files, `rel` relative to
|
|
113
|
+
* the repository root, `root` = the repository root. */
|
|
114
|
+
export function loadScopedTree(scope) {
|
|
115
|
+
const files = [];
|
|
116
|
+
let packageJson = null;
|
|
117
|
+
for (const root of scope.scanRoots) {
|
|
118
|
+
const t = engineTree(root);
|
|
119
|
+
packageJson ??= t.packageJson;
|
|
120
|
+
for (const f of t.files) {
|
|
121
|
+
const rel = relative(scope.repoRoot, f.path);
|
|
122
|
+
files.push({ ...f, rel: rel === "" ? basename(f.path) : rel });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { root: scope.repoRoot, files, packageJson };
|
|
126
|
+
}
|
|
127
|
+
/** Files scanned per top-level directory (two segments under the repo root), so a
|
|
128
|
+
* reader sees WHAT was judged — an empty or surprising surface is visible. */
|
|
129
|
+
export function coverageInventory(tree) {
|
|
130
|
+
const inv = new Map();
|
|
131
|
+
for (const f of tree.files) {
|
|
132
|
+
const parts = f.rel.split("/");
|
|
133
|
+
const key = parts.length > 2
|
|
134
|
+
? `${parts[0]}/${parts[1]}`
|
|
135
|
+
: parts.length === 2
|
|
136
|
+
? parts[0]
|
|
137
|
+
: ".";
|
|
138
|
+
inv.set(key, (inv.get(key) ?? 0) + 1);
|
|
139
|
+
}
|
|
140
|
+
return new Map([...inv.entries()].sort((a, b) => a[0].localeCompare(b[0])));
|
|
141
|
+
}
|
|
142
|
+
export function describeScope(scope, tree) {
|
|
143
|
+
const lines = [
|
|
144
|
+
`TARGET: ${scope.input}`,
|
|
145
|
+
` repo root: ${scope.repoRoot} (by ${scope.repoRootBy})`,
|
|
146
|
+
` convex dir: ${scope.convexDir ?? "NONE — no convex/ dir under the repo root; judging the argument as given"}`,
|
|
147
|
+
`COVERAGE INVENTORY (files scanned per dir, rel to repo root; ${tree.files.length} total):`,
|
|
148
|
+
];
|
|
149
|
+
const inv = coverageInventory(tree);
|
|
150
|
+
if (inv.size === 0)
|
|
151
|
+
lines.push(" scanned NOTHING — every source rule below abstains on an empty scan");
|
|
152
|
+
for (const [dir, n] of inv)
|
|
153
|
+
lines.push(` scanned ${dir}: ${n}`);
|
|
154
|
+
return lines.join("\n");
|
|
155
|
+
}
|
|
156
|
+
/** Wrap detectors so each judges the scoped tree instead of the engine's
|
|
157
|
+
* per-argument tree. runDoctor still owns the loud-fail contract. */
|
|
158
|
+
export function onScopedTree(tree, detectors) {
|
|
159
|
+
return detectors.map((d) => ({ ...d, run: () => d.run(tree) }));
|
|
160
|
+
}
|
|
161
|
+
/** Absolute convex dir for a scoped tree (tree.root = repo root) or for a tree
|
|
162
|
+
* rooted AT a convex dir (direct detector tests). `prefix` is what the tree's
|
|
163
|
+
* rels carry in front of a convex-relative path. */
|
|
164
|
+
export function convexRootOf(tree) {
|
|
165
|
+
if (existsSync(join(tree.root, "_generated")) ||
|
|
166
|
+
basename(tree.root) === "convex") {
|
|
167
|
+
return { dir: tree.root, prefix: "" };
|
|
168
|
+
}
|
|
169
|
+
const dir = join(tree.root, "convex");
|
|
170
|
+
if (isDir(dir))
|
|
171
|
+
return { dir, prefix: "convex/" };
|
|
172
|
+
return { dir: tree.root, prefix: "" };
|
|
173
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
/**
|
|
3
|
+
* Generated `api` utility — a MINIMAL, hand-kept replica of the shape
|
|
4
|
+
* `npx convex dev` emits, so CR-1's population parser is exercised on the real
|
|
5
|
+
* artefact shape (the `import type * as` map plus the `fullApi` member list) without
|
|
6
|
+
* this repository depending on the Convex CLI. See fixtures/cr1-classes/surface.ts.
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type * as surface from "../surface.js";
|
|
11
|
+
|
|
12
|
+
declare const fullApi: ApiFromModules<{
|
|
13
|
+
surface: typeof surface;
|
|
14
|
+
}>;
|
|
15
|
+
|
|
16
|
+
export declare const api: FilterApi<
|
|
17
|
+
typeof fullApi,
|
|
18
|
+
FunctionReference<any, "public">
|
|
19
|
+
>;
|
|
20
|
+
|
|
21
|
+
export declare const internal: FilterApi<
|
|
22
|
+
typeof fullApi,
|
|
23
|
+
FunctionReference<any, "internal">
|
|
24
|
+
>;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// CR-1's BIPOLAR PER-CLASS fixture. One member of each of the four classes, plus one
|
|
2
|
+
// `internalQuery` whose exclusion CR-1 must prove from the builder token at its own
|
|
3
|
+
// site. Every member is deliberately minimal: exactly one discriminator distinguishes
|
|
4
|
+
// it from its neighbouring class, so tests/caller-resolution.test.ts can delete that
|
|
5
|
+
// one discriminator and watch the member MIGRATE. Do not "tidy" this file — each line
|
|
6
|
+
// that looks redundant is a pole.
|
|
7
|
+
|
|
8
|
+
// ─── the tree's own helpers, which CR-1 derives (never a name list) ───────────
|
|
9
|
+
|
|
10
|
+
/** A GATE: consults the verified principal and REFUSES on its absent path. */
|
|
11
|
+
async function requireCaller(ctx: Ctx): Promise<void> {
|
|
12
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
13
|
+
if (!identity) {
|
|
14
|
+
throw new Error("RBAC_DENIED: no verified principal.");
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A RESOLVER: consults the principal and RETURNS a scope; the absent path is a
|
|
19
|
+
* typed empty scope, so the handler that calls it must use what it returns. */
|
|
20
|
+
async function resolveScope(ctx: Ctx): Promise<{ orgSlug: string | null }> {
|
|
21
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
22
|
+
if (!identity) {
|
|
23
|
+
return { orgSlug: null };
|
|
24
|
+
}
|
|
25
|
+
return { orgSlug: String(identity.organizationSlug) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ─── RESOLVES — a gate refuses on the absent-identity path before any read ────
|
|
29
|
+
|
|
30
|
+
export const resolvesMember = query({
|
|
31
|
+
args: {},
|
|
32
|
+
handler: async (ctx) => {
|
|
33
|
+
await requireCaller(ctx);
|
|
34
|
+
return await ctx.db.query("rows").collect();
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// ─── FAIL-OPEN — the resolver's result is bound and never read again ──────────
|
|
39
|
+
|
|
40
|
+
export const failOpenMember = query({
|
|
41
|
+
args: {},
|
|
42
|
+
handler: async (ctx) => {
|
|
43
|
+
// biome-ignore lint/correctness/noUnusedVariables: the UNUSED binding IS the pole —
|
|
44
|
+
// this fixture member exists to be a resolved principal that is then discarded.
|
|
45
|
+
// Renaming it `_scope` would delete the discriminator the FAIL-OPEN class is about.
|
|
46
|
+
const scope = await resolveScope(ctx);
|
|
47
|
+
return await ctx.db.query("rows").collect();
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ─── NO-IDENTITY — serves data, consults no principal, no justification ──────
|
|
52
|
+
|
|
53
|
+
export const noIdentityMember = query({
|
|
54
|
+
args: {},
|
|
55
|
+
handler: async (ctx) => {
|
|
56
|
+
return await ctx.db.query("rows").collect();
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ─── NO-DATA — touches no data, and says why, in this source ──────────────────
|
|
61
|
+
|
|
62
|
+
// allow-no-caller-resolution: returns the deployment's build constant only; it
|
|
63
|
+
// reads no table, no storage and no other function, so there is no caller-scoped
|
|
64
|
+
// datum for a principal to scope.
|
|
65
|
+
export const noDataMember = query({
|
|
66
|
+
args: {},
|
|
67
|
+
handler: async () => {
|
|
68
|
+
return { build: "fixture" };
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ─── RESOLVES through a DECLARE-THEN-ASSIGN gate, the shape a reviewer's ──────
|
|
73
|
+
// ─── probe caught the matcher failing to read ─────────────────────────────────
|
|
74
|
+
//
|
|
75
|
+
// ORIGIN, cited rather than invented: this is the shape of
|
|
76
|
+
// `requireServiceAccount` in vantageos-agency/vantage-peers
|
|
77
|
+
// convex/oauth.ts @ 40c71cb55721d99d3bed17058fdc10281103b158 — the local is
|
|
78
|
+
// DECLARED on its own line precisely so the resolution can sit inside a `try`
|
|
79
|
+
// and re-raise naming its own door, then the authority test follows.
|
|
80
|
+
//
|
|
81
|
+
// The matcher originally required the `=` to follow the declarator on one
|
|
82
|
+
// statement, so every gate written this way read as ABSENT and SEVEN correctly
|
|
83
|
+
// service-account-gated oauth handlers were classed FAIL-OPEN. Measured on that
|
|
84
|
+
// same tree: FAIL-OPEN 11 -> 4 and RESOLVES 139 -> 146 once the shape is read,
|
|
85
|
+
// with the classified total unchanged at 179.
|
|
86
|
+
//
|
|
87
|
+
// This site asserts RESOLVES. It is a fixture rather than a unit assertion
|
|
88
|
+
// because the defect was in the POPULATION-wide read, not in one predicate.
|
|
89
|
+
|
|
90
|
+
export const resolvesViaDeclareThenAssignGate = query({
|
|
91
|
+
args: {},
|
|
92
|
+
handler: async (ctx) => {
|
|
93
|
+
await requireServiceAccountShim(ctx, "fixture:resolvesViaDeclareThenAssignGate");
|
|
94
|
+
// It must TOUCH DATA, or CR-1 classes it NO-DATA and the gate question is
|
|
95
|
+
// moot — which is what happened on the first draft of this fixture, and
|
|
96
|
+
// is itself the instrument behaving correctly.
|
|
97
|
+
return await ctx.db.query("rows").collect();
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
async function requireServiceAccountShim(
|
|
102
|
+
ctx: Ctx,
|
|
103
|
+
registration: string,
|
|
104
|
+
): Promise<void> {
|
|
105
|
+
let scope: { isMaster: boolean } | undefined;
|
|
106
|
+
try {
|
|
107
|
+
scope = await resolveScopeShim(ctx);
|
|
108
|
+
} catch (err: unknown) {
|
|
109
|
+
throw new Error(`RBAC_DENIED: "${registration}" unresolved-caller`);
|
|
110
|
+
}
|
|
111
|
+
if (!scope?.isMaster) {
|
|
112
|
+
throw new Error(`RBAC_DENIED: "${registration}" not-the-service-account`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function resolveScopeShim(ctx: Ctx): Promise<{ isMaster: boolean }> {
|
|
117
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
118
|
+
return { isMaster: identity !== null };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── FAIL-OPEN in the SAME declare-then-assign shape — the WIDENING pole ──────
|
|
122
|
+
//
|
|
123
|
+
// WHY THIS EXISTS, and it is the pole that decides whether the shape above is a
|
|
124
|
+
// fix or a hole. `resolvesViaDeclareThenAssignGate` proves the matcher READS
|
|
125
|
+
// declare-then-assign. It does NOT prove the matcher still REFUSES when such a
|
|
126
|
+
// binding is never tested. A reviewer's mutant made every declare-then-assign
|
|
127
|
+
// binding count as a gate regardless of any refusal, and it SURVIVED the whole
|
|
128
|
+
// suite — 465/465, and the real-tree FAIL-OPEN count unmoved at 4. So
|
|
129
|
+
// let s; s = await resolver(ctx); return ctx.db…
|
|
130
|
+
// with no test of `s` would have read as RESOLVES.
|
|
131
|
+
//
|
|
132
|
+
// The widening direction of a security detector is the direction that hides a
|
|
133
|
+
// leak, and it was the untested one. This site is identical to its sibling in
|
|
134
|
+
// SHAPE and differs only in the thing that matters: the resolved value decides
|
|
135
|
+
// NOTHING before the data is served. It must class FAIL-OPEN and appear in
|
|
136
|
+
// violations().
|
|
137
|
+
|
|
138
|
+
export const failsOpenViaDeclareThenAssignNoTest = query({
|
|
139
|
+
args: {},
|
|
140
|
+
handler: async (ctx) => {
|
|
141
|
+
// Declared on its own line, assigned inside a try — the shape the matcher
|
|
142
|
+
// now reads — and then the binding is simply never consulted.
|
|
143
|
+
let scope: { isMaster: boolean } | undefined;
|
|
144
|
+
try {
|
|
145
|
+
scope = await resolveScopeShim(ctx);
|
|
146
|
+
} catch {
|
|
147
|
+
// swallowed: not a refusal. The caller is resolved and discarded.
|
|
148
|
+
}
|
|
149
|
+
void scope;
|
|
150
|
+
return await ctx.db.query("rows").collect();
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ─── the pole that actually kills the widening mutant ─────────────────────────
|
|
155
|
+
//
|
|
156
|
+
// WHY THE PREVIOUS POLE WAS NOT ENOUGH, measured rather than guessed. The
|
|
157
|
+
// reviewer's mutant W makes any declare-then-assign binding count as a REFUSAL,
|
|
158
|
+
// which promotes the enclosing function to a GATE. `failsOpenViaDeclareThenAssign
|
|
159
|
+
// NoTest` above is classed FAIL-OPEN on its own body and stays FAIL-OPEN under W,
|
|
160
|
+
// so it does not bite: W's damage lands on whoever TRUSTS a wrongly-promoted
|
|
161
|
+
// gate, not on the promoted function itself.
|
|
162
|
+
//
|
|
163
|
+
// So the pole has to be the site that DELEGATES its authority to such a helper.
|
|
164
|
+
// `pretendGateDeclareThenAssignNoTest` below is in the declare-then-assign shape
|
|
165
|
+
// and never tests what it resolved — it is not a gate. A registration whose only
|
|
166
|
+
// authority is that helper is therefore FAIL-OPEN. Under W the helper is promoted
|
|
167
|
+
// and the registration reads RESOLVES: a leak classified as safe, which is the
|
|
168
|
+
// single outcome a security detector must never produce.
|
|
169
|
+
|
|
170
|
+
export const failsOpenBehindAPretendGate = query({
|
|
171
|
+
args: {},
|
|
172
|
+
handler: async (ctx) => {
|
|
173
|
+
await pretendGateDeclareThenAssignNoTest(ctx);
|
|
174
|
+
return await ctx.db.query("rows").collect();
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
async function pretendGateDeclareThenAssignNoTest(ctx: Ctx): Promise<void> {
|
|
179
|
+
// Declared on its own line, assigned inside a try — the shape the matcher now
|
|
180
|
+
// reads — and then NEVER consulted. Resolving is not refusing.
|
|
181
|
+
let scope: { isMaster: boolean } | undefined;
|
|
182
|
+
try {
|
|
183
|
+
scope = await resolveScopeShim(ctx);
|
|
184
|
+
} catch {
|
|
185
|
+
// swallowed on purpose: there is no refusal anywhere in this function.
|
|
186
|
+
}
|
|
187
|
+
void scope;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ─── excluded — and the exclusion is proven by the builder token on THIS line ──
|
|
191
|
+
|
|
192
|
+
export const internalMember = internalQuery({
|
|
193
|
+
args: {},
|
|
194
|
+
handler: async (ctx) => {
|
|
195
|
+
return await ctx.db.query("rows").collect();
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ─── minimal ambient shims so this fixture is readable TypeScript on its own ──
|
|
200
|
+
|
|
201
|
+
interface Ctx {
|
|
202
|
+
auth: { getUserIdentity: () => Promise<Record<string, unknown> | null> };
|
|
203
|
+
db: { query: (t: string) => { collect: () => Promise<unknown[]> } };
|
|
204
|
+
}
|
|
205
|
+
declare function query<T>(config: T): T;
|
|
206
|
+
declare function internalQuery<T>(config: T): T;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const nothing = 1;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
/**
|
|
3
|
+
* Generated `api` utility — a MINIMAL, hand-kept replica of the shape
|
|
4
|
+
* `npx convex dev` emits (see fixtures/cr1-classes/_generated/api.d.ts). Only
|
|
5
|
+
* `doors` is listed: `lib/auth.ts` registers no public function, exactly as the
|
|
6
|
+
* product tree's `lib/auth` exposes none to CR-1's population.
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type * as doors from "../doors.js";
|
|
11
|
+
|
|
12
|
+
declare const fullApi: ApiFromModules<{
|
|
13
|
+
doors: typeof doors;
|
|
14
|
+
}>;
|
|
15
|
+
|
|
16
|
+
export declare const api: FilterApi<
|
|
17
|
+
typeof fullApi,
|
|
18
|
+
FunctionReference<any, "public">
|
|
19
|
+
>;
|
|
20
|
+
|
|
21
|
+
export declare const internal: FilterApi<
|
|
22
|
+
typeof fullApi,
|
|
23
|
+
FunctionReference<any, "internal">
|
|
24
|
+
>;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// CR-1 fixture — public doors gated the way vantage-peers 58ed649 gates the OKF
|
|
2
|
+
// export/import doors (okfBundleNode.ts:244-276, okfBundleDurable.ts:110-132,
|
|
3
|
+
// okfBundleNode.ts:783-793), plus the negative poles that must stay FAIL-OPEN.
|
|
4
|
+
// Each member is a pole for tests/cr1-refusal-on-resolved.test.ts.
|
|
5
|
+
import {
|
|
6
|
+
requireMasterDirect,
|
|
7
|
+
requireNamespaceSometimes,
|
|
8
|
+
requireNonEmpty,
|
|
9
|
+
requireTenantNamespace,
|
|
10
|
+
withOrgScope,
|
|
11
|
+
} from "./lib/auth";
|
|
12
|
+
|
|
13
|
+
// ─── helpers, product-shaped ─────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/** ACTION side (okfBundleNode.ts:assertCanExportNamespace): binding through
|
|
16
|
+
* ctx.runQuery(internal.…resolveOrgScopeForAction), refusal through a
|
|
17
|
+
* TRANSITIVE value refuser. */
|
|
18
|
+
async function assertCanExportNamespace(
|
|
19
|
+
ctx: ActionCtx,
|
|
20
|
+
namespace: string,
|
|
21
|
+
door: string,
|
|
22
|
+
): Promise<void> {
|
|
23
|
+
if (namespace.includes("..")) {
|
|
24
|
+
throw new Error(`OKF_NAMESPACE_INVALID: "${namespace}"`);
|
|
25
|
+
}
|
|
26
|
+
const scope = await ctx.runQuery(
|
|
27
|
+
internal.lib.auth.resolveOrgScopeForAction,
|
|
28
|
+
{},
|
|
29
|
+
);
|
|
30
|
+
requireTenantNamespace(scope, namespace, door);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** okfBundleNode.ts:assertCanImport — reaches the gate one call deeper. */
|
|
34
|
+
async function assertCanImport(
|
|
35
|
+
ctx: ActionCtx,
|
|
36
|
+
namespace: string,
|
|
37
|
+
): Promise<void> {
|
|
38
|
+
await assertCanExportNamespace(ctx, namespace, "doors:importBundle");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** V8 side (okfBundleDurable.ts:assertCanExportNamespaceV8): binding through
|
|
42
|
+
* withOrgScope, refusal through the same TRANSITIVE value refuser. */
|
|
43
|
+
async function assertCanExportNamespaceV8(
|
|
44
|
+
ctx: MutationCtx,
|
|
45
|
+
namespace: string,
|
|
46
|
+
door: string,
|
|
47
|
+
): Promise<void> {
|
|
48
|
+
const scope = await withOrgScope(ctx, { refuseWithoutThrow: true });
|
|
49
|
+
requireTenantNamespace(scope, namespace, door);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** binding through withOrgScope, refusal through a DIRECT (one-level) refuser. */
|
|
53
|
+
async function assertMasterDirect(
|
|
54
|
+
ctx: MutationCtx,
|
|
55
|
+
door: string,
|
|
56
|
+
): Promise<void> {
|
|
57
|
+
const scope = await withOrgScope(ctx);
|
|
58
|
+
requireMasterDirect(scope, door);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** NEGATIVE 1 — same resolution, result IGNORED: the refuser is handed the
|
|
62
|
+
* namespace, never the resolved scope. */
|
|
63
|
+
async function assertIgnored(
|
|
64
|
+
ctx: ActionCtx,
|
|
65
|
+
namespace: string,
|
|
66
|
+
door: string,
|
|
67
|
+
): Promise<void> {
|
|
68
|
+
// biome-ignore lint/correctness/noUnusedVariables: the UNUSED binding IS the pole.
|
|
69
|
+
const scope = await ctx.runQuery(
|
|
70
|
+
internal.lib.auth.resolveOrgScopeForAction,
|
|
71
|
+
{},
|
|
72
|
+
);
|
|
73
|
+
requireNonEmpty(namespace, door);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** NEGATIVE 2 — the old okfBundleNode.ts:assertCanValidate shape: the
|
|
77
|
+
* absent-identity branch is a bare `return;`, whatever follows it. */
|
|
78
|
+
async function assertCanValidateOld(
|
|
79
|
+
ctx: ActionCtx,
|
|
80
|
+
namespace: string,
|
|
81
|
+
door: string,
|
|
82
|
+
): Promise<void> {
|
|
83
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
84
|
+
if (identity === null || identity === undefined) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const scope = await ctx.runQuery(
|
|
88
|
+
internal.lib.auth.resolveOrgScopeForAction,
|
|
89
|
+
{},
|
|
90
|
+
);
|
|
91
|
+
requireTenantNamespace(scope, namespace, door);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** NEGATIVE 3 — the refuser proceeds silently on a caller-supplied value. */
|
|
95
|
+
async function assertSometimes(
|
|
96
|
+
ctx: MutationCtx,
|
|
97
|
+
namespace: string,
|
|
98
|
+
door: string,
|
|
99
|
+
): Promise<void> {
|
|
100
|
+
const scope = await withOrgScope(ctx);
|
|
101
|
+
requireNamespaceSometimes(scope, namespace, door);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const readRows = internalQuery({
|
|
105
|
+
args: { namespace: v.string() },
|
|
106
|
+
handler: async (ctx, args) => {
|
|
107
|
+
return await ctx.db
|
|
108
|
+
.query("rows")
|
|
109
|
+
.withIndex("by_namespace", (q) => q.eq("namespace", args.namespace))
|
|
110
|
+
.collect();
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ─── POSITIVE — RESOLVES ─────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
export const exportBundle = action({
|
|
117
|
+
args: { namespace: v.string() },
|
|
118
|
+
handler: async (ctx, args) => {
|
|
119
|
+
await assertCanExportNamespace(ctx, args.namespace, "doors:exportBundle");
|
|
120
|
+
return await ctx.runQuery(internal.doors.readRows, {
|
|
121
|
+
namespace: args.namespace,
|
|
122
|
+
});
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
export const importBundle = action({
|
|
127
|
+
args: { namespace: v.string() },
|
|
128
|
+
handler: async (ctx, args) => {
|
|
129
|
+
await assertCanImport(ctx, args.namespace);
|
|
130
|
+
return await ctx.runQuery(internal.doors.readRows, {
|
|
131
|
+
namespace: args.namespace,
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
export const startExport = mutation({
|
|
137
|
+
args: { namespace: v.string() },
|
|
138
|
+
handler: async (ctx, args) => {
|
|
139
|
+
await assertCanExportNamespaceV8(ctx, args.namespace, "doors:startExport");
|
|
140
|
+
return await ctx.db.insert("jobs", { namespace: args.namespace });
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
export const directDoor = mutation({
|
|
145
|
+
args: { namespace: v.string() },
|
|
146
|
+
handler: async (ctx, args) => {
|
|
147
|
+
await assertMasterDirect(ctx, "doors:directDoor");
|
|
148
|
+
return await ctx.db.insert("jobs", { namespace: args.namespace });
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ─── NEGATIVE — FAIL-OPEN ────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
export const ignoredDoor = action({
|
|
155
|
+
args: { namespace: v.string() },
|
|
156
|
+
handler: async (ctx, args) => {
|
|
157
|
+
await assertIgnored(ctx, args.namespace, "doors:ignoredDoor");
|
|
158
|
+
return await ctx.runQuery(internal.doors.readRows, {
|
|
159
|
+
namespace: args.namespace,
|
|
160
|
+
});
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
export const validateDoor = action({
|
|
165
|
+
args: { namespace: v.string() },
|
|
166
|
+
handler: async (ctx, args) => {
|
|
167
|
+
await assertCanValidateOld(ctx, args.namespace, "doors:validateDoor");
|
|
168
|
+
return await ctx.runQuery(internal.doors.readRows, {
|
|
169
|
+
namespace: args.namespace,
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
export const sometimesDoor = mutation({
|
|
175
|
+
args: { namespace: v.string() },
|
|
176
|
+
handler: async (ctx, args) => {
|
|
177
|
+
await assertSometimes(ctx, args.namespace, "doors:sometimesDoor");
|
|
178
|
+
return await ctx.db.insert("jobs", { namespace: args.namespace });
|
|
179
|
+
},
|
|
180
|
+
});
|