@consiliency/agent-board-schema 1.35.0 → 1.38.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/fixtures/already-applied.json +12 -0
- package/migrations/20260917000100_agent_board_invite_no_downgrade.sql +398 -0
- package/migrations/20260918000100_agent_board_signed_write_enforced_repoint.sql +139 -0
- package/migrations/20260919000100_agent_board_signed_write_model_key.sql +107 -0
- package/package.json +1 -1
- package/schema-manifest.json +30 -3
- package/src/deployment-drift-script.test.mjs +424 -0
- package/src/deployment-drift.test.mjs +301 -0
- package/src/tooling.test.mjs +38 -2
- package/tests/agent_board_bundle_endpoint_compat.test.sql +73 -18
- package/tests/agent_board_consent_substrate.test.sql +8 -1
- package/tests/agent_board_invite_no_downgrade.test.sql +689 -0
- package/tests/agent_board_signed_write_enforced.test.sql +366 -0
- package/tests/agent_board_source_strip_helper.test.sql +100 -1
- package/tests/agent_board_write_enforcement_class.test.sql +1016 -0
- package/tests/fixtures/isolation-substrate.sql +1 -1
- package/tests/fixtures/test-prereqs.sql +22 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
-- SIGNEDWRITEMODEL (message-board#326) — a self-describing sibling for
|
|
2
|
+
-- `signed_write_enforced` in the bundle/schema handshake.
|
|
3
|
+
--
|
|
4
|
+
-- THE PROBLEM. After #324 the flag has the right VALUE and the wrong NAME. It is
|
|
5
|
+
-- called "signed write enforced" and it tests "the signed path is operator-only".
|
|
6
|
+
-- Those are different propositions, and a boolean cannot say which one it means.
|
|
7
|
+
-- The consequence is already written down in specs/operations.md: a reader must
|
|
8
|
+
-- combine the flag with `installed_bundle_version` to know which MEANING they are
|
|
9
|
+
-- looking at — below 1.37.0 it tests the model 20260718000800 abandoned, at or
|
|
10
|
+
-- above it tests the current one. That works and it asks every reader to carry a
|
|
11
|
+
-- version boundary in their head.
|
|
12
|
+
--
|
|
13
|
+
-- WHY NOT RENAME OR RETIRE, which is the strongest argument in this area.
|
|
14
|
+
-- packages/agent-board-client/src/compatibility.ts recognises a health response
|
|
15
|
+
-- with a runtime TYPE GUARD:
|
|
16
|
+
-- "schema_version" in value && "signed_write_enforced" in value && "ready" in value
|
|
17
|
+
-- Drop the key and every deployed client stops recognising a HEALTHY board's
|
|
18
|
+
-- response as a health response at all. The other spelling of retirement — keep
|
|
19
|
+
-- the key, pin it false/null forever — is a permanently-lying instrument, which is
|
|
20
|
+
-- precisely the harm #310 names. Retirement loses both ways, which is why #324
|
|
21
|
+
-- re-pointed rather than replaced, and why this migration ADDS.
|
|
22
|
+
--
|
|
23
|
+
-- WHY ADDING IS SAFE, and how far that claim goes. The guard requires those three
|
|
24
|
+
-- keys to be PRESENT; it does not enumerate the payload. Checked rather than
|
|
25
|
+
-- assumed: no consumer in this repository asserts an exhaustive key set over
|
|
26
|
+
-- `get_bundle_compatibility()` — the pgTAP consumers read individual keys with
|
|
27
|
+
-- `->>`, and the client reads named fields. A consumer OUTSIDE this repository
|
|
28
|
+
-- that pins the whole object would still be broken by any added key, which is a
|
|
29
|
+
-- property of adding keys and not of this one.
|
|
30
|
+
--
|
|
31
|
+
-- NOT PROPOSED: gating on it. A sound gate is constructible
|
|
32
|
+
-- (`installed_bundle_version >= 1.37.0 AND flag true`) but semver strings need
|
|
33
|
+
-- real comparison, and the recorded version is what the materializer WROTE, not
|
|
34
|
+
-- necessarily what is APPLIED. That makes a good argument and a bad gate, so this
|
|
35
|
+
-- ships as a decode hint like the boolean beside it.
|
|
36
|
+
--
|
|
37
|
+
-- FUTURE-PROOFING is the actual point. The next posture change adds a new VALUE
|
|
38
|
+
-- here rather than silently re-pointing a boolean for a second time — which is
|
|
39
|
+
-- exactly what #310 was.
|
|
40
|
+
--
|
|
41
|
+
-- CREATE OR REPLACE with the identical signature, so the existing ACL survives.
|
|
42
|
+
-- The grants are restated anyway and the COMMENT rewritten, because a recreated
|
|
43
|
+
-- SECURITY DEFINER function that does not restate them is the gate-bypass this
|
|
44
|
+
-- repo has already shipped once (#118). `anon` is deliberately NOT granted.
|
|
45
|
+
|
|
46
|
+
CREATE OR REPLACE FUNCTION agent_board.get_bundle_compatibility()
|
|
47
|
+
RETURNS jsonb
|
|
48
|
+
LANGUAGE sql
|
|
49
|
+
STABLE SECURITY DEFINER
|
|
50
|
+
SET search_path TO 'agent_board', 'pg_temp'
|
|
51
|
+
AS $function$
|
|
52
|
+
select jsonb_build_object(
|
|
53
|
+
'schema_version', (select version from agent_board.schema_version limit 1),
|
|
54
|
+
'installed_bundle_version', (select package_version from agent_board.bundle_installation limit 1),
|
|
55
|
+
'installed_bundle_digest', (select bundle_digest from agent_board.bundle_installation limit 1),
|
|
56
|
+
'installed_schema_version', (select schema_version from agent_board.bundle_installation limit 1),
|
|
57
|
+
'source_commit', (select source_commit from agent_board.bundle_installation limit 1),
|
|
58
|
+
-- #326: which PROPOSITION the boolean below is answering, said out loud, so a
|
|
59
|
+
-- reader does not have to consult a version boundary to decode it. A constant
|
|
60
|
+
-- rather than a derivation: it names the model this bundle's predicate encodes,
|
|
61
|
+
-- which is a fact about the migration you are reading, not about the database.
|
|
62
|
+
'signed_write_model', 'operator-only-gateway',
|
|
63
|
+
-- #310: enforcement means the SIGNED path stays operator-only, not that the unsigned path is
|
|
64
|
+
-- unreachable. See 20260918000100's header for what this does and does not establish.
|
|
65
|
+
'signed_write_enforced', (
|
|
66
|
+
-- The anchor. A schema with no signed gateway grants none of them, which would make the
|
|
67
|
+
-- second clause vacuously true; requiring the gateway to exist is what stops "enforced"
|
|
68
|
+
-- from meaning "nothing to enforce".
|
|
69
|
+
exists (
|
|
70
|
+
select 1
|
|
71
|
+
from pg_catalog.pg_proc p
|
|
72
|
+
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
|
|
73
|
+
where n.nspname = 'agent_board'
|
|
74
|
+
and p.proname = 'signed_send_direct_message'
|
|
75
|
+
)
|
|
76
|
+
and not exists (
|
|
77
|
+
-- Catalog-derived over pg_proc ROWS: covers all seven gateways and every overload of
|
|
78
|
+
-- each, and asks the privilege RESOLVER rather than reading a direct-grant view.
|
|
79
|
+
select 1
|
|
80
|
+
from pg_catalog.pg_proc p
|
|
81
|
+
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
|
|
82
|
+
cross join (values ('authenticated'), ('anon')) as r(rolname)
|
|
83
|
+
where n.nspname = 'agent_board'
|
|
84
|
+
and p.proname like 'signed\_%'
|
|
85
|
+
and pg_catalog.has_function_privilege(r.rolname, p.oid, 'EXECUTE')
|
|
86
|
+
)
|
|
87
|
+
),
|
|
88
|
+
-- Ready only once the materializer has recorded an install AND the schema
|
|
89
|
+
-- version handshake resolves.
|
|
90
|
+
'ready', (
|
|
91
|
+
(select version from agent_board.schema_version limit 1) is not null
|
|
92
|
+
and exists (select 1 from agent_board.bundle_installation)
|
|
93
|
+
),
|
|
94
|
+
'checked_at', now()
|
|
95
|
+
);
|
|
96
|
+
$function$;
|
|
97
|
+
|
|
98
|
+
-- Restated rather than relied upon. CREATE OR REPLACE preserves the ACL, but this repo has
|
|
99
|
+
-- already shipped a gate-bypass by recreating a SECURITY DEFINER function without restating its
|
|
100
|
+
-- privileges, so the rule is to restate them in the same migration every time. `anon` is
|
|
101
|
+
-- deliberately NOT granted: it did not hold EXECUTE before this migration and must not gain it.
|
|
102
|
+
revoke all on function agent_board.get_bundle_compatibility() from public;
|
|
103
|
+
grant execute on function agent_board.get_bundle_compatibility() to authenticated;
|
|
104
|
+
grant execute on function agent_board.get_bundle_compatibility() to service_role;
|
|
105
|
+
|
|
106
|
+
comment on function agent_board.get_bundle_compatibility() is
|
|
107
|
+
'Bundle/schema handshake for adopters. `signed_write_model` NAMES the proposition the boolean beside it answers -- `operator-only-gateway` means "the signed gateway exists and no agent_board.signed_* function (all overloads, via has_function_privilege) is EXECUTE-able by authenticated or anon". Read the pair: the model says WHAT is being tested, `signed_write_enforced` says whether it HOLDS. PRESENCE of this key implies the current meaning and needs no semver comparison. ABSENCE does NOT imply the retired one: the boolean changed meaning at bundle 1.37.0 and this key starts at 1.38.0, so on exactly 1.37.x the key is absent while a FALSE is a REAL regression. Absent means consult installed_bundle_version, not "nothing to see". Neither field is a gate. Neither tests that a writer derives its actor from the session and gates -- that half is asserted in agent_board_signed_write_enforced.test.sql, and ONLY for the unsigned counterpart of a signed_* gateway, so a writer with no signed twin is outside both instruments. #310, #326.';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@consiliency/agent-board-schema",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.38.0",
|
|
4
4
|
"description": "Canonical, immutable, exact-pinnable agent_board schema bundle: ordered SQL migrations, digest + compatibility manifest, and deterministic materialize/plan/check tooling for adopters.",
|
|
5
5
|
"repository": "https://github.com/Consiliency/message-board.git",
|
|
6
6
|
"type": "module",
|
package/schema-manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schema": "agent_board_schema_manifest.v1",
|
|
3
3
|
"interface": "IF-0-MBPLANE-BUNDLE-1",
|
|
4
4
|
"package": "@consiliency/agent-board-schema",
|
|
5
|
-
"packageVersion": "1.
|
|
5
|
+
"packageVersion": "1.38.0",
|
|
6
6
|
"schemaVersion": 8,
|
|
7
7
|
"minClientVersion": "1.1.0",
|
|
8
8
|
"source": {
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"usage": "upsert (retention/monitoring defaults; on conflict do nothing)"
|
|
47
47
|
}
|
|
48
48
|
],
|
|
49
|
-
"migrationCount":
|
|
50
|
-
"aggregateSha256": "
|
|
49
|
+
"migrationCount": 137,
|
|
50
|
+
"aggregateSha256": "bfe0b978234464938428a66199b75d2f8d028706517b4674f9ae11cae34c52d1",
|
|
51
51
|
"migrations": [
|
|
52
52
|
{
|
|
53
53
|
"order": 0,
|
|
@@ -1254,6 +1254,33 @@
|
|
|
1254
1254
|
"sourceRef": "message-board",
|
|
1255
1255
|
"bytes": 84162,
|
|
1256
1256
|
"sha256": "d9f05e18094954f2e4ee32bf7b129f0a9ffc39bc800a5efa9cf7938e1555c8dc"
|
|
1257
|
+
},
|
|
1258
|
+
{
|
|
1259
|
+
"order": 134,
|
|
1260
|
+
"id": "20260917000100_agent_board_invite_no_downgrade",
|
|
1261
|
+
"filename": "20260917000100_agent_board_invite_no_downgrade.sql",
|
|
1262
|
+
"origin": "message-board-forward",
|
|
1263
|
+
"sourceRef": "message-board",
|
|
1264
|
+
"bytes": 24929,
|
|
1265
|
+
"sha256": "c205a9a679dca059979972b49c89c3b1b3b8f8e2039bd7675a10578f49f9b35a"
|
|
1266
|
+
},
|
|
1267
|
+
{
|
|
1268
|
+
"order": 135,
|
|
1269
|
+
"id": "20260918000100_agent_board_signed_write_enforced_repoint",
|
|
1270
|
+
"filename": "20260918000100_agent_board_signed_write_enforced_repoint.sql",
|
|
1271
|
+
"origin": "message-board-forward",
|
|
1272
|
+
"sourceRef": "message-board",
|
|
1273
|
+
"bytes": 9965,
|
|
1274
|
+
"sha256": "f76dd01744029bfd0088bd4f6cf6fa5cb93e3c9b7fa5559ad83857cf7f4eb8ce"
|
|
1275
|
+
},
|
|
1276
|
+
{
|
|
1277
|
+
"order": 136,
|
|
1278
|
+
"id": "20260919000100_agent_board_signed_write_model_key",
|
|
1279
|
+
"filename": "20260919000100_agent_board_signed_write_model_key.sql",
|
|
1280
|
+
"origin": "message-board-forward",
|
|
1281
|
+
"sourceRef": "message-board",
|
|
1282
|
+
"bytes": 7204,
|
|
1283
|
+
"sha256": "464ae39acbd96330c5ad1c753fa1204ca290baa0d6f82d7d30d0ead4d2944d37"
|
|
1257
1284
|
}
|
|
1258
1285
|
],
|
|
1259
1286
|
"excludedBridge": {
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
// Tests for the SHIPPED deployment-drift SCRIPT — scripts/check-deployment-drift.mjs.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS FILE EXISTS. deployment-drift.test.mjs covers `evaluate()`, the decision. It
|
|
4
|
+
// does not load the script at all, and an audit measured the consequence: flipping the
|
|
5
|
+
// final line's exit mapping from `UNKNOWN -> 2` to `UNKNOWN -> 0` left the whole suite
|
|
6
|
+
// green, and deleting `makeScrub` entirely left it green. The exit mapping is the property
|
|
7
|
+
// the PR exists for and the scrubber is its security property; both were pinned by nothing.
|
|
8
|
+
//
|
|
9
|
+
// So these tests run the real script as a SUBPROCESS and assert its exit code and its
|
|
10
|
+
// output. Nothing here reimplements it.
|
|
11
|
+
//
|
|
12
|
+
// HERMETIC BY CONSTRUCTION. The script shells out to `npm view` and `git`, and fetches
|
|
13
|
+
// over the network. All three are controlled, and none of them reaches outside this box:
|
|
14
|
+
// - `npm` and `git` are shell shims on a prepended PATH. They also append their argv to
|
|
15
|
+
// a log, which is how the `-L` range below is asserted.
|
|
16
|
+
// - `globalThis.fetch` is replaced by a preloaded module (`--import`), so no request is
|
|
17
|
+
// ever issued. No real project ref is ever named.
|
|
18
|
+
// The tests therefore do not depend on network reachability, on npm auth, or on the
|
|
19
|
+
// clone depth of the checkout running them.
|
|
20
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
21
|
+
import { spawnSync } from "node:child_process";
|
|
22
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, chmodSync, existsSync } from "node:fs";
|
|
23
|
+
import { tmpdir } from "node:os";
|
|
24
|
+
import { dirname, join, resolve } from "node:path";
|
|
25
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
26
|
+
|
|
27
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const ROOT = resolve(HERE, "..", "..", ".."); // packages/agent-board-schema/src -> repo root
|
|
29
|
+
const SCRIPT = join(ROOT, "scripts", "check-deployment-drift.mjs");
|
|
30
|
+
const PKG_JSON = join(ROOT, "packages", "agent-board-schema", "package.json");
|
|
31
|
+
const MANIFEST = join(ROOT, "packages", "agent-board-schema", "schema-manifest.json");
|
|
32
|
+
|
|
33
|
+
const MERGED = JSON.parse(readFileSync(PKG_JSON, "utf8")).version;
|
|
34
|
+
const MANIFEST_JSON = JSON.parse(readFileSync(MANIFEST, "utf8"));
|
|
35
|
+
|
|
36
|
+
// A version several minors BELOW merged, derived so this does not rot when merged bumps.
|
|
37
|
+
function minorsBelow(v, n) {
|
|
38
|
+
const [maj, min, pat] = v.split(".").map(Number);
|
|
39
|
+
return `${maj}.${Math.max(0, min - n)}.${pat}`;
|
|
40
|
+
}
|
|
41
|
+
function minorsAbove(v, n) {
|
|
42
|
+
const [maj, min, pat] = v.split(".").map(Number);
|
|
43
|
+
return `${maj}.${min + n}.${pat}`;
|
|
44
|
+
}
|
|
45
|
+
const FRESH_BUMP = new Date(Date.now() - 3_600_000).toISOString();
|
|
46
|
+
// Postgres renders timestamptz with a space and a two-digit offset; the script's own
|
|
47
|
+
// parser exists for that shape, so the fixture uses it rather than an ISO string.
|
|
48
|
+
const pgStamp = (msAgo) => new Date(Date.now() - msAgo).toISOString().replace("T", " ").replace(/Z$/, "+00");
|
|
49
|
+
// A record that agrees with the repo on every field the check compares.
|
|
50
|
+
const consistentRow = (over = {}) => ({
|
|
51
|
+
package_version: MERGED,
|
|
52
|
+
schema_version: MANIFEST_JSON.schemaVersion,
|
|
53
|
+
bundle_digest: MANIFEST_JSON.aggregateSha256,
|
|
54
|
+
installed_at: pgStamp(1_800_000), // 30 minutes ago — after FRESH_BUMP
|
|
55
|
+
...over,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// A canary that is NOT a credential: it never authenticated anything and is not a real
|
|
59
|
+
// token shape. The 20-character prefix is the thing an audit saw printed verbatim.
|
|
60
|
+
const CANARY = "canary_token_shape_A_not_a_real_secret_value";
|
|
61
|
+
const CANARY_PREFIX = CANARY.slice(0, 20); // "canary_token_shape_A"
|
|
62
|
+
|
|
63
|
+
let SHIMS;
|
|
64
|
+
let ARGV_LOG;
|
|
65
|
+
let PRELOAD;
|
|
66
|
+
|
|
67
|
+
beforeAll(() => {
|
|
68
|
+
SHIMS = mkdtempSync(join(tmpdir(), "drift-shims-"));
|
|
69
|
+
ARGV_LOG = join(SHIMS, "argv.log");
|
|
70
|
+
|
|
71
|
+
// One arg per line, `--` between invocations, so an assertion can read exact argv rather
|
|
72
|
+
// than a space-joined approximation.
|
|
73
|
+
const logArgv = 'printf -- "--\\n" >> "$DRIFT_ARGV_LOG"; for a in "$@"; do printf "%s\\n" "$a" >> "$DRIFT_ARGV_LOG"; done';
|
|
74
|
+
|
|
75
|
+
writeFileSync(
|
|
76
|
+
join(SHIMS, "npm"),
|
|
77
|
+
`#!/bin/sh\n${logArgv}\n[ -n "\${FAKE_NPM_VERSION:-}" ] || exit 1\nprintf "%s\\n" "$FAKE_NPM_VERSION"\n`,
|
|
78
|
+
);
|
|
79
|
+
writeFileSync(
|
|
80
|
+
join(SHIMS, "git"),
|
|
81
|
+
`#!/bin/sh\n${logArgv}\ncase " $* " in\n *" rev-parse "*) printf "%s\\n" "\${FAKE_SHALLOW:-false}" ;;\n *" log "*) [ -n "\${FAKE_BUMP_ISO:-}" ] || exit 1; printf "%s\\n" "$FAKE_BUMP_ISO" ;;\n *) exit 1 ;;\nesac\n`,
|
|
82
|
+
);
|
|
83
|
+
chmodSync(join(SHIMS, "npm"), 0o755);
|
|
84
|
+
chmodSync(join(SHIMS, "git"), 0o755);
|
|
85
|
+
|
|
86
|
+
// No request leaves this machine.
|
|
87
|
+
//
|
|
88
|
+
// The throwing modes carry their probe EARLY in the string, so a passing redaction
|
|
89
|
+
// assertion cannot be truncation in disguise. The RESOLVING modes exist because a review
|
|
90
|
+
// found every mode here threw: `readRunning`'s success mapping and its no-record branch
|
|
91
|
+
// were unreachable from any test, so the only board outcomes ever exercised were the
|
|
92
|
+
// error ones. A stub that can only fail cannot pin what happens when the call works.
|
|
93
|
+
PRELOAD = join(SHIMS, "no-network.mjs");
|
|
94
|
+
writeFileSync(
|
|
95
|
+
PRELOAD,
|
|
96
|
+
[
|
|
97
|
+
'const mode = process.env.FAKE_FETCH_MODE || "";',
|
|
98
|
+
"const respond = (status, body) => ({",
|
|
99
|
+
" ok: status >= 200 && status < 300,",
|
|
100
|
+
" status,",
|
|
101
|
+
" json: async () => body,",
|
|
102
|
+
"});",
|
|
103
|
+
"globalThis.fetch = async () => {",
|
|
104
|
+
' if (mode === "board-row") return respond(200, [JSON.parse(process.env.FAKE_BOARD_ROW)]);',
|
|
105
|
+
' if (mode === "board-no-record") return respond(200, []);',
|
|
106
|
+
' if (mode === "board-http-error") return respond(401, { message: "unauthorized" });',
|
|
107
|
+
' if (mode === "echo-token-prefix") {',
|
|
108
|
+
" throw new Error(`connect ${String(process.env.SUPABASE_ACCESS_TOKEN).slice(0, 20)} ECONNREFUSED upstream`);",
|
|
109
|
+
" }",
|
|
110
|
+
' if (mode === "foreign-github") throw new Error("upstream gho_OTHERPARTYCRED_9999 rejected while proxying");',
|
|
111
|
+
' if (mode === "foreign-unknown-shape") throw new Error("upstream zt9f3k1QweRtyUiOpAsDf rejected while proxying");',
|
|
112
|
+
' if (mode === "foreign-vendor-only") throw new Error("upstream xoxb-SLACKBOTTOKENEXAMPLE rejected while proxying");',
|
|
113
|
+
' if (mode === "foreign-aws-secret") throw new Error("upstream wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY rejected");',
|
|
114
|
+
// The seat's measured over-redaction: a URL inside the RESPONSE text is eaten by the
|
|
115
|
+
// generic layer, because a path has the same shape as a base64 secret. Note the ref
|
|
116
|
+
// embedded here differs from the one passed on the command line, so an assertion
|
|
117
|
+
// about the endpoint cannot be satisfied by this string surviving.
|
|
118
|
+
' if (mode === "http-500-with-url") {',
|
|
119
|
+
' throw new Error("HTTP 500 from https://api.supabase.com/v1/projects/some-other-ref-in-the-message/database/query");',
|
|
120
|
+
" }",
|
|
121
|
+
' throw new Error("network disabled in test");',
|
|
122
|
+
"};",
|
|
123
|
+
"",
|
|
124
|
+
].join("\n"),
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
afterAll(() => {
|
|
129
|
+
if (SHIMS) rmSync(SHIMS, { recursive: true, force: true });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
function runScript(args = [], env = {}) {
|
|
133
|
+
const r = spawnSync(process.execPath, [SCRIPT, ...args], {
|
|
134
|
+
cwd: ROOT,
|
|
135
|
+
encoding: "utf8",
|
|
136
|
+
timeout: 60_000,
|
|
137
|
+
env: {
|
|
138
|
+
...process.env,
|
|
139
|
+
PATH: `${SHIMS}:${process.env.PATH}`,
|
|
140
|
+
NODE_OPTIONS: `--import ${pathToFileURL(PRELOAD).href}`,
|
|
141
|
+
DRIFT_ARGV_LOG: ARGV_LOG,
|
|
142
|
+
FAKE_BUMP_ISO: FRESH_BUMP,
|
|
143
|
+
FAKE_SHALLOW: "false",
|
|
144
|
+
FAKE_FETCH_MODE: "",
|
|
145
|
+
SUPABASE_ACCESS_TOKEN: CANARY,
|
|
146
|
+
...env,
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
return { ...r, out: `${r.stdout ?? ""}${r.stderr ?? ""}` };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// The exit mapping. This is the whole point of the change, and it was pinned by nothing:
|
|
154
|
+
// `UNKNOWN -> 0` left all 38 evaluate() tests green.
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
describe("the shipped script's exit mapping", () => {
|
|
157
|
+
it("exits 0 when merged and published agree — OK is reachable", () => {
|
|
158
|
+
const r = runScript([], { FAKE_NPM_VERSION: MERGED });
|
|
159
|
+
expect(r.status).toBe(0);
|
|
160
|
+
expect(r.out).toMatch(/no drift/);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("exits 1 on real drift — DRIFT is not folded into success", () => {
|
|
164
|
+
const r = runScript([], { FAKE_NPM_VERSION: minorsBelow(MERGED, 10) });
|
|
165
|
+
expect(r.status).toBe(1);
|
|
166
|
+
expect(r.out).toMatch(/DRIFT/);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("exits 2 when a leg cannot be determined — UNKNOWN is NOT a pass", () => {
|
|
170
|
+
// npm shim exits non-zero: the published version is undeterminable.
|
|
171
|
+
const r = runScript([], { FAKE_NPM_VERSION: "" });
|
|
172
|
+
expect(r.status).toBe(2);
|
|
173
|
+
expect(r.out).toMatch(/UNKNOWN/);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("exits 2, not 0, when the board leg cannot be read", () => {
|
|
177
|
+
const r = runScript(["--board", "fixture-ref-not-a-real-project"], {
|
|
178
|
+
FAKE_NPM_VERSION: MERGED,
|
|
179
|
+
FAKE_FETCH_MODE: "network disabled",
|
|
180
|
+
});
|
|
181
|
+
expect(r.status).toBe(2);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("rejects a non-numeric threshold with exit 2 rather than passing everything", () => {
|
|
185
|
+
const r = runScript(["--max-days", "not-a-number"], { FAKE_NPM_VERSION: MERGED });
|
|
186
|
+
expect(r.status).toBe(2);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("refuses to date the bump on a shallow clone, so an unpublished gap is UNKNOWN", () => {
|
|
190
|
+
// FAKE_SHALLOW was never true in any test, so the shallow-clone refusal the previous
|
|
191
|
+
// round added was driven by nothing. Making isShallowClone() return false
|
|
192
|
+
// unconditionally reds this: the shimmed bump date would come back fresh and a
|
|
193
|
+
// one-version gap would report OK / exit 0.
|
|
194
|
+
const r = runScript([], { FAKE_NPM_VERSION: minorsBelow(MERGED, 1), FAKE_SHALLOW: "true" });
|
|
195
|
+
expect(r.status).toBe(2);
|
|
196
|
+
expect(r.out).toMatch(/bump date could not be determined/);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// The board leg. Every mocked fetch used to THROW, so `readRunning`'s success mapping and
|
|
202
|
+
// its no-record branch were unreachable from any test and only the error outcomes were
|
|
203
|
+
// ever exercised.
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
describe("the board leg, when the query actually succeeds", () => {
|
|
206
|
+
const boardArgs = ["--board", "fixture-ref-not-a-real-project"];
|
|
207
|
+
|
|
208
|
+
it("maps a consistent install record to OK — the success path is reachable at all", () => {
|
|
209
|
+
const r = runScript(boardArgs, {
|
|
210
|
+
FAKE_NPM_VERSION: MERGED,
|
|
211
|
+
FAKE_FETCH_MODE: "board-row",
|
|
212
|
+
FAKE_BOARD_ROW: JSON.stringify(consistentRow()),
|
|
213
|
+
});
|
|
214
|
+
expect(r.status).toBe(0);
|
|
215
|
+
expect(r.out).toMatch(/record consistent/);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("treats an empty result set as NO install record — DRIFT, not an empty pass", () => {
|
|
219
|
+
const r = runScript(boardArgs, { FAKE_NPM_VERSION: MERGED, FAKE_FETCH_MODE: "board-no-record" });
|
|
220
|
+
expect(r.status).toBe(1);
|
|
221
|
+
expect(r.out).toMatch(/NO install record/);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("treats a non-2xx response as UNKNOWN, not as an absent record", () => {
|
|
225
|
+
const r = runScript(boardArgs, { FAKE_NPM_VERSION: MERGED, FAKE_FETCH_MODE: "board-http-error" });
|
|
226
|
+
expect(r.status).toBe(2);
|
|
227
|
+
expect(r.out).toMatch(/HTTP 401/);
|
|
228
|
+
// The status-code path names the endpoint too. Without this, only the exception path's
|
|
229
|
+
// `endpointOf` was pinned and this one could lose it silently.
|
|
230
|
+
expect(r.out).toContain("POST /v1/projects/fixture-ref-not-a-real-project/database/query");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("reports a board AHEAD of merged as DRIFT, end to end — it exits 1, not 0", () => {
|
|
234
|
+
// The round-3 reproduction: a board running a version main does not have, with a
|
|
235
|
+
// digest and schema_version that MATCH, reported ok and exited 0.
|
|
236
|
+
const r = runScript(boardArgs, {
|
|
237
|
+
FAKE_NPM_VERSION: MERGED,
|
|
238
|
+
FAKE_FETCH_MODE: "board-row",
|
|
239
|
+
FAKE_BOARD_ROW: JSON.stringify(consistentRow({ package_version: minorsAbove(MERGED, 1) })),
|
|
240
|
+
});
|
|
241
|
+
expect(r.status).toBe(1);
|
|
242
|
+
expect(r.out).toMatch(/AHEAD of merged/);
|
|
243
|
+
expect(r.out).not.toMatch(/record consistent/);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// The scrubber, end to end through the shipped script. Deleting `makeScrub` left the
|
|
249
|
+
// previous suite green.
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
describe("credentials never reach the output", () => {
|
|
252
|
+
it("redacts a TRUNCATED echo of the token we hold, not just an exact match", () => {
|
|
253
|
+
const r = runScript(["--board", "fixture-ref-not-a-real-project", "--json"], {
|
|
254
|
+
FAKE_NPM_VERSION: MERGED,
|
|
255
|
+
FAKE_FETCH_MODE: "echo-token-prefix",
|
|
256
|
+
});
|
|
257
|
+
expect(r.out).not.toContain(CANARY);
|
|
258
|
+
expect(r.out).not.toContain(CANARY_PREFIX);
|
|
259
|
+
// Proves redaction did the work, not the 200-character truncation.
|
|
260
|
+
expect(r.out).toMatch(/<redacted>/);
|
|
261
|
+
expect(r.status).toBe(2);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// The probe from the original finding. NOTE, because a commit message got this wrong:
|
|
265
|
+
// this probe is matched by the vendor layer AND by the generic layer, so it does NOT
|
|
266
|
+
// isolate either one — deleting the vendor layer leaves it green. It pins that the
|
|
267
|
+
// reported probe stops printing, nothing more. The isolating probe is the next test.
|
|
268
|
+
it("redacts a foreign credential of a KNOWN vendor shape we never held", () => {
|
|
269
|
+
const r = runScript(["--board", "fixture-ref-not-a-real-project", "--json"], {
|
|
270
|
+
FAKE_NPM_VERSION: MERGED,
|
|
271
|
+
FAKE_FETCH_MODE: "foreign-github",
|
|
272
|
+
});
|
|
273
|
+
expect(r.out).not.toContain("gho_OTHERPARTYCRED_9999");
|
|
274
|
+
expect(r.out).not.toContain("OTHERPARTYCRED");
|
|
275
|
+
expect(r.out).toMatch(/<redacted>/);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("redacts a vendor-prefixed credential the generic layer CANNOT see — pins layer 2 alone", () => {
|
|
279
|
+
// All letters after the prefix, so the generic layer's digit lookahead fails and only
|
|
280
|
+
// the vendor-prefix layer can catch it. Deleting that layer reds this and nothing else.
|
|
281
|
+
const r = runScript(["--board", "fixture-ref-not-a-real-project", "--json"], {
|
|
282
|
+
FAKE_NPM_VERSION: MERGED,
|
|
283
|
+
FAKE_FETCH_MODE: "foreign-vendor-only",
|
|
284
|
+
});
|
|
285
|
+
expect(r.out).not.toContain("xoxb-SLACKBOTTOKENEXAMPLE");
|
|
286
|
+
expect(r.out).not.toContain("SLACKBOTTOKEN");
|
|
287
|
+
expect(r.out).toMatch(/<redacted>/);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("redacts an AWS secret access key — the `/` that split it out of the old class", () => {
|
|
291
|
+
// `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY` printed intact: the old generic class
|
|
292
|
+
// excluded `/`, so it split into 13-, 7- and 18-character pieces and the 18-character
|
|
293
|
+
// piece was all letters. Long and mixed-class — inside what the header claimed to
|
|
294
|
+
// cover. Each surviving fragment is asserted separately, so a partial leak fails too.
|
|
295
|
+
const r = runScript(["--board", "fixture-ref-not-a-real-project", "--json"], {
|
|
296
|
+
FAKE_NPM_VERSION: MERGED,
|
|
297
|
+
FAKE_FETCH_MODE: "foreign-aws-secret",
|
|
298
|
+
});
|
|
299
|
+
expect(r.out).not.toContain("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY");
|
|
300
|
+
expect(r.out).not.toContain("wJalrXUtnFEMI");
|
|
301
|
+
expect(r.out).not.toContain("bPxRfiCYEXAMPLEKEY");
|
|
302
|
+
expect(r.out).toMatch(/<redacted>/);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("redacts a foreign credential of an UNKNOWN shape — the generic mixed-class layer", () => {
|
|
306
|
+
const r = runScript(["--board", "fixture-ref-not-a-real-project", "--json"], {
|
|
307
|
+
FAKE_NPM_VERSION: MERGED,
|
|
308
|
+
FAKE_FETCH_MODE: "foreign-unknown-shape",
|
|
309
|
+
});
|
|
310
|
+
expect(r.out).not.toContain("zt9f3k1QweRtyUiOpAsDf");
|
|
311
|
+
expect(r.out).toMatch(/<redacted>/);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("an HTTP failure still names the board and the endpoint even when the message is redacted", () => {
|
|
315
|
+
// Redaction must not be able to destroy diagnosability. The generic layer eats URL
|
|
316
|
+
// paths (a path and a base64 secret have the same shape), so the ref and the endpoint
|
|
317
|
+
// are printed from this process's OWN variables rather than recovered from the
|
|
318
|
+
// response. Removing `endpointOf(ref)` from the error string reds this.
|
|
319
|
+
const r = runScript(["--board", "fixture-ref-not-a-real-project", "--json"], {
|
|
320
|
+
FAKE_NPM_VERSION: MERGED,
|
|
321
|
+
FAKE_FETCH_MODE: "http-500-with-url",
|
|
322
|
+
});
|
|
323
|
+
// The path we supplied survives in full...
|
|
324
|
+
expect(r.out).toContain("POST /v1/projects/fixture-ref-not-a-real-project/database/query");
|
|
325
|
+
// ...while the path that came back over the network was still redacted, so the
|
|
326
|
+
// assertion above cannot be passing on the response text.
|
|
327
|
+
expect(r.out).not.toContain("some-other-ref-in-the-message");
|
|
328
|
+
expect(r.out).toMatch(/<redacted>/);
|
|
329
|
+
expect(r.status).toBe(2);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("never places a credential in a child process's argv", () => {
|
|
333
|
+
runScript(["--board", "fixture-ref-not-a-real-project"], {
|
|
334
|
+
FAKE_NPM_VERSION: MERGED,
|
|
335
|
+
FAKE_FETCH_MODE: "network disabled",
|
|
336
|
+
});
|
|
337
|
+
const argv = readFileSync(ARGV_LOG, "utf8");
|
|
338
|
+
expect(argv).not.toContain(CANARY);
|
|
339
|
+
expect(argv).not.toContain(CANARY_PREFIX);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
// Manifest expectations: the exact field, not a pattern.
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
describe("the digest expectation comes from the named manifest field", () => {
|
|
347
|
+
it("carries schema-manifest.json's aggregateSha256 into the facts", () => {
|
|
348
|
+
const r = runScript(["--json"], { FAKE_NPM_VERSION: MERGED });
|
|
349
|
+
const doc = JSON.parse(r.stdout);
|
|
350
|
+
expect(MANIFEST_JSON.aggregateSha256).toBeTruthy();
|
|
351
|
+
expect(doc.facts.expectedDigest).toBe(MANIFEST_JSON.aggregateSha256);
|
|
352
|
+
expect(doc.facts.expectedSchemaVersion).toBe(MANIFEST_JSON.schemaVersion);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
// The `git log -L` range. A seat reported that `/"version":/,+1` spans TWO lines, so an
|
|
358
|
+
// edit to the ADJACENT line would reset the bump date. Measured on git 2.55, `,+N` is N
|
|
359
|
+
// lines TOTAL, so `,+1` is one line and the finding is refuted. This test pins BOTH halves
|
|
360
|
+
// so the refutation is not just a comment: it reads the range the script actually passes,
|
|
361
|
+
// then runs REAL git with that exact range against a fixture where the line after
|
|
362
|
+
// `"version":` was edited later than the version itself.
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
describe("the bump date comes from the version line alone", () => {
|
|
365
|
+
const git = (cwd, args, env = {}) =>
|
|
366
|
+
spawnSync("git", args, { cwd, encoding: "utf8", env: { ...process.env, ...env } });
|
|
367
|
+
|
|
368
|
+
function fixtureRepo(dir) {
|
|
369
|
+
mkdirSync(dir, { recursive: true });
|
|
370
|
+
const commit = (msg, body, iso) => {
|
|
371
|
+
writeFileSync(join(dir, "package.json"), body);
|
|
372
|
+
git(dir, ["add", "-A"]);
|
|
373
|
+
git(dir, ["-c", "user.email=t@example.invalid", "-c", "user.name=t", "commit", "-qm", msg], {
|
|
374
|
+
GIT_AUTHOR_DATE: iso,
|
|
375
|
+
GIT_COMMITTER_DATE: iso,
|
|
376
|
+
});
|
|
377
|
+
};
|
|
378
|
+
git(dir, ["init", "-q", "."]);
|
|
379
|
+
const doc = (v, d) => `{\n "name": "x",\n "version": "${v}",\n "description": "${d}",\n "type": "module"\n}\n`;
|
|
380
|
+
commit("base", doc("1.0.0", "one"), "2026-01-01T00:00:00Z");
|
|
381
|
+
commit("bump", doc("1.1.0", "one"), "2026-02-01T00:00:00Z");
|
|
382
|
+
commit("adjacent-line-edit", doc("1.1.0", "TWO"), "2026-06-01T00:00:00Z");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
it("passes a range that selects the version bump and NOT the adjacent-line edit", () => {
|
|
386
|
+
// 1. what range does the shipped script actually pass?
|
|
387
|
+
runScript([], { FAKE_NPM_VERSION: MERGED });
|
|
388
|
+
const lines = readFileSync(ARGV_LOG, "utf8").split("\n");
|
|
389
|
+
const i = lines.lastIndexOf("-L");
|
|
390
|
+
expect(i).toBeGreaterThan(-1);
|
|
391
|
+
const range = lines[i + 1];
|
|
392
|
+
expect(range).toMatch(/:packages\/agent-board-schema\/package\.json$/);
|
|
393
|
+
|
|
394
|
+
// 2. does that range, under REAL git, select only the version line?
|
|
395
|
+
const dir = mkdtempSync(join(tmpdir(), "drift-Lfixture-"));
|
|
396
|
+
try {
|
|
397
|
+
fixtureRepo(dir);
|
|
398
|
+
// The fixture must really have the three commits; an empty `git log` result would
|
|
399
|
+
// otherwise satisfy `not.toBe("adjacent-line-edit")` while asserting nothing.
|
|
400
|
+
expect((git(dir, ["log", "--format=%s"]).stdout ?? "").trim().split("\n")).toEqual([
|
|
401
|
+
"adjacent-line-edit",
|
|
402
|
+
"bump",
|
|
403
|
+
"base",
|
|
404
|
+
]);
|
|
405
|
+
// The range itself contains colons (`/"version":/,+1`), so split on the LAST one.
|
|
406
|
+
const spec = `${range.slice(0, range.lastIndexOf(":"))}:package.json`;
|
|
407
|
+
const out = git(dir, ["log", "-1", "--format=%s", "-L", spec]).stdout ?? "";
|
|
408
|
+
const subject = out.split("\n")[0].trim();
|
|
409
|
+
expect(subject).toBe("bump");
|
|
410
|
+
expect(subject).not.toBe("adjacent-line-edit");
|
|
411
|
+
} finally {
|
|
412
|
+
rmSync(dir, { recursive: true, force: true });
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
describe("the script under test is the one that ships", () => {
|
|
418
|
+
it("is the file CI runs", () => {
|
|
419
|
+
expect(existsSync(SCRIPT)).toBe(true);
|
|
420
|
+
expect(readFileSync(join(ROOT, ".github", "workflows", "ci.yml"), "utf8")).toContain(
|
|
421
|
+
"node scripts/check-deployment-drift.mjs",
|
|
422
|
+
);
|
|
423
|
+
});
|
|
424
|
+
});
|