qualia-framework 7.0.0 → 7.1.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/CHANGELOG.md +58 -0
- package/bin/cli.js +6 -1
- package/bin/dep-verify.mjs +328 -0
- package/bin/install.js +9 -0
- package/bin/recall.js +12 -1
- package/bin/report-payload.js +4 -1
- package/bin/runtime-manifest.js +1 -0
- package/bin/state.js +11 -4
- package/bin/trust-score.js +1 -1
- package/hooks/pre-deploy-gate.js +54 -3
- package/hooks/secret-guard.js +162 -0
- package/package.json +4 -1
- package/skills/qualia-build/SKILL.md +8 -1
- package/skills/qualia-recall/SKILL.md +6 -1
- package/skills/qualia-ship/SKILL.md +2 -1
- package/skills/qualia-verify/SKILL.md +11 -4
- package/tests/bin.test.sh +2 -2
- package/tests/dep-verify.test.sh +247 -0
- package/tests/hooks.test.sh +97 -0
- package/tests/install-smoke.test.sh +4 -3
- package/tests/lib.test.sh +4 -4
- package/tests/recall.test.sh +12 -11
- package/tests/run-all.sh +3 -0
- package/tests/runner.js +2 -2
- package/tests/runtime-parity.test.sh +62 -0
- package/tests/secret-guard.test.sh +92 -0
- package/tests/state.test.sh +35 -0
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
8
8
|
> Note: git tags for historical versions were not retained; commit references are approximate
|
|
9
9
|
> and dates reflect commit history rather than npm publish timestamps.
|
|
10
10
|
|
|
11
|
+
## [7.1.0] - 2026-06-25 (harness gates — hallucinated deps, secrets, model routing)
|
|
12
|
+
|
|
13
|
+
Hardening pass: reviewed Google's "The New SDLC With Vibe Coding" whitepaper
|
|
14
|
+
against the framework, then ran a deep multi-agent audit of the result and fixed
|
|
15
|
+
what it surfaced. Theme: move enforcement out of model-read prose and into
|
|
16
|
+
deterministic gates.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- **`bin/dep-verify.mjs` — hallucinated/slopsquatted dependency gate.** Flags an
|
|
20
|
+
import only when its package is BOTH undeclared in `package.json` AND absent
|
|
21
|
+
from `node_modules` — the paper's #1 named AI-generated-code security failure
|
|
22
|
+
mode. A string/regex/comment-aware parser with statement-anchored matching
|
|
23
|
+
keeps false positives near zero. Wired into `/qualia-verify`, `/qualia-ship`,
|
|
24
|
+
and the `pre-deploy-gate` hook (OWNER-only `QUALIA_SKIP_DEPCHECK` escape).
|
|
25
|
+
- **`hooks/secret-guard.js` — commit-time secret gate.** Fail-closed PreToolUse
|
|
26
|
+
hook on `git commit*` that scans staged content for service_role keys, private
|
|
27
|
+
keys, AWS/OpenAI/GitHub tokens, and staged `.env` files; blocks (exit 2),
|
|
28
|
+
never prints the matched value, OWNER-only `QUALIA_SECRET_SKIP` escape.
|
|
29
|
+
Enforces two constitution non-negotiables that were prose-only. Registered on
|
|
30
|
+
both runtimes; added to `trust-score` REQUIRED_HOOKS.
|
|
31
|
+
- **Intelligent model routing.** `/qualia-build` builders default to `sonnet`
|
|
32
|
+
(escalate to frontier for architect / high-complexity / auth-payment-migration;
|
|
33
|
+
`haiku` for mechanical sweeps); `/qualia-verify` panel verifiers run on
|
|
34
|
+
`sonnet`, skeptic adjudication on frontier. The token-economy OpEx lever.
|
|
35
|
+
- **`tests/runtime-parity.test.sh`.** Asserts the Codex blocking-gate set is a
|
|
36
|
+
superset of the critical gates so Claude/Codex hook drift can't recur.
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
- **Deploy gates now fail CLOSED.** `pre-deploy-gate` blocked only on exit 1, so a
|
|
40
|
+
scanner crash or timeout printed "✓" and deployed anyway; both the anti-slop and
|
|
41
|
+
dep-verify blocks now block on any non-clean outcome.
|
|
42
|
+
- **Codex gate parity.** `task-write-guard.js` (the off-contract-write gate) was
|
|
43
|
+
registered on Claude but omitted from the Codex hooks — so on Codex a builder
|
|
44
|
+
could write fabricated/out-of-scope files unblocked. Now registered on both.
|
|
45
|
+
- **A5 failed-status gap closure (`state.js`).** The A5 increment path sets status
|
|
46
|
+
`"failed"` on a failed verify (legacy keeps `"verified"`+fail); the gap-cycle
|
|
47
|
+
circuit breaker, the re-plan transition (`VALID_FROM`), the gap-cycle counter,
|
|
48
|
+
and `next_command` all only recognized `"verified"` — so the breaker never fired
|
|
49
|
+
(unbounded gap cycles), re-plan was rejected, and routing dead-ended at
|
|
50
|
+
`/qualia`. All four spots now recognize `"failed"`.
|
|
51
|
+
|
|
52
|
+
### Tests
|
|
53
|
+
- New suites: dep-verify, secret-guard, runtime-parity; A5 regression cases in
|
|
54
|
+
`state.test.sh`; pre-deploy-gate fail-closed + dep cases in `hooks.test.sh`.
|
|
55
|
+
- Full suite green: **27 shell suites + 179 node tests**.
|
|
56
|
+
|
|
57
|
+
## [7.0.1] - 2026-06-22 (/qualia-recall is OWNER-only)
|
|
58
|
+
|
|
59
|
+
### Changed — `/qualia-recall` restricted to OWNER
|
|
60
|
+
- `recall.js` now resolves the install role up front and **refuses any role other
|
|
61
|
+
than `OWNER`** (exit 3, clear message) — a deterministic gate, not a prose note.
|
|
62
|
+
Previously the command was available to everyone and only the *vault content*
|
|
63
|
+
was role-filtered. Employees keep `/qualia-learn` (write side) and the read-only
|
|
64
|
+
memory MCP for ALL_ROLES wiki content; the curated cross-project recall is the
|
|
65
|
+
OWNER's surface. Skill description + body mark it OWNER-only; the employee
|
|
66
|
+
manual drops it from the command reference. `tests/recall.test.sh` now asserts
|
|
67
|
+
OWNER→allowed, EMPLOYEE/MANAGER/unknown→refused (20 cases). All 24 suites green.
|
|
68
|
+
|
|
11
69
|
## [7.0.0] - 2026-06-22 (v7 — the restructure is complete)
|
|
12
70
|
|
|
13
71
|
The v7 milestone release. The 6.12→6.29 line landed the whole v7 restructure
|
package/bin/cli.js
CHANGED
|
@@ -214,6 +214,7 @@ const QUALIA_HOOK_FILES = [
|
|
|
214
214
|
"env-empty-guard.js",
|
|
215
215
|
"supabase-destructive-guard.js",
|
|
216
216
|
"vercel-account-guard.js",
|
|
217
|
+
"secret-guard.js",
|
|
217
218
|
];
|
|
218
219
|
const QUALIA_LEGACY_HOOK_FILES = [
|
|
219
220
|
"block-env-edit.js", // removed in v3.2.0
|
|
@@ -712,6 +713,7 @@ function cmdMigrate() {
|
|
|
712
713
|
"vercel-account-guard.js",
|
|
713
714
|
"env-empty-guard.js",
|
|
714
715
|
"supabase-destructive-guard.js",
|
|
716
|
+
"secret-guard.js",
|
|
715
717
|
];
|
|
716
718
|
const requiredEditHooks = ["migration-guard.js"];
|
|
717
719
|
|
|
@@ -747,6 +749,7 @@ function cmdMigrate() {
|
|
|
747
749
|
if (hookFile === "vercel-account-guard.js") { hookDef.if = "Bash(vercel --prod*)|Bash(vercel deploy*)"; hookDef.timeout = 8; hookDef.statusMessage = "⬢ Verifying Vercel account..."; }
|
|
748
750
|
if (hookFile === "env-empty-guard.js") { hookDef.if = "Bash(vercel env*)"; hookDef.statusMessage = "⬢ Checking env value..."; }
|
|
749
751
|
if (hookFile === "supabase-destructive-guard.js") { hookDef.if = "Bash(supabase*)|Bash(npx supabase*)"; hookDef.statusMessage = "⬢ Checking Supabase safety..."; }
|
|
752
|
+
if (hookFile === "secret-guard.js") { hookDef.if = "Bash(git commit*)"; hookDef.timeout = 10; hookDef.statusMessage = "⬢ Scanning staged content for secrets..."; }
|
|
750
753
|
bashEntry.hooks.push(hookDef);
|
|
751
754
|
changes++;
|
|
752
755
|
console.log(` ${GREEN}+${RESET} Wired ${hookFile} into PreToolUse/Bash`);
|
|
@@ -1011,7 +1014,9 @@ async function cmdErpPing() {
|
|
|
1011
1014
|
project: "qualia-framework-erp-ping",
|
|
1012
1015
|
project_id: "ping",
|
|
1013
1016
|
team_id: "qualia-solutions",
|
|
1014
|
-
client_report_id:
|
|
1017
|
+
// client_report_id intentionally omitted: ERP only accepts a QS-REPORT-NN
|
|
1018
|
+
// form or an absent field. A synthetic "QS-PING-NN" is rejected (422). With
|
|
1019
|
+
// it absent the server assigns a throwaway UUID — exactly what a dry-run ping wants.
|
|
1015
1020
|
phase: 0,
|
|
1016
1021
|
phase_name: "ping",
|
|
1017
1022
|
status: "setup",
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dep-verify — Hallucinated / slopsquatted dependency scanner for Qualia projects.
|
|
4
|
+
*
|
|
5
|
+
* The harness gate that AI-generated code most often trips: importing a package
|
|
6
|
+
* that does not exist. The model invents a plausible name (`react-use-toast`),
|
|
7
|
+
* or typosquats a real one (`lodahs`), and the import only fails at install or
|
|
8
|
+
* runtime — long after the agent reported success. This scanner catches it at
|
|
9
|
+
* the verify/ship gate, deterministically and with zero network calls.
|
|
10
|
+
*
|
|
11
|
+
* The signal is precise: a bare import is flagged ONLY when its package is
|
|
12
|
+
* BOTH undeclared in package.json AND absent from node_modules. That is the
|
|
13
|
+
* exact signature of a hallucinated or slopsquatted dependency — and it keeps
|
|
14
|
+
* false positives near zero (phantom/hoisted deps that are actually installed
|
|
15
|
+
* are left alone).
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* node bin/dep-verify.mjs # scan repo from cwd
|
|
19
|
+
* node bin/dep-verify.mjs src/app/page.tsx # scan one file
|
|
20
|
+
* node bin/dep-verify.mjs src/ # scan a directory
|
|
21
|
+
* node bin/dep-verify.mjs --json # machine-readable output
|
|
22
|
+
* node bin/dep-verify.mjs --severity=critical # parity flag (all findings are critical)
|
|
23
|
+
*
|
|
24
|
+
* Exit codes:
|
|
25
|
+
* 0 no undeclared/uninstalled imports
|
|
26
|
+
* 1 one or more hallucinated/slopsquatted imports found
|
|
27
|
+
* 2 invocation error
|
|
28
|
+
*
|
|
29
|
+
* Builder agents and the /qualia-verify + /qualia-ship gates call this BEFORE
|
|
30
|
+
* commit/deploy. A non-zero exit blocks. Pairs with slop-detect.mjs (design
|
|
31
|
+
* tells) — this is the correctness/security half of the same gate.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
|
|
35
|
+
import { join, extname, relative, resolve, dirname } from "node:path";
|
|
36
|
+
import { argv, exit, cwd } from "node:process";
|
|
37
|
+
import { builtinModules } from "node:module";
|
|
38
|
+
|
|
39
|
+
// ── Config ────────────────────────────────────────────────────────────
|
|
40
|
+
const SOURCE_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"]);
|
|
41
|
+
const SKIP_DIRS = new Set([
|
|
42
|
+
"node_modules", ".git", "dist", "build", ".next", "out",
|
|
43
|
+
"coverage", ".turbo", ".vercel", "vendor", ".cache",
|
|
44
|
+
]);
|
|
45
|
+
// Node builtins, with and without the `node:` prefix.
|
|
46
|
+
const BUILTINS = new Set([...builtinModules, ...builtinModules.map((m) => `node:${m}`)]);
|
|
47
|
+
|
|
48
|
+
// ── Import extraction ─────────────────────────────────────────────────
|
|
49
|
+
// A context-free comment regex is unsafe here: a `//` inside a string or
|
|
50
|
+
// regex literal would erase the rest of the line (dropping a real import → a
|
|
51
|
+
// silent miss), and import-looking text *inside* a string would be matched as
|
|
52
|
+
// a live import (a false block). So we strip comments with a small scanner
|
|
53
|
+
// that tracks string, template, and regex-literal state, then match imports
|
|
54
|
+
// only at statement boundaries so syntax embedded in a string never counts.
|
|
55
|
+
|
|
56
|
+
// Heuristic for whether a `/` begins a regex literal: it does when the last
|
|
57
|
+
// significant code character is an operator/opener (not a value/identifier).
|
|
58
|
+
function isRegexStart(prev) {
|
|
59
|
+
return prev === "" || "(,=:[!&|?{};+-*%^~<>".includes(prev);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Remove // and /* */ comments while preserving strings, template literals,
|
|
63
|
+
// and regex literals (and preserving newlines so line numbers are stable).
|
|
64
|
+
function stripComments(src) {
|
|
65
|
+
let out = "";
|
|
66
|
+
let i = 0;
|
|
67
|
+
const n = src.length;
|
|
68
|
+
let state = "code"; // code | line | block | regex | sq | dq | tpl
|
|
69
|
+
let prevSig = ""; // last significant (non-whitespace) code char emitted
|
|
70
|
+
while (i < n) {
|
|
71
|
+
const c = src[i];
|
|
72
|
+
const c2 = src[i + 1];
|
|
73
|
+
if (state === "code") {
|
|
74
|
+
if (c === "/" && c2 === "/") { state = "line"; i += 2; continue; }
|
|
75
|
+
if (c === "/" && c2 === "*") { state = "block"; i += 2; continue; }
|
|
76
|
+
if (c === "/" && isRegexStart(prevSig)) { state = "regex"; out += c; i++; continue; }
|
|
77
|
+
if (c === "'") { state = "sq"; out += c; prevSig = c; i++; continue; }
|
|
78
|
+
if (c === '"') { state = "dq"; out += c; prevSig = c; i++; continue; }
|
|
79
|
+
if (c === "`") { state = "tpl"; out += c; prevSig = c; i++; continue; }
|
|
80
|
+
out += c;
|
|
81
|
+
if (!/\s/.test(c)) prevSig = c;
|
|
82
|
+
i++;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (state === "line") {
|
|
86
|
+
if (c === "\n") { state = "code"; out += c; }
|
|
87
|
+
i++;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (state === "block") {
|
|
91
|
+
if (c === "*" && c2 === "/") { state = "code"; i += 2; continue; }
|
|
92
|
+
if (c === "\n") out += c;
|
|
93
|
+
i++;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
// regex literal or string: copy verbatim, honoring escapes.
|
|
97
|
+
out += c;
|
|
98
|
+
if (c === "\\") {
|
|
99
|
+
if (i + 1 < n) { out += src[i + 1]; i += 2; continue; }
|
|
100
|
+
i++;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (state === "regex" && c === "/") { state = "code"; prevSig = "/"; }
|
|
104
|
+
else if (state === "sq" && c === "'") { state = "code"; prevSig = c; }
|
|
105
|
+
else if (state === "dq" && c === '"') { state = "code"; prevSig = c; }
|
|
106
|
+
else if (state === "tpl" && c === "`") { state = "code"; prevSig = c; }
|
|
107
|
+
i++;
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Statement-anchored so import/export keywords inside a string literal don't
|
|
113
|
+
// match (they are preceded by a quote, not a statement boundary). `m` flag so
|
|
114
|
+
// `^` anchors each line; the negated class spans multi-line import bodies.
|
|
115
|
+
const ES_FROM_RE = /(?:^|[;{}])\s*(?:import|export)\b[^'";]*?from\s*['"]([^'"]+)['"]/gm;
|
|
116
|
+
const BARE_IMPORT_RE = /(?:^|[;{}])\s*import\s*['"]([^'"]+)['"]/gm;
|
|
117
|
+
// require()/import() can be inline; exclude member/identifier prefixes
|
|
118
|
+
// (myRequire(, foo.import() ) via lookbehind.
|
|
119
|
+
const CALL_RE = /(?<![\w$.])(?:require|import)\s*\(\s*['"]([^'"]+)['"]/g;
|
|
120
|
+
|
|
121
|
+
function extractSpecifiers(source) {
|
|
122
|
+
const cleaned = stripComments(source);
|
|
123
|
+
const out = new Set();
|
|
124
|
+
for (const re of [ES_FROM_RE, BARE_IMPORT_RE, CALL_RE]) {
|
|
125
|
+
re.lastIndex = 0;
|
|
126
|
+
let m;
|
|
127
|
+
while ((m = re.exec(cleaned)) !== null) {
|
|
128
|
+
if (m[1]) out.add(m[1]);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return [...out];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Resolve a raw specifier to its installable package name, or null if it is
|
|
135
|
+
// not an external package (relative, absolute, alias, or subpath import).
|
|
136
|
+
function packageName(spec) {
|
|
137
|
+
if (!spec) return null;
|
|
138
|
+
if (spec.startsWith(".") || spec.startsWith("/")) return null; // relative / absolute
|
|
139
|
+
if (spec.includes(":")) return null; // protocol specifiers: node:, virtual:, https:, data:, blob:
|
|
140
|
+
if (spec.startsWith("@/") || spec.startsWith("~/") || spec.startsWith("#")) return null; // common aliases / subpath imports
|
|
141
|
+
if (spec.startsWith("@")) {
|
|
142
|
+
const parts = spec.split("/");
|
|
143
|
+
return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : null; // @scope/name
|
|
144
|
+
}
|
|
145
|
+
return spec.split("/")[0]; // name (drop subpath)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── package.json + tsconfig discovery (walk up, cached) ───────────────
|
|
149
|
+
const declaredCache = new Map();
|
|
150
|
+
const aliasCache = new Map();
|
|
151
|
+
|
|
152
|
+
function readJSONSafe(p) {
|
|
153
|
+
try {
|
|
154
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
155
|
+
} catch {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Union of declared deps from the nearest package.json walking up, plus the
|
|
161
|
+
// top-most (repo-root) package.json to tolerate monorepo hoisting.
|
|
162
|
+
function declaredDepsFor(fileDir) {
|
|
163
|
+
if (declaredCache.has(fileDir)) return declaredCache.get(fileDir);
|
|
164
|
+
const names = new Set();
|
|
165
|
+
let dir = fileDir;
|
|
166
|
+
let last = null;
|
|
167
|
+
while (true) {
|
|
168
|
+
const pj = join(dir, "package.json");
|
|
169
|
+
if (existsSync(pj)) {
|
|
170
|
+
const json = readJSONSafe(pj);
|
|
171
|
+
if (json) {
|
|
172
|
+
for (const field of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) {
|
|
173
|
+
for (const k of Object.keys(json[field] || {})) names.add(k);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
last = dir;
|
|
177
|
+
}
|
|
178
|
+
const parent = dirname(dir);
|
|
179
|
+
if (parent === dir) break;
|
|
180
|
+
dir = parent;
|
|
181
|
+
}
|
|
182
|
+
declaredCache.set(fileDir, names);
|
|
183
|
+
return names;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// tsconfig/jsconfig path-alias prefixes (e.g. "@app/*" → "@app/").
|
|
187
|
+
function aliasPrefixesFor(fileDir) {
|
|
188
|
+
if (aliasCache.has(fileDir)) return aliasCache.get(fileDir);
|
|
189
|
+
const prefixes = [];
|
|
190
|
+
let dir = fileDir;
|
|
191
|
+
while (true) {
|
|
192
|
+
for (const name of ["tsconfig.json", "jsconfig.json"]) {
|
|
193
|
+
const cfg = readJSONSafe(join(dir, name));
|
|
194
|
+
const paths = cfg?.compilerOptions?.paths;
|
|
195
|
+
if (paths) {
|
|
196
|
+
for (const key of Object.keys(paths)) {
|
|
197
|
+
// A wildcard alias ("@app/*") suppresses its subtree; a non-wildcard
|
|
198
|
+
// alias ("@env") suppresses only the exact specifier. Without this
|
|
199
|
+
// distinction a bare "@utils" would wrongly swallow "@utils-extended/server".
|
|
200
|
+
if (key.endsWith("*")) {
|
|
201
|
+
const p = key.replace(/\*$/, "");
|
|
202
|
+
prefixes.push({ wild: true, value: p.endsWith("/") ? p : `${p}/` });
|
|
203
|
+
} else {
|
|
204
|
+
prefixes.push({ wild: false, value: key });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const parent = dirname(dir);
|
|
210
|
+
if (parent === dir) break;
|
|
211
|
+
dir = parent;
|
|
212
|
+
}
|
|
213
|
+
aliasCache.set(fileDir, prefixes);
|
|
214
|
+
return prefixes;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Is the package physically installed in any node_modules up the tree?
|
|
218
|
+
function isInstalled(fileDir, pkg) {
|
|
219
|
+
let dir = fileDir;
|
|
220
|
+
while (true) {
|
|
221
|
+
if (existsSync(join(dir, "node_modules", pkg))) return true;
|
|
222
|
+
const parent = dirname(dir);
|
|
223
|
+
if (parent === dir) break;
|
|
224
|
+
dir = parent;
|
|
225
|
+
}
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── File walking ──────────────────────────────────────────────────────
|
|
230
|
+
function collectFiles(target) {
|
|
231
|
+
let st;
|
|
232
|
+
try {
|
|
233
|
+
st = statSync(target);
|
|
234
|
+
} catch {
|
|
235
|
+
return null; // signal: path does not exist
|
|
236
|
+
}
|
|
237
|
+
if (st.isFile()) return SOURCE_EXT.has(extname(target)) ? [target] : [];
|
|
238
|
+
const files = [];
|
|
239
|
+
const walk = (d) => {
|
|
240
|
+
let entries;
|
|
241
|
+
try {
|
|
242
|
+
entries = readdirSync(d, { withFileTypes: true });
|
|
243
|
+
} catch {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
for (const e of entries) {
|
|
247
|
+
if (e.isDirectory()) {
|
|
248
|
+
if (!SKIP_DIRS.has(e.name)) walk(join(d, e.name));
|
|
249
|
+
} else if (e.isFile() && SOURCE_EXT.has(extname(e.name))) {
|
|
250
|
+
files.push(join(d, e.name));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
walk(target);
|
|
255
|
+
return files;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Scan ──────────────────────────────────────────────────────────────
|
|
259
|
+
function scanFile(file) {
|
|
260
|
+
const findings = [];
|
|
261
|
+
let source;
|
|
262
|
+
try {
|
|
263
|
+
source = readFileSync(file, "utf8");
|
|
264
|
+
} catch {
|
|
265
|
+
return findings;
|
|
266
|
+
}
|
|
267
|
+
const fileDir = dirname(resolve(file));
|
|
268
|
+
const declared = declaredDepsFor(fileDir);
|
|
269
|
+
const aliases = aliasPrefixesFor(fileDir);
|
|
270
|
+
const seen = new Set();
|
|
271
|
+
for (const spec of extractSpecifiers(source)) {
|
|
272
|
+
if (aliases.some((a) => (a.wild ? spec.startsWith(a.value) : spec === a.value))) continue;
|
|
273
|
+
const pkg = packageName(spec);
|
|
274
|
+
if (!pkg) continue;
|
|
275
|
+
if (BUILTINS.has(pkg) || BUILTINS.has(spec)) continue;
|
|
276
|
+
if (declared.has(pkg)) continue;
|
|
277
|
+
if (isInstalled(fileDir, pkg)) continue;
|
|
278
|
+
if (seen.has(pkg)) continue; // one finding per package per file
|
|
279
|
+
seen.add(pkg);
|
|
280
|
+
findings.push({ file: relative(cwd(), file), package: pkg, specifier: spec });
|
|
281
|
+
}
|
|
282
|
+
return findings;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── CLI ───────────────────────────────────────────────────────────────
|
|
286
|
+
function main() {
|
|
287
|
+
const args = argv.slice(2);
|
|
288
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
289
|
+
console.log("dep-verify — flags imports that are neither declared in package.json nor installed.");
|
|
290
|
+
console.log("Usage: node bin/dep-verify.mjs [paths...] [--json] [--severity=critical]");
|
|
291
|
+
exit(0);
|
|
292
|
+
}
|
|
293
|
+
const json = args.includes("--json");
|
|
294
|
+
const paths = args.filter((a) => !a.startsWith("--") && !a.startsWith("-"));
|
|
295
|
+
const targets = paths.length ? paths : [cwd()];
|
|
296
|
+
|
|
297
|
+
const allFiles = [];
|
|
298
|
+
for (const t of targets) {
|
|
299
|
+
const files = collectFiles(t);
|
|
300
|
+
if (files === null) {
|
|
301
|
+
if (json) console.log(JSON.stringify({ ok: false, error: "ENOENT", path: t }, null, 2));
|
|
302
|
+
else console.error(`dep-verify: path not found: ${t}`);
|
|
303
|
+
exit(2);
|
|
304
|
+
}
|
|
305
|
+
allFiles.push(...files);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const findings = [];
|
|
309
|
+
for (const f of allFiles) findings.push(...scanFile(f));
|
|
310
|
+
|
|
311
|
+
if (json) {
|
|
312
|
+
console.log(JSON.stringify({ ok: findings.length === 0, scanned: allFiles.length, findings }, null, 2));
|
|
313
|
+
} else if (findings.length === 0) {
|
|
314
|
+
console.log(`dep-verify: ${allFiles.length} file(s) scanned — no hallucinated dependencies.`);
|
|
315
|
+
} else {
|
|
316
|
+
console.error(`dep-verify: ${findings.length} hallucinated/slopsquatted import(s) — undeclared AND not installed:\n`);
|
|
317
|
+
for (const f of findings) {
|
|
318
|
+
console.error(` ✗ ${f.file}`);
|
|
319
|
+
console.error(` imports "${f.specifier}" → package "${f.package}" is not in package.json and not in node_modules`);
|
|
320
|
+
}
|
|
321
|
+
console.error(`\n Fix: confirm the package name is real, then add it to package.json (npm/pnpm/yarn add),`);
|
|
322
|
+
console.error(` or correct the import. AI commonly invents or typosquats package names.`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
exit(findings.length === 0 ? 0 : 1);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
main();
|
package/bin/install.js
CHANGED
|
@@ -1283,6 +1283,8 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
|
|
|
1283
1283
|
"fawzi-approval-guard.js", "task-write-guard.js",
|
|
1284
1284
|
// v5.0 — insights-driven destructive-op + wrong-account guards
|
|
1285
1285
|
"vercel-account-guard.js", "env-empty-guard.js", "supabase-destructive-guard.js",
|
|
1286
|
+
// commit-time secret gate (constitution: never commit service_role / .env)
|
|
1287
|
+
"secret-guard.js",
|
|
1286
1288
|
// performance-audit telemetry capture (UserPromptSubmit)
|
|
1287
1289
|
"usage-capture.js",
|
|
1288
1290
|
]);
|
|
@@ -1316,6 +1318,7 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
|
|
|
1316
1318
|
{ type: "command", if: "Bash(vercel --prod*)|Bash(vercel deploy*)", command: nodeCmd("vercel-account-guard.js"), timeout: 8, statusMessage: "⬢ Verifying Vercel account..." },
|
|
1317
1319
|
{ type: "command", if: "Bash(vercel env*)", command: nodeCmd("env-empty-guard.js"), timeout: 5, statusMessage: "⬢ Checking env value..." },
|
|
1318
1320
|
{ type: "command", if: "Bash(supabase*)|Bash(npx supabase*)", command: nodeCmd("supabase-destructive-guard.js"), timeout: 5, statusMessage: "⬢ Checking Supabase safety..." },
|
|
1321
|
+
{ type: "command", if: "Bash(git commit*)", command: nodeCmd("secret-guard.js"), timeout: 10, statusMessage: "⬢ Scanning staged content for secrets..." },
|
|
1319
1322
|
],
|
|
1320
1323
|
},
|
|
1321
1324
|
{
|
|
@@ -1861,6 +1864,7 @@ async function installCodex(member, target, employeeMode = false) {
|
|
|
1861
1864
|
{ type: "command", command: nodeCmd("vercel-account-guard.js"), timeout: 8 },
|
|
1862
1865
|
{ type: "command", command: nodeCmd("env-empty-guard.js"), timeout: 5 },
|
|
1863
1866
|
{ type: "command", command: nodeCmd("supabase-destructive-guard.js"), timeout: 5 },
|
|
1867
|
+
{ type: "command", command: nodeCmd("secret-guard.js"), timeout: 10, statusMessage: "⬢ Scanning staged content for secrets..." },
|
|
1864
1868
|
],
|
|
1865
1869
|
},
|
|
1866
1870
|
{
|
|
@@ -1868,6 +1872,11 @@ async function installCodex(member, target, employeeMode = false) {
|
|
|
1868
1872
|
hooks: [
|
|
1869
1873
|
{ type: "command", command: nodeCmd("fawzi-approval-guard.js"), timeout: 5 },
|
|
1870
1874
|
{ type: "command", command: nodeCmd("migration-guard.js"), timeout: 10 },
|
|
1875
|
+
// Runtime parity with Claude (install.js Edit|Write group): the
|
|
1876
|
+
// off-contract-write gate must run on Codex too, else a Codex
|
|
1877
|
+
// builder can write fabricated/out-of-scope files unblocked.
|
|
1878
|
+
// Enforced by tests/runtime-parity.test.sh.
|
|
1879
|
+
{ type: "command", command: nodeCmd("task-write-guard.js"), timeout: 5, statusMessage: "⬢ Checking plan-contract file scope..." },
|
|
1871
1880
|
],
|
|
1872
1881
|
},
|
|
1873
1882
|
],
|
package/bin/recall.js
CHANGED
|
@@ -124,6 +124,18 @@ function main() {
|
|
|
124
124
|
usage();
|
|
125
125
|
process.exit(0);
|
|
126
126
|
}
|
|
127
|
+
|
|
128
|
+
// OWNER-only command. The curated cross-project memory recall is the OWNER's
|
|
129
|
+
// surface; employees still have the read-only memory MCP for ALL_ROLES wiki
|
|
130
|
+
// content. Deterministic gate — not just a prose note. Exit 3 = forbidden.
|
|
131
|
+
const role = resolveRole(qualiaHome());
|
|
132
|
+
if (role !== "OWNER") {
|
|
133
|
+
process.stderr.write(
|
|
134
|
+
"/qualia-recall is OWNER-only. Ask Fawzi if you need a cross-project lookup.\n"
|
|
135
|
+
);
|
|
136
|
+
process.exit(3);
|
|
137
|
+
}
|
|
138
|
+
|
|
127
139
|
const query = opts.terms.join(" ").trim();
|
|
128
140
|
if (!query) {
|
|
129
141
|
usage();
|
|
@@ -134,7 +146,6 @@ function main() {
|
|
|
134
146
|
process.exit(2);
|
|
135
147
|
}
|
|
136
148
|
|
|
137
|
-
const role = resolveRole(qualiaHome());
|
|
138
149
|
const knowledge = opts.scope === "vault" ? [] : searchKnowledge(query, opts.max);
|
|
139
150
|
const vault = opts.scope === "knowledge" ? [] : searchVault(query, opts.max, role);
|
|
140
151
|
const total = knowledge.length + vault.length;
|
package/bin/report-payload.js
CHANGED
|
@@ -113,7 +113,10 @@ function buildPayload(options = {}) {
|
|
|
113
113
|
? { assignment_deadline: workPacket.deadline_date }
|
|
114
114
|
: {}),
|
|
115
115
|
client: tracking.client || "",
|
|
116
|
-
client_report_id
|
|
116
|
+
// ERP validates client_report_id as either a QS-REPORT-NN form or ABSENT.
|
|
117
|
+
// An empty string is rejected (422). Omit when unset so the server assigns
|
|
118
|
+
// a UUID, per docs/erp-contract.md (optional field).
|
|
119
|
+
...(env.CLIENT_REPORT_ID ? { client_report_id: env.CLIENT_REPORT_ID } : {}),
|
|
117
120
|
framework_version: config.version || "",
|
|
118
121
|
milestone: tracking.milestone || 1,
|
|
119
122
|
milestone_name: tracking.milestone_name || "",
|
package/bin/runtime-manifest.js
CHANGED
|
@@ -27,6 +27,7 @@ const RUNTIME_BIN_SCRIPTS = [
|
|
|
27
27
|
{ file: "last-report.js", label: "last-report.js (router surfacing — newest session-report digest at session start)" },
|
|
28
28
|
{ file: "agent-runs.js", label: "agent-runs.js (agent telemetry writer)" },
|
|
29
29
|
{ file: "slop-detect.mjs", label: "slop-detect.mjs (anti-pattern scanner — runs pre-commit on frontend builds)" },
|
|
30
|
+
{ file: "dep-verify.mjs", label: "dep-verify.mjs (hallucinated/slopsquatted dependency scanner — verify + ship gate)" },
|
|
30
31
|
{ file: "erp-retry.js", label: "erp-retry.js (ERP report retry queue — drained by session-start hook and erp-flush CLI)" },
|
|
31
32
|
{ file: "erp-event.js", label: "erp-event.js (signed lifecycle-event emitter → ERP /api/v1/events, R14 client)" },
|
|
32
33
|
{ file: "work-packet.js", label: "work-packet.js (ERP mission/work packet pull + local reader)" },
|
package/bin/state.js
CHANGED
|
@@ -655,7 +655,9 @@ function cmdTransitionIncrement(opts, target) {
|
|
|
655
655
|
const t = ensureLifetime(readTracking() || {});
|
|
656
656
|
inc.status = target;
|
|
657
657
|
if (target === "planned") {
|
|
658
|
-
|
|
658
|
+
// A5 sets status "failed" on a failed verify (vs the legacy path which keeps
|
|
659
|
+
// "verified" + verification="fail"); count a gap cycle for either.
|
|
660
|
+
if (prevStatus === "verified" || prevStatus === "failed") inc.gap_cycles = (inc.gap_cycles || 0) + 1;
|
|
659
661
|
} else if (target === "built") {
|
|
660
662
|
inc.tasks_done = parseInt(opts.tasks_done) || 0;
|
|
661
663
|
inc.tasks_total = parseInt(opts.tasks_total) || 0;
|
|
@@ -956,7 +958,7 @@ Resume: ${s.resume || "—"}
|
|
|
956
958
|
|
|
957
959
|
// ─── Precondition Checks ─────────────────────────────────
|
|
958
960
|
const VALID_FROM = {
|
|
959
|
-
planned: ["setup", "verified"], // verified(fail) → planned = gap closure
|
|
961
|
+
planned: ["setup", "verified", "failed"], // verified(fail) [legacy] or failed [A5] → planned = gap closure
|
|
960
962
|
built: ["planned"],
|
|
961
963
|
verified: ["built"],
|
|
962
964
|
polished: ["verified"],
|
|
@@ -1073,8 +1075,9 @@ function checkPreconditions(current, target, opts) {
|
|
|
1073
1075
|
}
|
|
1074
1076
|
}
|
|
1075
1077
|
|
|
1076
|
-
// Gap-closure circuit breaker (configurable limit)
|
|
1077
|
-
|
|
1078
|
+
// Gap-closure circuit breaker (configurable limit). Fires from either the
|
|
1079
|
+
// legacy "verified"(+fail) status or the A5 "failed" status.
|
|
1080
|
+
if (target === "planned" && (current.status === "verified" || current.status === "failed")) {
|
|
1078
1081
|
const t = readTracking() || {};
|
|
1079
1082
|
const cycles = (t.gap_cycles || {})[String(phase)] || 0;
|
|
1080
1083
|
const limit = getGapCycleLimit();
|
|
@@ -1148,6 +1151,10 @@ function nextCommand(status, phase, totalPhases, verification, lifecycle) {
|
|
|
1148
1151
|
if (verification === "fail") return `/qualia-plan ${phase} --gaps`;
|
|
1149
1152
|
if (phase < totalPhases) return `/qualia-plan ${phase + 1}`;
|
|
1150
1153
|
return operate ? "/qualia-update" : "/qualia-polish";
|
|
1154
|
+
// A5 increments set status "failed" on a failed verify (legacy keeps
|
|
1155
|
+
// "verified"+fail). Route both to gap closure instead of dead-ending at /qualia.
|
|
1156
|
+
case "failed":
|
|
1157
|
+
return `/qualia-plan ${phase} --gaps`;
|
|
1151
1158
|
case "polished":
|
|
1152
1159
|
return "/qualia-ship";
|
|
1153
1160
|
case "shipped":
|
package/bin/trust-score.js
CHANGED
|
@@ -27,7 +27,7 @@ const REQUIRED_HOOKS = [
|
|
|
27
27
|
"session-start.js", "auto-update.js", "branch-guard.js", "pre-push.js",
|
|
28
28
|
"pre-deploy-gate.js", "migration-guard.js", "git-guardrails.js",
|
|
29
29
|
"stop-session-log.js", "fawzi-approval-guard.js", "vercel-account-guard.js", "env-empty-guard.js",
|
|
30
|
-
"supabase-destructive-guard.js",
|
|
30
|
+
"supabase-destructive-guard.js", "secret-guard.js",
|
|
31
31
|
];
|
|
32
32
|
|
|
33
33
|
const REQUIRED_DESIGN_FILES = [
|
package/hooks/pre-deploy-gate.js
CHANGED
|
@@ -346,20 +346,71 @@ if (fs.existsSync(slopScript)) {
|
|
|
346
346
|
encoding: "utf8",
|
|
347
347
|
timeout: 60000,
|
|
348
348
|
});
|
|
349
|
-
|
|
350
|
-
|
|
349
|
+
// Fail CLOSED (same contract as the dep-verify gate below): only a clean
|
|
350
|
+
// exit 0 passes; a CRITICAL finding (1) or a crash/timeout blocks.
|
|
351
|
+
if (r.status !== 0) {
|
|
352
|
+
console.error(
|
|
353
|
+
r.status === 1
|
|
354
|
+
? "BLOCKED: anti-slop scan found CRITICAL design tells. Fix before deploying."
|
|
355
|
+
: `BLOCKED: anti-slop scan did not complete (${r.error ? r.error.message : "exit " + r.status}). Treating as FAIL.`
|
|
356
|
+
);
|
|
351
357
|
const output = `${r.stdout || ""}${r.stderr || ""}`.trim();
|
|
352
358
|
if (output) {
|
|
353
359
|
const lines = output.split(/\r?\n/).filter(Boolean).slice(-20);
|
|
354
360
|
for (const line of lines) console.error(` ${line}`);
|
|
355
361
|
}
|
|
356
|
-
_trace("pre-deploy-gate", "block", { gate: "slop", status: r.status });
|
|
362
|
+
_trace("pre-deploy-gate", "block", { gate: "slop", status: r.status, error: r.error ? r.error.message : undefined });
|
|
357
363
|
process.exit(2);
|
|
358
364
|
}
|
|
359
365
|
console.log(" ✓ Anti-slop");
|
|
360
366
|
}
|
|
361
367
|
}
|
|
362
368
|
|
|
369
|
+
// Dependency verification: zero-token, zero-network scan for imports of
|
|
370
|
+
// packages that are BOTH undeclared in package.json AND absent from
|
|
371
|
+
// node_modules — the signature of an AI-hallucinated or slopsquatted
|
|
372
|
+
// dependency (the #1 named AI-generated-code security failure mode). The
|
|
373
|
+
// correctness/security companion to the design-focused anti-slop scan above.
|
|
374
|
+
// Skipped silently when the scanner isn't installed (brownfield / older
|
|
375
|
+
// installs). OWNER-only escape hatch mirrors QUALIA_SKIP_SLOP.
|
|
376
|
+
const depScript = path.join(QUALIA_HOME, "bin", "dep-verify.mjs");
|
|
377
|
+
if (fs.existsSync(depScript)) {
|
|
378
|
+
const skipDep = process.env.QUALIA_SKIP_DEPCHECK === "1";
|
|
379
|
+
if (skipDep) {
|
|
380
|
+
const depRole = String(readConfig().role || "").toUpperCase();
|
|
381
|
+
if (depRole !== "OWNER") {
|
|
382
|
+
const depState = readState();
|
|
383
|
+
blockDeploy("QUALIA_SKIP_DEPCHECK is OWNER-only.", (depState && depState.next_command) || "/qualia");
|
|
384
|
+
}
|
|
385
|
+
console.log(" ⚠ Dependency check skipped (QUALIA_SKIP_DEPCHECK=1)");
|
|
386
|
+
_trace("pre-deploy-gate", "skip-depcheck", { reason: "QUALIA_SKIP_DEPCHECK=1" });
|
|
387
|
+
} else {
|
|
388
|
+
const r = spawnSync(process.execPath, [depScript, "--severity=critical"], {
|
|
389
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
390
|
+
encoding: "utf8",
|
|
391
|
+
timeout: 60000,
|
|
392
|
+
});
|
|
393
|
+
// Fail CLOSED: only a clean exit 0 passes. Findings (1), invocation
|
|
394
|
+
// errors (2), or a crash/timeout (status null + r.error) all block — a
|
|
395
|
+
// security gate that silently passes when it cannot run is not a gate.
|
|
396
|
+
if (r.status !== 0) {
|
|
397
|
+
console.error(
|
|
398
|
+
r.status === 1
|
|
399
|
+
? "BLOCKED: hallucinated/slopsquatted imports found. Fix before deploying."
|
|
400
|
+
: `BLOCKED: dep-verify did not complete (${r.error ? r.error.message : "exit " + r.status}). Treating as FAIL.`
|
|
401
|
+
);
|
|
402
|
+
const output = `${r.stdout || ""}${r.stderr || ""}`.trim();
|
|
403
|
+
if (output) {
|
|
404
|
+
const lines = output.split(/\r?\n/).filter(Boolean).slice(-20);
|
|
405
|
+
for (const line of lines) console.error(` ${line}`);
|
|
406
|
+
}
|
|
407
|
+
_trace("pre-deploy-gate", "block", { gate: "dep-verify", status: r.status, error: r.error ? r.error.message : undefined });
|
|
408
|
+
process.exit(2);
|
|
409
|
+
}
|
|
410
|
+
console.log(" ✓ Dependencies");
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
363
414
|
console.log("⬢ All gates passed.");
|
|
364
415
|
|
|
365
416
|
_trace("pre-deploy-gate", "allow");
|