@massu/core 2.5.4 → 2.5.6
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 +66 -0
- package/dist/cli.js +505 -407
- package/dist/hooks/auto-learning-pipeline.js +26 -2
- package/dist/hooks/classify-failure.js +53 -656
- package/dist/hooks/cost-tracker.js +26 -2
- package/dist/hooks/fix-detector.js +30 -4
- package/dist/hooks/incident-pipeline.js +26 -2
- package/dist/hooks/intent-suggester.js +26 -2
- package/dist/hooks/memory-recall.js +26 -2
- package/dist/hooks/post-edit-context.js +26 -2
- package/dist/hooks/post-tool-use.js +26 -2
- package/dist/hooks/pre-compact.js +26 -2
- package/dist/hooks/pre-delete-check.js +405 -71
- package/dist/hooks/pre-tool-use-gate.js +196 -223
- package/dist/hooks/quality-event.js +26 -2
- package/dist/hooks/rule-enforcement-pipeline.js +26 -2
- package/dist/hooks/security-gate.js +570 -181
- package/dist/hooks/session-end.js +1081 -354
- package/dist/hooks/session-start.js +26 -2
- package/dist/hooks/user-prompt.js +26 -2
- package/package.json +1 -1
|
@@ -951,7 +951,8 @@ var init_config = __esm({
|
|
|
951
951
|
input_per_million: z2.number(),
|
|
952
952
|
output_per_million: z2.number(),
|
|
953
953
|
cache_read_per_million: z2.number().optional(),
|
|
954
|
-
cache_write_per_million: z2.number().optional()
|
|
954
|
+
cache_write_per_million: z2.number().optional(),
|
|
955
|
+
cache_write_1h_per_million: z2.number().optional()
|
|
955
956
|
});
|
|
956
957
|
AnalyticsConfigSchema = z2.object({
|
|
957
958
|
quality: z2.object({
|
|
@@ -1618,6 +1619,77 @@ var init_rule_candidate_store = __esm({
|
|
|
1618
1619
|
}
|
|
1619
1620
|
});
|
|
1620
1621
|
|
|
1622
|
+
// src/hooks/lib/hook-failure-signal.ts
|
|
1623
|
+
import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync5, existsSync as existsSync6 } from "fs";
|
|
1624
|
+
import { join as join4, dirname as dirname5 } from "path";
|
|
1625
|
+
function resolveFailureLogPath() {
|
|
1626
|
+
const explicit = process.env.MASSU_HOOK_FAILURE_LOG;
|
|
1627
|
+
if (explicit) return explicit;
|
|
1628
|
+
let dir = process.cwd();
|
|
1629
|
+
for (let i = 0; i < 12; i++) {
|
|
1630
|
+
if (existsSync6(join4(dir, ".massu")) || existsSync6(join4(dir, "massu.config.yaml"))) {
|
|
1631
|
+
return join4(dir, ".massu", "hook-failures.jsonl");
|
|
1632
|
+
}
|
|
1633
|
+
const parent = dirname5(dir);
|
|
1634
|
+
if (parent === dir) break;
|
|
1635
|
+
dir = parent;
|
|
1636
|
+
}
|
|
1637
|
+
return join4(process.cwd(), ".massu", "hook-failures.jsonl");
|
|
1638
|
+
}
|
|
1639
|
+
function recordHookFailure(hook, error, context) {
|
|
1640
|
+
const record = {
|
|
1641
|
+
hook,
|
|
1642
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1643
|
+
stack: error instanceof Error ? error.stack?.split("\n").slice(0, 6).join("\n") : void 0,
|
|
1644
|
+
context,
|
|
1645
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1646
|
+
};
|
|
1647
|
+
let wroteSomething = false;
|
|
1648
|
+
try {
|
|
1649
|
+
const path = resolveFailureLogPath();
|
|
1650
|
+
mkdirSync5(dirname5(path), { recursive: true });
|
|
1651
|
+
appendFileSync2(path, JSON.stringify(record) + "\n", "utf-8");
|
|
1652
|
+
wroteSomething = true;
|
|
1653
|
+
} catch {
|
|
1654
|
+
}
|
|
1655
|
+
try {
|
|
1656
|
+
process.stderr.write(
|
|
1657
|
+
`[massu] HOOK FAILURE in ${hook}: ${record.error}
|
|
1658
|
+
[massu] This is a bug in Massu, not in your code. See .massu/hook-failures.jsonl
|
|
1659
|
+
`
|
|
1660
|
+
);
|
|
1661
|
+
wroteSomething = true;
|
|
1662
|
+
} catch {
|
|
1663
|
+
}
|
|
1664
|
+
try {
|
|
1665
|
+
recordHookHealthRow(record);
|
|
1666
|
+
} catch {
|
|
1667
|
+
}
|
|
1668
|
+
return wroteSomething;
|
|
1669
|
+
}
|
|
1670
|
+
function recordHookHealthRow(record) {
|
|
1671
|
+
const { getMemoryDb: getMemoryDb2 } = (init_memory_db(), __toCommonJS(memory_db_exports));
|
|
1672
|
+
const db = getMemoryDb2();
|
|
1673
|
+
try {
|
|
1674
|
+
db.prepare(
|
|
1675
|
+
`INSERT INTO hook_health (hook, error, context_json, occurred_at)
|
|
1676
|
+
VALUES (?, ?, ?, ?)`
|
|
1677
|
+
).run(
|
|
1678
|
+
record.hook,
|
|
1679
|
+
record.error,
|
|
1680
|
+
record.context ? JSON.stringify(record.context) : null,
|
|
1681
|
+
record.timestamp
|
|
1682
|
+
);
|
|
1683
|
+
} finally {
|
|
1684
|
+
db.close();
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
var init_hook_failure_signal = __esm({
|
|
1688
|
+
"src/hooks/lib/hook-failure-signal.ts"() {
|
|
1689
|
+
"use strict";
|
|
1690
|
+
}
|
|
1691
|
+
});
|
|
1692
|
+
|
|
1621
1693
|
// src/memory-embedder-tokenizer.ts
|
|
1622
1694
|
import { readFileSync as readFileSync5 } from "fs";
|
|
1623
1695
|
function loadVocab(vocabPath) {
|
|
@@ -1718,13 +1790,13 @@ function encode(text, vocab, opts = {}) {
|
|
|
1718
1790
|
const maxLen = opts.maxLen ?? 256;
|
|
1719
1791
|
const basic = basicTokenize(text);
|
|
1720
1792
|
const wpTokens = [];
|
|
1721
|
-
for (const
|
|
1722
|
-
for (const sub of wordpieceTokenize(
|
|
1793
|
+
for (const t2 of basic) {
|
|
1794
|
+
for (const sub of wordpieceTokenize(t2, vocab)) wpTokens.push(sub);
|
|
1723
1795
|
}
|
|
1724
1796
|
const truncated = wpTokens.slice(0, maxLen - 2);
|
|
1725
1797
|
const tokens = [CLS, ...truncated, SEP];
|
|
1726
1798
|
const unkId = vocab.get(UNK) ?? 100;
|
|
1727
|
-
const input_ids = tokens.map((
|
|
1799
|
+
const input_ids = tokens.map((t2) => vocab.get(t2) ?? unkId);
|
|
1728
1800
|
const attention_mask = tokens.map(() => 1);
|
|
1729
1801
|
const token_type_ids = tokens.map(() => 0);
|
|
1730
1802
|
return { tokens, input_ids, attention_mask, token_type_ids };
|
|
@@ -1741,8 +1813,8 @@ var init_memory_embedder_tokenizer = __esm({
|
|
|
1741
1813
|
|
|
1742
1814
|
// src/memory-embedder.ts
|
|
1743
1815
|
import { fileURLToPath } from "url";
|
|
1744
|
-
import { dirname as
|
|
1745
|
-
import { existsSync as
|
|
1816
|
+
import { dirname as dirname6, join as join5 } from "path";
|
|
1817
|
+
import { existsSync as existsSync7 } from "fs";
|
|
1746
1818
|
import { createRequire as createRequire3 } from "module";
|
|
1747
1819
|
function getActiveEmbedModel() {
|
|
1748
1820
|
return _activeModel;
|
|
@@ -1764,21 +1836,21 @@ function embeddingsDisabled() {
|
|
|
1764
1836
|
}
|
|
1765
1837
|
function resolveModelDir() {
|
|
1766
1838
|
try {
|
|
1767
|
-
let dir =
|
|
1839
|
+
let dir = dirname6(fileURLToPath(import.meta.url));
|
|
1768
1840
|
let root = null;
|
|
1769
1841
|
for (let i = 0; i < 8; i++) {
|
|
1770
|
-
if (
|
|
1842
|
+
if (existsSync7(join5(dir, "package.json"))) {
|
|
1771
1843
|
root = dir;
|
|
1772
1844
|
break;
|
|
1773
1845
|
}
|
|
1774
|
-
const parent =
|
|
1846
|
+
const parent = dirname6(dir);
|
|
1775
1847
|
if (parent === dir) break;
|
|
1776
1848
|
dir = parent;
|
|
1777
1849
|
}
|
|
1778
1850
|
if (!root) return null;
|
|
1779
1851
|
for (const rel of ["dist/embedder", "assets/embedder"]) {
|
|
1780
|
-
const candidate =
|
|
1781
|
-
if (
|
|
1852
|
+
const candidate = join5(root, rel);
|
|
1853
|
+
if (existsSync7(join5(candidate, "model_quantized.onnx"))) return candidate;
|
|
1782
1854
|
}
|
|
1783
1855
|
return null;
|
|
1784
1856
|
} catch {
|
|
@@ -1789,7 +1861,7 @@ function resolveWasmDir() {
|
|
|
1789
1861
|
try {
|
|
1790
1862
|
const req3 = createRequire3(import.meta.url);
|
|
1791
1863
|
const main2 = req3.resolve(ORT_PKG);
|
|
1792
|
-
return
|
|
1864
|
+
return dirname6(main2);
|
|
1793
1865
|
} catch {
|
|
1794
1866
|
return null;
|
|
1795
1867
|
}
|
|
@@ -1809,8 +1881,8 @@ async function getSession() {
|
|
|
1809
1881
|
ort.env.wasm.proxy = false;
|
|
1810
1882
|
ort.env.wasm.wasmPaths = wasmDir.endsWith("/") ? wasmDir : wasmDir + "/";
|
|
1811
1883
|
_ortTensor = ort.Tensor;
|
|
1812
|
-
const modelPath =
|
|
1813
|
-
const vocabPath =
|
|
1884
|
+
const modelPath = join5(modelDir, "model_quantized.onnx");
|
|
1885
|
+
const vocabPath = join5(modelDir, "vocab.txt");
|
|
1814
1886
|
_vocab = loadVocab(vocabPath);
|
|
1815
1887
|
const session = await ort.InferenceSession.create(modelPath, {
|
|
1816
1888
|
executionProviders: ["wasm"],
|
|
@@ -1826,11 +1898,11 @@ async function getSession() {
|
|
|
1826
1898
|
function meanPool(hidden, mask, seqLen, hiddenSize) {
|
|
1827
1899
|
const out = new Float32Array(hiddenSize);
|
|
1828
1900
|
let maskSum = 0;
|
|
1829
|
-
for (let
|
|
1830
|
-
const m = mask[
|
|
1901
|
+
for (let t2 = 0; t2 < seqLen; t2++) {
|
|
1902
|
+
const m = mask[t2];
|
|
1831
1903
|
if (m === 0) continue;
|
|
1832
1904
|
maskSum += m;
|
|
1833
|
-
const base =
|
|
1905
|
+
const base = t2 * hiddenSize;
|
|
1834
1906
|
for (let h = 0; h < hiddenSize; h++) out[h] += hidden[base + h] * m;
|
|
1835
1907
|
}
|
|
1836
1908
|
const denom = Math.max(maskSum, 1e-9);
|
|
@@ -1838,16 +1910,16 @@ function meanPool(hidden, mask, seqLen, hiddenSize) {
|
|
|
1838
1910
|
return out;
|
|
1839
1911
|
}
|
|
1840
1912
|
function chunkForEmbedding(text) {
|
|
1841
|
-
const
|
|
1842
|
-
if (!
|
|
1843
|
-
if (
|
|
1913
|
+
const t2 = text.trim();
|
|
1914
|
+
if (!t2) return [];
|
|
1915
|
+
if (t2.length <= CHUNK_TARGET_CHARS) return [t2];
|
|
1844
1916
|
const chunks = [];
|
|
1845
1917
|
let start = 0;
|
|
1846
|
-
while (start <
|
|
1847
|
-
let end = Math.min(start + CHUNK_TARGET_CHARS,
|
|
1848
|
-
if (end <
|
|
1918
|
+
while (start < t2.length && chunks.length < MAX_CHUNKS_PER_RECORD) {
|
|
1919
|
+
let end = Math.min(start + CHUNK_TARGET_CHARS, t2.length);
|
|
1920
|
+
if (end < t2.length) {
|
|
1849
1921
|
const floor = start + Math.floor(CHUNK_TARGET_CHARS / 2);
|
|
1850
|
-
const slice =
|
|
1922
|
+
const slice = t2.slice(start, end);
|
|
1851
1923
|
const para = slice.lastIndexOf("\n\n");
|
|
1852
1924
|
const sent = Math.max(slice.lastIndexOf(". "), slice.lastIndexOf(".\n"));
|
|
1853
1925
|
const space = slice.lastIndexOf(" ");
|
|
@@ -1859,9 +1931,9 @@ function chunkForEmbedding(text) {
|
|
|
1859
1931
|
}
|
|
1860
1932
|
}
|
|
1861
1933
|
}
|
|
1862
|
-
const chunk =
|
|
1934
|
+
const chunk = t2.slice(start, end).trim();
|
|
1863
1935
|
if (chunk) chunks.push(chunk);
|
|
1864
|
-
if (end >=
|
|
1936
|
+
if (end >= t2.length) break;
|
|
1865
1937
|
start = Math.max(end - CHUNK_OVERLAP_CHARS, start + 1);
|
|
1866
1938
|
}
|
|
1867
1939
|
return chunks;
|
|
@@ -1931,17 +2003,17 @@ async function embedBatch(texts) {
|
|
|
1931
2003
|
const settings = loadEmbedSettings();
|
|
1932
2004
|
if (!settings.enabled) return texts.map(() => null);
|
|
1933
2005
|
const results = [];
|
|
1934
|
-
for (const
|
|
1935
|
-
if (!
|
|
2006
|
+
for (const t2 of texts) {
|
|
2007
|
+
if (!t2 || !t2.trim()) {
|
|
1936
2008
|
results.push(null);
|
|
1937
2009
|
continue;
|
|
1938
2010
|
}
|
|
1939
2011
|
try {
|
|
1940
2012
|
let vec = null;
|
|
1941
2013
|
if (settings.endpoint) {
|
|
1942
|
-
vec = await embedTier0(
|
|
2014
|
+
vec = await embedTier0(t2, settings);
|
|
1943
2015
|
}
|
|
1944
|
-
if (!vec) vec = await embedTier1(
|
|
2016
|
+
if (!vec) vec = await embedTier1(t2);
|
|
1945
2017
|
results.push(vec);
|
|
1946
2018
|
} catch {
|
|
1947
2019
|
results.push(null);
|
|
@@ -2137,6 +2209,7 @@ __export(memory_db_exports, {
|
|
|
2137
2209
|
pruneToolCostEvents: () => pruneToolCostEvents,
|
|
2138
2210
|
recordRecallHits: () => recordRecallHits,
|
|
2139
2211
|
recordTelemetry: () => recordTelemetry,
|
|
2212
|
+
releaseMetaLease: () => releaseMetaLease,
|
|
2140
2213
|
removePendingSync: () => removePendingSync,
|
|
2141
2214
|
sanitizeFts5Query: () => sanitizeFts5Query,
|
|
2142
2215
|
sanitizeFts5QueryOr: () => sanitizeFts5QueryOr,
|
|
@@ -2145,31 +2218,32 @@ __export(memory_db_exports, {
|
|
|
2145
2218
|
searchObservations: () => searchObservations,
|
|
2146
2219
|
setLastProcessedLine: () => setLastProcessedLine,
|
|
2147
2220
|
setMemoryMeta: () => setMemoryMeta,
|
|
2221
|
+
tryAcquireMetaLease: () => tryAcquireMetaLease,
|
|
2148
2222
|
upsertObservationEmbedding: () => upsertObservationEmbedding,
|
|
2149
2223
|
usageWarmupElapsed: () => usageWarmupElapsed
|
|
2150
2224
|
});
|
|
2151
|
-
import { dirname as
|
|
2152
|
-
import { existsSync as
|
|
2225
|
+
import { dirname as dirname7, basename as basename2 } from "path";
|
|
2226
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync6 } from "fs";
|
|
2153
2227
|
function sanitizeFts5Query(raw) {
|
|
2154
2228
|
const trimmed = raw.trim();
|
|
2155
2229
|
if (!trimmed) return '""';
|
|
2156
2230
|
const tokens = trimmed.replace(/"/g, "").split(/\s+/).filter(Boolean);
|
|
2157
|
-
return tokens.map((
|
|
2231
|
+
return tokens.map((t2) => `"${t2}"`).join(" ");
|
|
2158
2232
|
}
|
|
2159
2233
|
function sanitizeFts5QueryOr(raw) {
|
|
2160
2234
|
const trimmed = raw.trim();
|
|
2161
2235
|
if (!trimmed) return '""';
|
|
2162
|
-
const tokens = trimmed.replace(/"/g, "").split(/\s+/).filter((
|
|
2236
|
+
const tokens = trimmed.replace(/"/g, "").split(/\s+/).filter((t2) => t2.replace(/[^a-zA-Z0-9]/g, "").length >= 3);
|
|
2163
2237
|
if (tokens.length === 0) return '""';
|
|
2164
|
-
return tokens.map((
|
|
2238
|
+
return tokens.map((t2) => `"${t2}"`).join(" OR ");
|
|
2165
2239
|
}
|
|
2166
2240
|
function getMemoryDb() {
|
|
2167
2241
|
const dbPath = getResolvedPaths().memoryDbPath;
|
|
2168
|
-
const dir =
|
|
2169
|
-
if (!
|
|
2170
|
-
|
|
2242
|
+
const dir = dirname7(dbPath);
|
|
2243
|
+
if (!existsSync8(dir)) {
|
|
2244
|
+
mkdirSync6(dir, { recursive: true });
|
|
2171
2245
|
}
|
|
2172
|
-
const preExisting =
|
|
2246
|
+
const preExisting = existsSync8(dbPath);
|
|
2173
2247
|
const db = openDatabase2(dbPath);
|
|
2174
2248
|
db.pragma("journal_mode = WAL");
|
|
2175
2249
|
db.pragma("foreign_keys = ON");
|
|
@@ -2650,6 +2724,15 @@ function initMemorySchema(db) {
|
|
|
2650
2724
|
);
|
|
2651
2725
|
CREATE INDEX IF NOT EXISTS idx_sc_session ON session_costs(session_id);
|
|
2652
2726
|
`);
|
|
2727
|
+
const costCols = db.prepare(`PRAGMA table_info(session_costs)`).all();
|
|
2728
|
+
const addCostCol = (name, ddl) => {
|
|
2729
|
+
if (!costCols.some((c) => c.name === name)) db.exec(`ALTER TABLE session_costs ADD COLUMN ${ddl}`);
|
|
2730
|
+
};
|
|
2731
|
+
addCostCol("cost_status", `cost_status TEXT NOT NULL DEFAULT 'unknown'`);
|
|
2732
|
+
addCostCol("unpriced_models", `unpriced_models TEXT NOT NULL DEFAULT ''`);
|
|
2733
|
+
addCostCol("priced_responses", `priced_responses INTEGER NOT NULL DEFAULT 0`);
|
|
2734
|
+
addCostCol("unpriced_responses", `unpriced_responses INTEGER NOT NULL DEFAULT 0`);
|
|
2735
|
+
addCostCol("pricing_version", `pricing_version INTEGER NOT NULL DEFAULT 0`);
|
|
2653
2736
|
db.exec(`
|
|
2654
2737
|
CREATE TABLE IF NOT EXISTS feature_costs (
|
|
2655
2738
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -3079,6 +3162,18 @@ function getMemoryMeta(db, key) {
|
|
|
3079
3162
|
function setMemoryMeta(db, key, value) {
|
|
3080
3163
|
db.prepare("INSERT OR REPLACE INTO memory_meta (key, value) VALUES (?, ?)").run(key, value);
|
|
3081
3164
|
}
|
|
3165
|
+
function tryAcquireMetaLease(db, key, ttlSec, nowSec = Math.floor(Date.now() / 1e3)) {
|
|
3166
|
+
const r = db.prepare(
|
|
3167
|
+
`INSERT INTO memory_meta (key, value) VALUES (?, ?)
|
|
3168
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
3169
|
+
WHERE memory_meta.value = ''
|
|
3170
|
+
OR CAST(substr(memory_meta.value, instr(memory_meta.value, ':') + 1) AS INTEGER) <= ?`
|
|
3171
|
+
).run(key, `${process.pid}:${nowSec + ttlSec}`, nowSec);
|
|
3172
|
+
return Number(r.changes) === 1;
|
|
3173
|
+
}
|
|
3174
|
+
function releaseMetaLease(db, key) {
|
|
3175
|
+
db.prepare(`UPDATE memory_meta SET value = '' WHERE key = ? AND value LIKE ?`).run(key, `${process.pid}:%`);
|
|
3176
|
+
}
|
|
3082
3177
|
function upsertObservationEmbedding(db, observationId, vec, modelId, dim) {
|
|
3083
3178
|
db.prepare(
|
|
3084
3179
|
`INSERT OR REPLACE INTO observation_embeddings (observation_id, model_id, dim, vec, created_at)
|
|
@@ -3086,9 +3181,9 @@ function upsertObservationEmbedding(db, observationId, vec, modelId, dim) {
|
|
|
3086
3181
|
).run(observationId, modelId, dim, float32ToBlob(vec));
|
|
3087
3182
|
}
|
|
3088
3183
|
function observationEmbedText(title, detail) {
|
|
3089
|
-
const
|
|
3184
|
+
const t2 = (title ?? "").trim();
|
|
3090
3185
|
const d = (detail ?? "").trim();
|
|
3091
|
-
return d ? `${
|
|
3186
|
+
return d ? `${t2}. ${d}` : t2;
|
|
3092
3187
|
}
|
|
3093
3188
|
async function embedMissingObservations(db, opts = {}) {
|
|
3094
3189
|
return runEmbedSweep(
|
|
@@ -3947,7 +4042,7 @@ var init_memory_db = __esm({
|
|
|
3947
4042
|
init_rule_candidate_store();
|
|
3948
4043
|
init_hook_failure_signal();
|
|
3949
4044
|
init_memory_embed_sweep();
|
|
3950
|
-
MEMORY_SCHEMA_VERSION =
|
|
4045
|
+
MEMORY_SCHEMA_VERSION = 2;
|
|
3951
4046
|
TOOL_COST_EVENTS_RETENTION_DAYS = 90;
|
|
3952
4047
|
MAX_DRAFT_TEXT_LEN = 16384;
|
|
3953
4048
|
FUNNEL_EVENT_OUTBOX_CAP = 2e4;
|
|
@@ -3959,129 +4054,7 @@ var init_memory_db = __esm({
|
|
|
3959
4054
|
}
|
|
3960
4055
|
});
|
|
3961
4056
|
|
|
3962
|
-
// src/hooks/lib/
|
|
3963
|
-
import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync6, existsSync as existsSync8 } from "fs";
|
|
3964
|
-
import { join as join5, dirname as dirname7 } from "path";
|
|
3965
|
-
function resolveFailureLogPath() {
|
|
3966
|
-
const explicit = process.env.MASSU_HOOK_FAILURE_LOG;
|
|
3967
|
-
if (explicit) return explicit;
|
|
3968
|
-
let dir = process.cwd();
|
|
3969
|
-
for (let i = 0; i < 12; i++) {
|
|
3970
|
-
if (existsSync8(join5(dir, ".massu")) || existsSync8(join5(dir, "massu.config.yaml"))) {
|
|
3971
|
-
return join5(dir, ".massu", "hook-failures.jsonl");
|
|
3972
|
-
}
|
|
3973
|
-
const parent = dirname7(dir);
|
|
3974
|
-
if (parent === dir) break;
|
|
3975
|
-
dir = parent;
|
|
3976
|
-
}
|
|
3977
|
-
return join5(process.cwd(), ".massu", "hook-failures.jsonl");
|
|
3978
|
-
}
|
|
3979
|
-
function recordHookFailure(hook, error, context) {
|
|
3980
|
-
const record = {
|
|
3981
|
-
hook,
|
|
3982
|
-
error: error instanceof Error ? error.message : String(error),
|
|
3983
|
-
stack: error instanceof Error ? error.stack?.split("\n").slice(0, 6).join("\n") : void 0,
|
|
3984
|
-
context,
|
|
3985
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
3986
|
-
};
|
|
3987
|
-
let wroteSomething = false;
|
|
3988
|
-
try {
|
|
3989
|
-
const path = resolveFailureLogPath();
|
|
3990
|
-
mkdirSync6(dirname7(path), { recursive: true });
|
|
3991
|
-
appendFileSync2(path, JSON.stringify(record) + "\n", "utf-8");
|
|
3992
|
-
wroteSomething = true;
|
|
3993
|
-
} catch {
|
|
3994
|
-
}
|
|
3995
|
-
try {
|
|
3996
|
-
process.stderr.write(
|
|
3997
|
-
`[massu] HOOK FAILURE in ${hook}: ${record.error}
|
|
3998
|
-
[massu] This is a bug in Massu, not in your code. See .massu/hook-failures.jsonl
|
|
3999
|
-
`
|
|
4000
|
-
);
|
|
4001
|
-
wroteSomething = true;
|
|
4002
|
-
} catch {
|
|
4003
|
-
}
|
|
4004
|
-
try {
|
|
4005
|
-
recordHookHealthRow(record);
|
|
4006
|
-
} catch {
|
|
4007
|
-
}
|
|
4008
|
-
return wroteSomething;
|
|
4009
|
-
}
|
|
4010
|
-
function recordHookHealthRow(record) {
|
|
4011
|
-
const { getMemoryDb: getMemoryDb2 } = (init_memory_db(), __toCommonJS(memory_db_exports));
|
|
4012
|
-
const db = getMemoryDb2();
|
|
4013
|
-
try {
|
|
4014
|
-
db.prepare(
|
|
4015
|
-
`INSERT INTO hook_health (hook, error, context_json, occurred_at)
|
|
4016
|
-
VALUES (?, ?, ?, ?)`
|
|
4017
|
-
).run(
|
|
4018
|
-
record.hook,
|
|
4019
|
-
record.error,
|
|
4020
|
-
record.context ? JSON.stringify(record.context) : null,
|
|
4021
|
-
record.timestamp
|
|
4022
|
-
);
|
|
4023
|
-
} finally {
|
|
4024
|
-
db.close();
|
|
4025
|
-
}
|
|
4026
|
-
}
|
|
4027
|
-
var init_hook_failure_signal = __esm({
|
|
4028
|
-
"src/hooks/lib/hook-failure-signal.ts"() {
|
|
4029
|
-
"use strict";
|
|
4030
|
-
}
|
|
4031
|
-
});
|
|
4032
|
-
|
|
4033
|
-
// src/hooks/lib/write-hook-message.ts
|
|
4034
|
-
var HOOK_EVENTS = [
|
|
4035
|
-
"UserPromptSubmit",
|
|
4036
|
-
"UserPromptExpansion",
|
|
4037
|
-
"SessionStart",
|
|
4038
|
-
"PreToolUse",
|
|
4039
|
-
"PostToolUse",
|
|
4040
|
-
"Stop",
|
|
4041
|
-
"SubagentStop",
|
|
4042
|
-
"PreCompact"
|
|
4043
|
-
];
|
|
4044
|
-
var ADDITIONAL_CONTEXT_MAX_CHARS = 1e4;
|
|
4045
|
-
function isHookEvent(value) {
|
|
4046
|
-
return typeof value === "string" && HOOK_EVENTS.includes(value);
|
|
4047
|
-
}
|
|
4048
|
-
function writeHookContext(hookEventName, message) {
|
|
4049
|
-
if (!isHookEvent(hookEventName)) {
|
|
4050
|
-
process.stderr.write(
|
|
4051
|
-
`[massu] writeHookContext: refusing to emit \u2014 unrecognised hook event ${JSON.stringify(hookEventName)}. Known: ${HOOK_EVENTS.join(", ")}.
|
|
4052
|
-
`
|
|
4053
|
-
);
|
|
4054
|
-
return;
|
|
4055
|
-
}
|
|
4056
|
-
if (typeof message !== "string" || message.length === 0) return;
|
|
4057
|
-
let additionalContext = message;
|
|
4058
|
-
if (additionalContext.length > ADDITIONAL_CONTEXT_MAX_CHARS) {
|
|
4059
|
-
const marker = `
|
|
4060
|
-
[massu] \u2026truncated at ${ADDITIONAL_CONTEXT_MAX_CHARS} chars (spec cap).`;
|
|
4061
|
-
additionalContext = additionalContext.slice(0, ADDITIONAL_CONTEXT_MAX_CHARS - marker.length) + marker;
|
|
4062
|
-
}
|
|
4063
|
-
process.stdout.write(
|
|
4064
|
-
JSON.stringify({ hookSpecificOutput: { hookEventName, additionalContext } }) + "\n"
|
|
4065
|
-
);
|
|
4066
|
-
}
|
|
4067
|
-
|
|
4068
|
-
// src/hooks/security-gate.ts
|
|
4069
|
-
init_hook_failure_signal();
|
|
4070
|
-
|
|
4071
|
-
// src/hooks/lib/is-direct-invocation.ts
|
|
4072
|
-
import { pathToFileURL } from "url";
|
|
4073
|
-
function isDirectInvocation(moduleUrl) {
|
|
4074
|
-
const entry = process.argv[1];
|
|
4075
|
-
if (!entry) return false;
|
|
4076
|
-
try {
|
|
4077
|
-
return moduleUrl === pathToFileURL(entry).href;
|
|
4078
|
-
} catch {
|
|
4079
|
-
return false;
|
|
4080
|
-
}
|
|
4081
|
-
}
|
|
4082
|
-
|
|
4083
|
-
// src/hooks/security-gate.ts
|
|
4084
|
-
var HOOK_EVENT = "PreToolUse";
|
|
4057
|
+
// src/hooks/lib/security-gate-checks.ts
|
|
4085
4058
|
var DANGEROUS_BASH_PATTERNS = [
|
|
4086
4059
|
// BLOCK — destructive and irreversible, or arbitrary remote code execution.
|
|
4087
4060
|
{ pattern: /rm\s+-[a-z]*r[a-z]*f[a-z]*\s+\/(?:\s|$)/, label: "rm -rf /", severity: "block" },
|
|
@@ -4133,6 +4106,7 @@ var SECRET_EXACT_BASENAMES = [
|
|
|
4133
4106
|
];
|
|
4134
4107
|
var CREDENTIALS_PATH_SUFFIX = "/credentials";
|
|
4135
4108
|
var DOTENV = ".env";
|
|
4109
|
+
var SECRET_DATA_EXTENSIONS = ["yaml", "yml", "json", "toml", "ini", "env", "conf", "properties", "txt"];
|
|
4136
4110
|
function looksLikeCode(token) {
|
|
4137
4111
|
return /[()[\]{};=`$<>|&*?!"']/.test(token) || /\s/.test(token);
|
|
4138
4112
|
}
|
|
@@ -4151,8 +4125,13 @@ function namesSecretFile(token) {
|
|
|
4151
4125
|
return DOTENV_TEMPLATE_SUFFIXES.includes(suffix) ? null : `${base} file`;
|
|
4152
4126
|
}
|
|
4153
4127
|
if (base.endsWith(DOTENV) && clean.includes("/")) return `${base} file`;
|
|
4154
|
-
for (const
|
|
4155
|
-
if (base.endsWith(`.${
|
|
4128
|
+
for (const ext2 of SECRET_BASENAME_EXTENSIONS) {
|
|
4129
|
+
if (base.endsWith(`.${ext2}`)) return `${base} file`;
|
|
4130
|
+
}
|
|
4131
|
+
const parts = base.toLowerCase().split(".");
|
|
4132
|
+
const ext = parts.length > 1 ? parts[parts.length - 1] : "";
|
|
4133
|
+
if (SECRET_DATA_EXTENSIONS.includes(ext) && parts.slice(0, -1).some((p) => p === "secret" || p === "secrets")) {
|
|
4134
|
+
return `${base} file`;
|
|
4156
4135
|
}
|
|
4157
4136
|
return null;
|
|
4158
4137
|
}
|
|
@@ -4314,30 +4293,440 @@ Review carefully before proceeding.`
|
|
|
4314
4293
|
function runSecurityGateChecks(hookInput) {
|
|
4315
4294
|
return runSecurityGateFindings(hookInput).map((f) => f.message);
|
|
4316
4295
|
}
|
|
4317
|
-
|
|
4296
|
+
|
|
4297
|
+
// src/hooks/lib/pre-delete-checks.ts
|
|
4298
|
+
init_db_driver();
|
|
4299
|
+
import { existsSync as existsSync9 } from "fs";
|
|
4300
|
+
|
|
4301
|
+
// src/sentinel-db.ts
|
|
4302
|
+
init_config();
|
|
4303
|
+
init_memory_db();
|
|
4304
|
+
|
|
4305
|
+
// src/lib/sql-table-names.ts
|
|
4306
|
+
init_config();
|
|
4307
|
+
function t(suffix) {
|
|
4308
|
+
return `${getConfig().toolPrefix}_${suffix}`;
|
|
4309
|
+
}
|
|
4310
|
+
|
|
4311
|
+
// src/sentinel-db.ts
|
|
4312
|
+
var PROJECT_ROOT = getProjectRoot();
|
|
4313
|
+
function parsePortalScope(raw) {
|
|
4314
|
+
if (!raw) return [];
|
|
4318
4315
|
try {
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4316
|
+
return JSON.parse(raw);
|
|
4317
|
+
} catch {
|
|
4318
|
+
return [];
|
|
4319
|
+
}
|
|
4320
|
+
}
|
|
4321
|
+
function toFeature(row) {
|
|
4322
|
+
return {
|
|
4323
|
+
id: row.id,
|
|
4324
|
+
feature_key: row.feature_key,
|
|
4325
|
+
domain: row.domain,
|
|
4326
|
+
subdomain: row.subdomain || null,
|
|
4327
|
+
title: row.title,
|
|
4328
|
+
description: row.description || null,
|
|
4329
|
+
status: row.status,
|
|
4330
|
+
priority: row.priority,
|
|
4331
|
+
portal_scope: parsePortalScope(row.portal_scope),
|
|
4332
|
+
created_at: row.created_at,
|
|
4333
|
+
updated_at: row.updated_at,
|
|
4334
|
+
removed_at: row.removed_at || null,
|
|
4335
|
+
removed_reason: row.removed_reason || null
|
|
4336
|
+
};
|
|
4337
|
+
}
|
|
4338
|
+
function getFeatureById(db, id) {
|
|
4339
|
+
const row = db.prepare(`SELECT * FROM ${t("sentinel")} WHERE id = ?`).get(id);
|
|
4340
|
+
return row ? toFeature(row) : null;
|
|
4341
|
+
}
|
|
4342
|
+
function getFeatureImpact(db, filePaths) {
|
|
4343
|
+
const fileSet = new Set(filePaths);
|
|
4344
|
+
const affectedFeatureIds = /* @__PURE__ */ new Set();
|
|
4345
|
+
for (const filePath of filePaths) {
|
|
4346
|
+
const links = db.prepare(
|
|
4347
|
+
`SELECT feature_id FROM ${t("sentinel_components")} WHERE component_file = ? LIMIT 10000`
|
|
4348
|
+
).all(filePath);
|
|
4349
|
+
for (const link of links) {
|
|
4350
|
+
affectedFeatureIds.add(link.feature_id);
|
|
4351
|
+
}
|
|
4352
|
+
}
|
|
4353
|
+
const orphaned = [];
|
|
4354
|
+
const degraded = [];
|
|
4355
|
+
const unaffected = [];
|
|
4356
|
+
for (const featureId of affectedFeatureIds) {
|
|
4357
|
+
const feature = getFeatureById(db, featureId);
|
|
4358
|
+
if (!feature || feature.status !== "active") continue;
|
|
4359
|
+
const allComponents = db.prepare(
|
|
4360
|
+
`SELECT component_file, is_primary FROM ${t("sentinel_components")} WHERE feature_id = ? LIMIT 10000`
|
|
4361
|
+
).all(featureId);
|
|
4362
|
+
const affected = allComponents.filter((c) => fileSet.has(c.component_file));
|
|
4363
|
+
const remaining = allComponents.filter((c) => !fileSet.has(c.component_file));
|
|
4364
|
+
const primaryAffected = affected.some((c) => c.is_primary);
|
|
4365
|
+
const item = {
|
|
4366
|
+
feature,
|
|
4367
|
+
affected_files: affected.map((c) => c.component_file),
|
|
4368
|
+
remaining_files: remaining.map((c) => c.component_file),
|
|
4369
|
+
status: "unaffected"
|
|
4370
|
+
};
|
|
4371
|
+
if (primaryAffected && remaining.filter((c) => c.is_primary).length === 0) {
|
|
4372
|
+
item.status = "orphaned";
|
|
4373
|
+
orphaned.push(item);
|
|
4374
|
+
} else if (affected.length > 0) {
|
|
4375
|
+
item.status = "degraded";
|
|
4376
|
+
degraded.push(item);
|
|
4377
|
+
} else {
|
|
4378
|
+
unaffected.push(item);
|
|
4379
|
+
}
|
|
4380
|
+
}
|
|
4381
|
+
const hasCriticalOrphans = orphaned.some((o) => o.feature.priority === "critical");
|
|
4382
|
+
const hasStandardOrphans = orphaned.some((o) => o.feature.priority === "standard");
|
|
4383
|
+
return {
|
|
4384
|
+
files_analyzed: filePaths,
|
|
4385
|
+
orphaned,
|
|
4386
|
+
degraded,
|
|
4387
|
+
unaffected,
|
|
4388
|
+
blocked: hasCriticalOrphans || hasStandardOrphans,
|
|
4389
|
+
block_reason: hasCriticalOrphans ? `BLOCKED: ${orphaned.length} features would be orphaned (includes critical features). Create migration plan first.` : hasStandardOrphans ? `BLOCKED: ${orphaned.length} standard features would be orphaned. Create migration plan first.` : null
|
|
4390
|
+
};
|
|
4391
|
+
}
|
|
4392
|
+
|
|
4393
|
+
// src/hooks/lib/pre-delete-checks.ts
|
|
4394
|
+
init_config();
|
|
4395
|
+
|
|
4396
|
+
// src/lib/source-layout.ts
|
|
4397
|
+
init_config();
|
|
4398
|
+
function normalizeDir(raw) {
|
|
4399
|
+
if (typeof raw !== "string") return null;
|
|
4400
|
+
const posix = raw.replace(/\\/g, "/");
|
|
4401
|
+
if (posix.startsWith("/") || /^[A-Za-z]:\//.test(posix)) return null;
|
|
4402
|
+
const parts = posix.split("/").filter((p) => p !== "" && p !== ".");
|
|
4403
|
+
if (parts.some((p) => p === "..")) return null;
|
|
4404
|
+
if (parts.length === 0) return ".";
|
|
4405
|
+
return parts.join("/");
|
|
4406
|
+
}
|
|
4407
|
+
function declaredLanguageSourceDirs() {
|
|
4408
|
+
const languages = getConfig().framework.languages;
|
|
4409
|
+
if (!languages) return [];
|
|
4410
|
+
const out = [];
|
|
4411
|
+
for (const entry of Object.values(languages)) {
|
|
4412
|
+
if (Array.isArray(entry.source_dirs)) out.push(...entry.source_dirs);
|
|
4413
|
+
}
|
|
4414
|
+
return out;
|
|
4415
|
+
}
|
|
4416
|
+
function getSourceLayout() {
|
|
4417
|
+
const config = getConfig();
|
|
4418
|
+
const paths = config.paths;
|
|
4419
|
+
const declared = [
|
|
4420
|
+
paths.source,
|
|
4421
|
+
...paths.monorepo_roots ?? [],
|
|
4422
|
+
...declaredLanguageSourceDirs()
|
|
4423
|
+
];
|
|
4424
|
+
const normalized = declared.map(normalizeDir).filter((d) => d !== null);
|
|
4425
|
+
const includesRoot = normalized.includes(".");
|
|
4426
|
+
const sourceDirs = includesRoot ? [] : [...new Set(normalized)].sort();
|
|
4427
|
+
const source = normalizeDir(paths.source) ?? ".";
|
|
4428
|
+
const under = (child) => source === "." ? child : `${source}/${child}`;
|
|
4429
|
+
return {
|
|
4430
|
+
sourceDirs,
|
|
4431
|
+
includesRoot,
|
|
4432
|
+
extensions: getResolvedPaths().extensions,
|
|
4433
|
+
pagesDir: normalizeDir(paths.pages ?? under("app")) ?? under("app"),
|
|
4434
|
+
componentsDir: normalizeDir(paths.components ?? under("components")) ?? under("components")
|
|
4435
|
+
};
|
|
4436
|
+
}
|
|
4437
|
+
function isUnderSourceDir(path, layout = getSourceLayout()) {
|
|
4438
|
+
if (normalizeDir(path) === null) return false;
|
|
4439
|
+
if (layout.includesRoot) return true;
|
|
4440
|
+
return layout.sourceDirs.some((d) => path.startsWith(`${d}/`));
|
|
4441
|
+
}
|
|
4442
|
+
|
|
4443
|
+
// src/hooks/lib/pre-delete-checks.ts
|
|
4444
|
+
var PROJECT_ROOT2 = getProjectRoot();
|
|
4445
|
+
var KNOWLEDGE_PROTECTED_FILES = [
|
|
4446
|
+
"knowledge-db.ts",
|
|
4447
|
+
"knowledge-indexer.ts",
|
|
4448
|
+
"knowledge-tools.ts"
|
|
4449
|
+
];
|
|
4450
|
+
function getDataDb() {
|
|
4451
|
+
const dbPath = getResolvedPaths().dataDbPath;
|
|
4452
|
+
if (!existsSync9(dbPath)) return null;
|
|
4453
|
+
try {
|
|
4454
|
+
const db = openDatabase2(dbPath, { readonly: true, selfHeal: false });
|
|
4455
|
+
db.pragma("journal_mode = WAL");
|
|
4456
|
+
return db;
|
|
4457
|
+
} catch {
|
|
4458
|
+
return null;
|
|
4459
|
+
}
|
|
4460
|
+
}
|
|
4461
|
+
function checkKnowledgeFileProtection(input) {
|
|
4462
|
+
const candidateFiles = [];
|
|
4463
|
+
if (input.tool_name === "Bash" && input.tool_input.command) {
|
|
4464
|
+
const cmd = input.tool_input.command;
|
|
4465
|
+
const rmMatch = cmd.match(/(?:rm|git\s+rm)\s+(?:-[rf]*\s+)*(.+)/);
|
|
4466
|
+
if (rmMatch) {
|
|
4467
|
+
const parts = rmMatch[1].split(/\s+/).filter((p) => !p.startsWith("-"));
|
|
4468
|
+
candidateFiles.push(...parts);
|
|
4469
|
+
}
|
|
4470
|
+
}
|
|
4471
|
+
if (input.tool_name === "Write" && input.tool_input.file_path) {
|
|
4472
|
+
const content = input.tool_input.content || "";
|
|
4473
|
+
if (content.trim().length === 0) {
|
|
4474
|
+
candidateFiles.push(input.tool_input.file_path);
|
|
4475
|
+
}
|
|
4476
|
+
}
|
|
4477
|
+
for (const f of candidateFiles) {
|
|
4478
|
+
const basename3 = f.split("/").pop() ?? f;
|
|
4479
|
+
if (KNOWLEDGE_PROTECTED_FILES.includes(basename3)) {
|
|
4480
|
+
return `KNOWLEDGE SYSTEM PROTECTION: "${basename3}" is a core knowledge system file. Deleting it will break knowledge indexing and memory retrieval. Create a replacement before removing.`;
|
|
4481
|
+
}
|
|
4482
|
+
}
|
|
4483
|
+
return null;
|
|
4484
|
+
}
|
|
4485
|
+
function extractDeletedFiles(input) {
|
|
4486
|
+
const files = [];
|
|
4487
|
+
if (input.tool_name === "Bash" && input.tool_input.command) {
|
|
4488
|
+
const cmd = input.tool_input.command;
|
|
4489
|
+
const rmMatch = cmd.match(/(?:rm|git\s+rm)\s+(?:-[rf]*\s+)*(.+)/);
|
|
4490
|
+
if (rmMatch) {
|
|
4491
|
+
const paths = rmMatch[1].split(/\s+/).filter((p) => !p.startsWith("-"));
|
|
4492
|
+
for (const p of paths) {
|
|
4493
|
+
const relPath = isUnderSourceDir(p) ? p : p.replace(PROJECT_ROOT2 + "/", "");
|
|
4494
|
+
if (isUnderSourceDir(relPath) || relPath.endsWith(".py")) {
|
|
4495
|
+
files.push(relPath);
|
|
4496
|
+
}
|
|
4497
|
+
}
|
|
4498
|
+
}
|
|
4499
|
+
}
|
|
4500
|
+
if (input.tool_name === "Write" && input.tool_input.file_path) {
|
|
4501
|
+
const content = input.tool_input.content || "";
|
|
4502
|
+
if (content.trim().length === 0) {
|
|
4503
|
+
const relPath = input.tool_input.file_path.replace(PROJECT_ROOT2 + "/", "");
|
|
4504
|
+
if (isUnderSourceDir(relPath) || relPath.endsWith(".py")) {
|
|
4505
|
+
files.push(relPath);
|
|
4506
|
+
}
|
|
4507
|
+
}
|
|
4508
|
+
}
|
|
4509
|
+
return files;
|
|
4510
|
+
}
|
|
4511
|
+
function runPreDeleteChecks(hookInput) {
|
|
4512
|
+
const messages = [];
|
|
4513
|
+
try {
|
|
4514
|
+
const knowledgeWarning = checkKnowledgeFileProtection(hookInput);
|
|
4515
|
+
if (knowledgeWarning) {
|
|
4516
|
+
messages.push(knowledgeWarning);
|
|
4517
|
+
return messages;
|
|
4518
|
+
}
|
|
4519
|
+
const deletedFiles = extractDeletedFiles(hookInput);
|
|
4520
|
+
if (deletedFiles.length === 0) return messages;
|
|
4521
|
+
const db = getDataDb();
|
|
4522
|
+
if (!db) return messages;
|
|
4523
|
+
const SENTINEL_TABLE = t("sentinel");
|
|
4524
|
+
try {
|
|
4525
|
+
const tableExists2 = db.prepare(
|
|
4526
|
+
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`
|
|
4527
|
+
).get(SENTINEL_TABLE);
|
|
4528
|
+
if (!tableExists2) return messages;
|
|
4529
|
+
const impact = getFeatureImpact(db, deletedFiles);
|
|
4530
|
+
if (impact.blocked) {
|
|
4531
|
+
const msg = [
|
|
4532
|
+
`SENTINEL IMPACT WARNING: Deleting ${deletedFiles.length} file(s) would affect features:`,
|
|
4533
|
+
""
|
|
4534
|
+
];
|
|
4535
|
+
if (impact.orphaned.length > 0) {
|
|
4536
|
+
msg.push(`ORPHANED (${impact.orphaned.length} features - no primary components left):`);
|
|
4537
|
+
for (const item of impact.orphaned) {
|
|
4538
|
+
msg.push(` - ${item.feature.feature_key} [${item.feature.priority}]: ${item.feature.title}`);
|
|
4539
|
+
}
|
|
4540
|
+
}
|
|
4541
|
+
if (impact.degraded.length > 0) {
|
|
4542
|
+
msg.push(`DEGRADED (${impact.degraded.length} features - some components removed):`);
|
|
4543
|
+
for (const item of impact.degraded) {
|
|
4544
|
+
msg.push(` - ${item.feature.feature_key}: ${item.feature.title}`);
|
|
4545
|
+
}
|
|
4546
|
+
}
|
|
4547
|
+
msg.push("");
|
|
4548
|
+
msg.push("Create a migration plan before deleting these files.");
|
|
4549
|
+
messages.push(msg.join("\n"));
|
|
4550
|
+
}
|
|
4551
|
+
const pyFiles = deletedFiles.filter((f) => f.endsWith(".py"));
|
|
4552
|
+
if (pyFiles.length > 0) {
|
|
4553
|
+
try {
|
|
4554
|
+
for (const pyFile of pyFiles) {
|
|
4555
|
+
const importers = db.prepare(
|
|
4556
|
+
`SELECT source_file FROM ${t("py_imports")} WHERE target_file = ? LIMIT 10000`
|
|
4557
|
+
).all(pyFile);
|
|
4558
|
+
const routes = db.prepare(
|
|
4559
|
+
`SELECT method, path FROM ${t("py_routes")} WHERE file = ? LIMIT 10000`
|
|
4560
|
+
).all(pyFile);
|
|
4561
|
+
const models = db.prepare(
|
|
4562
|
+
`SELECT class_name FROM ${t("py_models")} WHERE file = ? LIMIT 10000`
|
|
4563
|
+
).all(pyFile);
|
|
4564
|
+
if (importers.length > 0 || routes.length > 0 || models.length > 0) {
|
|
4565
|
+
const parts = [];
|
|
4566
|
+
if (importers.length > 0) parts.push(`imported by ${importers.length} files`);
|
|
4567
|
+
if (routes.length > 0) parts.push(`defines ${routes.length} routes`);
|
|
4568
|
+
if (models.length > 0) parts.push(`defines ${models.length} models`);
|
|
4569
|
+
messages.push(`PYTHON IMPACT: "${pyFile}" ${parts.join(", ")}. Check dependents before deleting.`);
|
|
4570
|
+
}
|
|
4571
|
+
}
|
|
4572
|
+
} catch {
|
|
4573
|
+
}
|
|
4574
|
+
}
|
|
4575
|
+
} finally {
|
|
4576
|
+
db.close();
|
|
4577
|
+
}
|
|
4578
|
+
} catch {
|
|
4579
|
+
}
|
|
4580
|
+
return messages;
|
|
4581
|
+
}
|
|
4582
|
+
|
|
4583
|
+
// src/hooks/lib/write-hook-message.ts
|
|
4584
|
+
var HOOK_EVENTS = [
|
|
4585
|
+
"UserPromptSubmit",
|
|
4586
|
+
"UserPromptExpansion",
|
|
4587
|
+
"SessionStart",
|
|
4588
|
+
"PreToolUse",
|
|
4589
|
+
"PostToolUse",
|
|
4590
|
+
"Stop",
|
|
4591
|
+
"SubagentStop",
|
|
4592
|
+
"PreCompact"
|
|
4593
|
+
];
|
|
4594
|
+
var ADDITIONAL_CONTEXT_MAX_CHARS = 1e4;
|
|
4595
|
+
function isHookEvent(value) {
|
|
4596
|
+
return typeof value === "string" && HOOK_EVENTS.includes(value);
|
|
4597
|
+
}
|
|
4598
|
+
function writeHookContext(hookEventName, message) {
|
|
4599
|
+
if (!isHookEvent(hookEventName)) {
|
|
4600
|
+
process.stderr.write(
|
|
4601
|
+
`[massu] writeHookContext: refusing to emit \u2014 unrecognised hook event ${JSON.stringify(hookEventName)}. Known: ${HOOK_EVENTS.join(", ")}.
|
|
4602
|
+
`
|
|
4603
|
+
);
|
|
4604
|
+
return;
|
|
4327
4605
|
}
|
|
4328
|
-
|
|
4606
|
+
if (typeof message !== "string" || message.length === 0) return;
|
|
4607
|
+
let additionalContext = message;
|
|
4608
|
+
if (additionalContext.length > ADDITIONAL_CONTEXT_MAX_CHARS) {
|
|
4609
|
+
const marker = `
|
|
4610
|
+
[massu] \u2026truncated at ${ADDITIONAL_CONTEXT_MAX_CHARS} chars (spec cap).`;
|
|
4611
|
+
additionalContext = additionalContext.slice(0, ADDITIONAL_CONTEXT_MAX_CHARS - marker.length) + marker;
|
|
4612
|
+
}
|
|
4613
|
+
process.stdout.write(
|
|
4614
|
+
JSON.stringify({ hookSpecificOutput: { hookEventName, additionalContext } }) + "\n"
|
|
4615
|
+
);
|
|
4329
4616
|
}
|
|
4330
|
-
|
|
4331
|
-
|
|
4617
|
+
|
|
4618
|
+
// src/hooks/lib/read-stdin.ts
|
|
4619
|
+
var StdinTimeoutError = class extends Error {
|
|
4620
|
+
bytesRead;
|
|
4621
|
+
constructor(ms, bytesRead) {
|
|
4622
|
+
super(
|
|
4623
|
+
`stdin did not reach EOF within ${ms}ms (${bytesRead} bytes read). The payload is INCOMPLETE and must not be treated as valid input.`
|
|
4624
|
+
);
|
|
4625
|
+
this.name = "StdinTimeoutError";
|
|
4626
|
+
this.bytesRead = bytesRead;
|
|
4627
|
+
}
|
|
4628
|
+
};
|
|
4629
|
+
function readStdinToEof(deadlineMs = 4e3) {
|
|
4630
|
+
return new Promise((resolve5, reject) => {
|
|
4332
4631
|
let data = "";
|
|
4632
|
+
let settled = false;
|
|
4633
|
+
const timer = setTimeout(() => {
|
|
4634
|
+
if (settled) return;
|
|
4635
|
+
settled = true;
|
|
4636
|
+
reject(new StdinTimeoutError(deadlineMs, data.length));
|
|
4637
|
+
}, deadlineMs);
|
|
4638
|
+
const finish = (fn) => {
|
|
4639
|
+
if (settled) return;
|
|
4640
|
+
settled = true;
|
|
4641
|
+
clearTimeout(timer);
|
|
4642
|
+
fn();
|
|
4643
|
+
};
|
|
4333
4644
|
process.stdin.setEncoding("utf-8");
|
|
4334
4645
|
process.stdin.on("data", (chunk) => {
|
|
4335
4646
|
data += chunk;
|
|
4336
4647
|
});
|
|
4337
|
-
process.stdin.on("end", () => resolve5(data));
|
|
4338
|
-
|
|
4648
|
+
process.stdin.on("end", () => finish(() => resolve5(data)));
|
|
4649
|
+
process.stdin.on("error", (err) => finish(() => reject(err)));
|
|
4339
4650
|
});
|
|
4340
4651
|
}
|
|
4652
|
+
|
|
4653
|
+
// src/hooks/lib/pre-tool-use-decision.ts
|
|
4654
|
+
init_hook_failure_signal();
|
|
4655
|
+
var EXIT_DENY = 2;
|
|
4656
|
+
var EXIT_ALLOW = 0;
|
|
4657
|
+
function deny(reason, detail) {
|
|
4658
|
+
process.stderr.write(
|
|
4659
|
+
`MASSU SECURITY GATE \u2014 BLOCKED
|
|
4660
|
+
|
|
4661
|
+
${reason}
|
|
4662
|
+
` + (detail ? `
|
|
4663
|
+
${detail}
|
|
4664
|
+
` : "") + `
|
|
4665
|
+
The gate denies by default when it cannot verify a command is safe.
|
|
4666
|
+
`
|
|
4667
|
+
);
|
|
4668
|
+
process.exit(EXIT_DENY);
|
|
4669
|
+
}
|
|
4670
|
+
async function runPreToolUseDecision(hookName, event) {
|
|
4671
|
+
let hookInput;
|
|
4672
|
+
let input = "";
|
|
4673
|
+
try {
|
|
4674
|
+
input = await readStdinToEof();
|
|
4675
|
+
if (!input.trim()) {
|
|
4676
|
+
recordHookFailure(hookName, new Error("empty stdin payload"));
|
|
4677
|
+
deny("The gate received an EMPTY payload and could not determine what tool call was requested.");
|
|
4678
|
+
}
|
|
4679
|
+
hookInput = JSON.parse(input);
|
|
4680
|
+
} catch (err) {
|
|
4681
|
+
recordHookFailure(hookName, err, {
|
|
4682
|
+
phase: "read-parse",
|
|
4683
|
+
bytesRead: input.length,
|
|
4684
|
+
endsWithBrace: input.trimEnd().endsWith("}")
|
|
4685
|
+
});
|
|
4686
|
+
deny(
|
|
4687
|
+
"The gate could not READ or PARSE the tool-call payload, so it cannot know what it was being asked to approve.",
|
|
4688
|
+
err instanceof Error ? `Cause: ${err.message}` : void 0
|
|
4689
|
+
);
|
|
4690
|
+
}
|
|
4691
|
+
let findings;
|
|
4692
|
+
try {
|
|
4693
|
+
const security = runSecurityGateFindings(hookInput);
|
|
4694
|
+
const deletes = runPreDeleteChecks(hookInput).map(
|
|
4695
|
+
(message) => ({ severity: "warn", message })
|
|
4696
|
+
);
|
|
4697
|
+
findings = [...security, ...deletes];
|
|
4698
|
+
} catch (err) {
|
|
4699
|
+
recordHookFailure(hookName, err, { phase: "evaluate" });
|
|
4700
|
+
deny(
|
|
4701
|
+
"A security check CRASHED while evaluating this tool call. A check that did not run has not passed.",
|
|
4702
|
+
err instanceof Error ? `Cause: ${err.message}` : void 0
|
|
4703
|
+
);
|
|
4704
|
+
}
|
|
4705
|
+
for (const f of findings.filter((x) => x.severity === "warn")) {
|
|
4706
|
+
writeHookContext(event, f.message);
|
|
4707
|
+
}
|
|
4708
|
+
const blocking = findings.filter((f) => f.severity === "block");
|
|
4709
|
+
if (blocking.length > 0) deny(blocking.map((f) => f.message).join("\n\n"));
|
|
4710
|
+
process.exit(EXIT_ALLOW);
|
|
4711
|
+
}
|
|
4712
|
+
|
|
4713
|
+
// src/hooks/lib/is-direct-invocation.ts
|
|
4714
|
+
import { pathToFileURL } from "url";
|
|
4715
|
+
function isDirectInvocation(moduleUrl) {
|
|
4716
|
+
const entry = process.argv[1];
|
|
4717
|
+
if (!entry) return false;
|
|
4718
|
+
try {
|
|
4719
|
+
return moduleUrl === pathToFileURL(entry).href;
|
|
4720
|
+
} catch {
|
|
4721
|
+
return false;
|
|
4722
|
+
}
|
|
4723
|
+
}
|
|
4724
|
+
|
|
4725
|
+
// src/hooks/security-gate.ts
|
|
4726
|
+
var HOOK_EVENT = "PreToolUse";
|
|
4727
|
+
async function main() {
|
|
4728
|
+
await runPreToolUseDecision("security-gate", HOOK_EVENT);
|
|
4729
|
+
}
|
|
4341
4730
|
if (isDirectInvocation(import.meta.url)) {
|
|
4342
4731
|
main();
|
|
4343
4732
|
}
|