@massu/core 2.5.5 → 2.5.7
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 +63 -0
- package/dist/cli.js +167 -46
- package/dist/hooks/auto-learning-pipeline.js +81 -16
- package/dist/hooks/classify-failure.js +90 -652
- package/dist/hooks/cost-tracker.js +69 -4
- package/dist/hooks/fix-detector.js +79 -12
- package/dist/hooks/incident-pipeline.js +72 -7
- package/dist/hooks/intent-suggester.js +69 -4
- package/dist/hooks/memory-recall.js +89 -6
- package/dist/hooks/post-edit-context.js +69 -4
- package/dist/hooks/post-tool-use.js +76 -11
- package/dist/hooks/pre-compact.js +69 -4
- package/dist/hooks/pre-delete-check.js +446 -71
- package/dist/hooks/pre-tool-use-gate.js +237 -223
- package/dist/hooks/quality-event.js +69 -4
- package/dist/hooks/rule-enforcement-pipeline.js +72 -7
- package/dist/hooks/security-gate.js +611 -181
- package/dist/hooks/session-end.js +607 -73
- package/dist/hooks/session-start.js +112 -8
- package/dist/hooks/user-prompt.js +83 -18
- package/package.json +1 -1
|
@@ -2157,6 +2157,8 @@ __export(memory_db_exports, {
|
|
|
2157
2157
|
MEMORY_FILE_TITLE_LIKE: () => MEMORY_FILE_TITLE_LIKE,
|
|
2158
2158
|
MEMORY_FILE_TITLE_PREFIX: () => MEMORY_FILE_TITLE_PREFIX,
|
|
2159
2159
|
MEMORY_SCHEMA_VERSION: () => MEMORY_SCHEMA_VERSION,
|
|
2160
|
+
PENDING_SYNC_MAX_AGE_DAYS: () => PENDING_SYNC_MAX_AGE_DAYS,
|
|
2161
|
+
PENDING_SYNC_MAX_ITEMS: () => PENDING_SYNC_MAX_ITEMS,
|
|
2160
2162
|
TOOL_COST_EVENTS_RETENTION_DAYS: () => TOOL_COST_EVENTS_RETENTION_DAYS,
|
|
2161
2163
|
USAGE_COUNTER_ARMED_KEY: () => USAGE_COUNTER_ARMED_KEY,
|
|
2162
2164
|
addConversationTurn: () => addConversationTurn,
|
|
@@ -2165,6 +2167,7 @@ __export(memory_db_exports, {
|
|
|
2165
2167
|
addSummary: () => addSummary,
|
|
2166
2168
|
addToolCallDetail: () => addToolCallDetail,
|
|
2167
2169
|
addUserPrompt: () => addUserPrompt,
|
|
2170
|
+
allPendingSync: () => allPendingSync,
|
|
2168
2171
|
appendIncidentToFailureClass: () => appendIncidentToFailureClass,
|
|
2169
2172
|
armUsageCounter: () => armUsageCounter,
|
|
2170
2173
|
assignImportance: () => assignImportance,
|
|
@@ -2172,6 +2175,7 @@ __export(memory_db_exports, {
|
|
|
2172
2175
|
createSession: () => createSession,
|
|
2173
2176
|
deduplicateFailedAttempt: () => deduplicateFailedAttempt,
|
|
2174
2177
|
dequeuePendingSync: () => dequeuePendingSync,
|
|
2178
|
+
discardPendingSync: () => discardPendingSync,
|
|
2175
2179
|
embedMissingObservations: () => embedMissingObservations,
|
|
2176
2180
|
endSession: () => endSession,
|
|
2177
2181
|
enqueueRulePromotionEvent: () => enqueueRulePromotionEvent,
|
|
@@ -3154,6 +3158,35 @@ function initMemorySchema(db) {
|
|
|
3154
3158
|
}
|
|
3155
3159
|
function enqueueSyncPayload(db, payload) {
|
|
3156
3160
|
db.prepare("INSERT INTO pending_sync (payload) VALUES (?)").run(payload);
|
|
3161
|
+
const overflow = db.prepare(
|
|
3162
|
+
`SELECT id, retry_count, last_error, payload FROM pending_sync
|
|
3163
|
+
WHERE created_at < datetime('now', ?)
|
|
3164
|
+
OR id NOT IN (SELECT id FROM pending_sync ORDER BY created_at DESC, id DESC LIMIT ?)
|
|
3165
|
+
LIMIT 10000`
|
|
3166
|
+
).all(`-${PENDING_SYNC_MAX_AGE_DAYS} days`, PENDING_SYNC_MAX_ITEMS);
|
|
3167
|
+
if (overflow.length > 0) {
|
|
3168
|
+
discardPendingSync(db, overflow, `over the queue limit (${PENDING_SYNC_MAX_ITEMS} items / ${PENDING_SYNC_MAX_AGE_DAYS} days)`);
|
|
3169
|
+
}
|
|
3170
|
+
}
|
|
3171
|
+
function discardPendingSync(db, rows, why) {
|
|
3172
|
+
if (rows.length === 0) return 0;
|
|
3173
|
+
const rescued = rescueLearningFromStalePayloads(db, rows);
|
|
3174
|
+
for (let i = 0; i < rows.length; i += 500) {
|
|
3175
|
+
const ids = rows.slice(i, i + 500).map((r) => r.id);
|
|
3176
|
+
db.prepare(`DELETE FROM pending_sync WHERE id IN (${ids.map(() => "?").join(",")})`).run(...ids);
|
|
3177
|
+
}
|
|
3178
|
+
if (rescued > 0) {
|
|
3179
|
+
process.stderr.write(
|
|
3180
|
+
`[massu] RESCUED ${rescued} learned rule(s) from ${rows.length} discarded payload(s) \u2014 they were re-queued for delivery, NOT discarded.
|
|
3181
|
+
`
|
|
3182
|
+
);
|
|
3183
|
+
}
|
|
3184
|
+
process.stderr.write(`[massu] cloud sync: discarded ${rows.length} queued upload(s): ${why}.
|
|
3185
|
+
`);
|
|
3186
|
+
return rows.length;
|
|
3187
|
+
}
|
|
3188
|
+
function allPendingSync(db) {
|
|
3189
|
+
return db.prepare("SELECT id, retry_count, last_error, payload FROM pending_sync").all();
|
|
3157
3190
|
}
|
|
3158
3191
|
function getMemoryMeta(db, key) {
|
|
3159
3192
|
const row = db.prepare("SELECT value FROM memory_meta WHERE key = ?").get(key);
|
|
@@ -3356,7 +3389,9 @@ function dequeuePendingSync(db, limit = 10) {
|
|
|
3356
3389
|
}
|
|
3357
3390
|
}
|
|
3358
3391
|
return db.prepare(
|
|
3359
|
-
|
|
3392
|
+
// Least-retried first: oldest-first retried the same 10 items until they were discarded
|
|
3393
|
+
// while every newer item waited at retry 0 (2026-11-09: 43,492 never attempted).
|
|
3394
|
+
"SELECT id, payload, retry_count FROM pending_sync ORDER BY retry_count ASC, created_at ASC LIMIT ?"
|
|
3360
3395
|
).all(limit);
|
|
3361
3396
|
}
|
|
3362
3397
|
function removePendingSync(db, id) {
|
|
@@ -4029,7 +4064,7 @@ function scoreFailureClasses(db, matchText, filePath, promptContext, weights) {
|
|
|
4029
4064
|
}
|
|
4030
4065
|
return bestMatch;
|
|
4031
4066
|
}
|
|
4032
|
-
var MEMORY_SCHEMA_VERSION, TOOL_COST_EVENTS_RETENTION_DAYS, MAX_DRAFT_TEXT_LEN, FUNNEL_EVENT_OUTBOX_CAP, _temporalColCache, CONSOLIDATION_LESSON_EVIDENCE, MEMORY_FILE_TITLE_PREFIX, MEMORY_FILE_TITLE_LIKE, USAGE_COUNTER_ARMED_KEY;
|
|
4067
|
+
var MEMORY_SCHEMA_VERSION, TOOL_COST_EVENTS_RETENTION_DAYS, PENDING_SYNC_MAX_ITEMS, PENDING_SYNC_MAX_AGE_DAYS, MAX_DRAFT_TEXT_LEN, FUNNEL_EVENT_OUTBOX_CAP, _temporalColCache, CONSOLIDATION_LESSON_EVIDENCE, MEMORY_FILE_TITLE_PREFIX, MEMORY_FILE_TITLE_LIKE, USAGE_COUNTER_ARMED_KEY;
|
|
4033
4068
|
var init_memory_db = __esm({
|
|
4034
4069
|
"src/memory-db.ts"() {
|
|
4035
4070
|
"use strict";
|
|
@@ -4044,6 +4079,8 @@ var init_memory_db = __esm({
|
|
|
4044
4079
|
init_memory_embed_sweep();
|
|
4045
4080
|
MEMORY_SCHEMA_VERSION = 2;
|
|
4046
4081
|
TOOL_COST_EVENTS_RETENTION_DAYS = 90;
|
|
4082
|
+
PENDING_SYNC_MAX_ITEMS = 500;
|
|
4083
|
+
PENDING_SYNC_MAX_AGE_DAYS = 30;
|
|
4047
4084
|
MAX_DRAFT_TEXT_LEN = 16384;
|
|
4048
4085
|
FUNNEL_EVENT_OUTBOX_CAP = 2e4;
|
|
4049
4086
|
_temporalColCache = /* @__PURE__ */ new WeakMap();
|
|
@@ -4054,7 +4091,244 @@ var init_memory_db = __esm({
|
|
|
4054
4091
|
}
|
|
4055
4092
|
});
|
|
4056
4093
|
|
|
4057
|
-
// src/hooks/
|
|
4094
|
+
// src/hooks/lib/security-gate-checks.ts
|
|
4095
|
+
var DANGEROUS_BASH_PATTERNS = [
|
|
4096
|
+
// BLOCK — destructive and irreversible, or arbitrary remote code execution.
|
|
4097
|
+
{ pattern: /rm\s+-[a-z]*r[a-z]*f[a-z]*\s+\/(?:\s|$)/, label: "rm -rf /", severity: "block" },
|
|
4098
|
+
{ pattern: /rm\s+-[a-z]*f[a-z]*r[a-z]*\s+\/(?:\s|$)/, label: "rm -rf /", severity: "block" },
|
|
4099
|
+
{ pattern: /curl\s+.*\|\s*(?:bash|sh|zsh)/, label: "curl | bash (remote code execution)", severity: "block" },
|
|
4100
|
+
{ pattern: /wget\s+.*\|\s*(?:bash|sh|zsh)/, label: "wget | bash (remote code execution)", severity: "block" },
|
|
4101
|
+
{ pattern: />\s*\/etc\/passwd/, label: "write to /etc/passwd", severity: "block" },
|
|
4102
|
+
{ pattern: />\s*\/etc\/shadow/, label: "write to /etc/shadow", severity: "block" },
|
|
4103
|
+
{ pattern: />\s*\/etc\/sudoers/, label: "write to /etc/sudoers", severity: "block" },
|
|
4104
|
+
{ pattern: /dd\s+if=.*of=\/dev\/(?:sda|sdb|hda|hdb|nvme)/, label: "dd to raw device", severity: "block" },
|
|
4105
|
+
{ pattern: /mkfs\s+\/dev\//, label: "format disk device", severity: "block" },
|
|
4106
|
+
{ pattern: /:\(\)\s*\{\s*:\|:\s*&\s*\}/, label: "fork bomb", severity: "block" },
|
|
4107
|
+
{ pattern: /eval\s+.*\$\(.*curl/, label: "eval with remote curl", severity: "block" },
|
|
4108
|
+
{ pattern: /base64\s+-d\s+.*\|\s*(?:bash|sh|zsh)/, label: "base64 decoded shell exec", severity: "block" },
|
|
4109
|
+
// WARN — bad practice, but has legitimate uses (fixing a broken permission bit,
|
|
4110
|
+
// a throwaway container). Blocking these would make the product hostile.
|
|
4111
|
+
{ pattern: /chmod\s+777/, label: "chmod 777 (world-writable permissions)", severity: "warn" },
|
|
4112
|
+
{ pattern: /chmod\s+-R\s+777/, label: "chmod -R 777 (world-writable permissions)", severity: "warn" }
|
|
4113
|
+
];
|
|
4114
|
+
var PROTECTED_FILE_PATTERNS = [
|
|
4115
|
+
{ pattern: /\.env$/, label: ".env file" },
|
|
4116
|
+
{ pattern: /\.env\./, label: ".env.* file" },
|
|
4117
|
+
{ pattern: /credentials(?:\.json)?$/, label: "credentials file" },
|
|
4118
|
+
{ pattern: /\.pem$/, label: ".pem certificate/key file" },
|
|
4119
|
+
{ pattern: /\.key$/, label: ".key file" },
|
|
4120
|
+
{ pattern: /\.p12$/, label: ".p12 keystore file" },
|
|
4121
|
+
{ pattern: /\.pfx$/, label: ".pfx keystore file" },
|
|
4122
|
+
{ pattern: /id_rsa$/, label: "RSA private key" },
|
|
4123
|
+
{ pattern: /id_ed25519$/, label: "Ed25519 private key" },
|
|
4124
|
+
{ pattern: /id_ecdsa$/, label: "ECDSA private key" },
|
|
4125
|
+
{ pattern: /\.ssh\/config$/, label: "SSH config file" },
|
|
4126
|
+
{ pattern: /secrets\.yaml$/, label: "secrets.yaml file" },
|
|
4127
|
+
{ pattern: /secrets\.yml$/, label: "secrets.yml file" },
|
|
4128
|
+
{ pattern: /\.netrc$/, label: ".netrc credentials file" },
|
|
4129
|
+
{ pattern: /aws\/credentials$/, label: "AWS credentials file" },
|
|
4130
|
+
{ pattern: /kubeconfig$/, label: "Kubernetes config file" }
|
|
4131
|
+
];
|
|
4132
|
+
var SECRET_BASENAME_EXTENSIONS = ["pem", "key", "p12", "pfx", "secret"];
|
|
4133
|
+
var DOTENV_TEMPLATE_SUFFIXES = ["example", "template", "sample", "defaults", "dist"];
|
|
4134
|
+
var SECRET_EXACT_BASENAMES = [
|
|
4135
|
+
"id_rsa",
|
|
4136
|
+
"id_ed25519",
|
|
4137
|
+
"id_ecdsa",
|
|
4138
|
+
".netrc",
|
|
4139
|
+
"kubeconfig",
|
|
4140
|
+
"credentials.json",
|
|
4141
|
+
"secrets.yaml",
|
|
4142
|
+
"secrets.yml"
|
|
4143
|
+
];
|
|
4144
|
+
var CREDENTIALS_PATH_SUFFIX = "/credentials";
|
|
4145
|
+
var DOTENV = ".env";
|
|
4146
|
+
var SECRET_DATA_EXTENSIONS = ["yaml", "yml", "json", "toml", "ini", "env", "conf", "properties", "txt"];
|
|
4147
|
+
function looksLikeCode(token) {
|
|
4148
|
+
return /[()[\]{};=`$<>|&*?!"']/.test(token) || /\s/.test(token);
|
|
4149
|
+
}
|
|
4150
|
+
function namesSecretFile(token) {
|
|
4151
|
+
if (!token || looksLikeCode(token)) return null;
|
|
4152
|
+
const clean = token.replace(/[,:.]+$/, "");
|
|
4153
|
+
if (!clean) return null;
|
|
4154
|
+
const base = clean.split("/").pop() ?? "";
|
|
4155
|
+
if (!base) return null;
|
|
4156
|
+
if (SECRET_EXACT_BASENAMES.includes(base)) return base;
|
|
4157
|
+
if (clean.endsWith(".ssh/config")) return "SSH config file";
|
|
4158
|
+
if (clean.endsWith(CREDENTIALS_PATH_SUFFIX)) return "credentials file";
|
|
4159
|
+
if (base === DOTENV) return `${DOTENV} file`;
|
|
4160
|
+
if (base.startsWith(`${DOTENV}.`)) {
|
|
4161
|
+
const suffix = base.slice(DOTENV.length + 1);
|
|
4162
|
+
return DOTENV_TEMPLATE_SUFFIXES.includes(suffix) ? null : `${base} file`;
|
|
4163
|
+
}
|
|
4164
|
+
if (base.endsWith(DOTENV) && clean.includes("/")) return `${base} file`;
|
|
4165
|
+
for (const ext2 of SECRET_BASENAME_EXTENSIONS) {
|
|
4166
|
+
if (base.endsWith(`.${ext2}`)) return `${base} file`;
|
|
4167
|
+
}
|
|
4168
|
+
const parts = base.toLowerCase().split(".");
|
|
4169
|
+
const ext = parts.length > 1 ? parts[parts.length - 1] : "";
|
|
4170
|
+
if (SECRET_DATA_EXTENSIONS.includes(ext) && parts.slice(0, -1).some((p) => p === "secret" || p === "secrets")) {
|
|
4171
|
+
return `${base} file`;
|
|
4172
|
+
}
|
|
4173
|
+
return null;
|
|
4174
|
+
}
|
|
4175
|
+
function shellTokens(command) {
|
|
4176
|
+
const out = [];
|
|
4177
|
+
let cur = "";
|
|
4178
|
+
let quote = null;
|
|
4179
|
+
for (const ch of command) {
|
|
4180
|
+
if (quote) {
|
|
4181
|
+
if (ch === quote) quote = null;
|
|
4182
|
+
else cur += ch;
|
|
4183
|
+
continue;
|
|
4184
|
+
}
|
|
4185
|
+
if (ch === "'" || ch === '"') {
|
|
4186
|
+
quote = ch;
|
|
4187
|
+
cur += ch;
|
|
4188
|
+
continue;
|
|
4189
|
+
}
|
|
4190
|
+
if (/\s/.test(ch)) {
|
|
4191
|
+
if (cur) out.push(cur);
|
|
4192
|
+
cur = "";
|
|
4193
|
+
continue;
|
|
4194
|
+
}
|
|
4195
|
+
cur += ch;
|
|
4196
|
+
}
|
|
4197
|
+
if (cur) out.push(cur);
|
|
4198
|
+
return out;
|
|
4199
|
+
}
|
|
4200
|
+
function secretFilesInCommand(command) {
|
|
4201
|
+
const hits = [];
|
|
4202
|
+
for (const token of shellTokens(command)) {
|
|
4203
|
+
const label = namesSecretFile(token);
|
|
4204
|
+
if (label) hits.push(label);
|
|
4205
|
+
}
|
|
4206
|
+
return hits;
|
|
4207
|
+
}
|
|
4208
|
+
function checkBashCommand(command) {
|
|
4209
|
+
for (const { pattern, label, severity } of DANGEROUS_BASH_PATTERNS) {
|
|
4210
|
+
if (pattern.test(command)) {
|
|
4211
|
+
return { label, severity };
|
|
4212
|
+
}
|
|
4213
|
+
}
|
|
4214
|
+
return null;
|
|
4215
|
+
}
|
|
4216
|
+
function checkFilePath(filePath) {
|
|
4217
|
+
for (const { pattern, label } of PROTECTED_FILE_PATTERNS) {
|
|
4218
|
+
if (pattern.test(filePath)) {
|
|
4219
|
+
return label;
|
|
4220
|
+
}
|
|
4221
|
+
}
|
|
4222
|
+
return null;
|
|
4223
|
+
}
|
|
4224
|
+
var DANGEROUS_PYTHON_PATTERNS = [
|
|
4225
|
+
{ pattern: /\beval\s*\(/, label: "Python eval() \u2014 arbitrary code execution", scope: "code" },
|
|
4226
|
+
{ pattern: /\bexec\s*\(/, label: "Python exec() \u2014 arbitrary code execution", scope: "code" },
|
|
4227
|
+
{ pattern: /\b__import__\s*\(/, label: "Python __import__() \u2014 dynamic import (potential code injection)", scope: "code" },
|
|
4228
|
+
{ pattern: /subprocess\.call\([^)]*shell\s*=\s*True/, label: "subprocess.call(shell=True) \u2014 shell injection risk", scope: "code" },
|
|
4229
|
+
{ pattern: /subprocess\.Popen\([^)]*shell\s*=\s*True/, label: "subprocess.Popen(shell=True) \u2014 shell injection risk", scope: "code" },
|
|
4230
|
+
{ pattern: /os\.system\s*\(/, label: "os.system() \u2014 shell injection risk", scope: "code" },
|
|
4231
|
+
{ pattern: /\bf['"].*\{.*\}.*['"].*(?:execute|cursor|query)/, label: "f-string in SQL \u2014 SQL injection risk", scope: "raw" },
|
|
4232
|
+
{ pattern: /['"].*%s.*['"].*%.*(?:execute|cursor|query)/, label: "String formatting in SQL \u2014 SQL injection risk", scope: "raw" }
|
|
4233
|
+
];
|
|
4234
|
+
function stripPythonCommentsAndStringBodies(src) {
|
|
4235
|
+
let out = "";
|
|
4236
|
+
let i = 0;
|
|
4237
|
+
while (i < src.length) {
|
|
4238
|
+
const ch = src[i];
|
|
4239
|
+
if (ch === "#") {
|
|
4240
|
+
while (i < src.length && src[i] !== "\n") i++;
|
|
4241
|
+
continue;
|
|
4242
|
+
}
|
|
4243
|
+
if (ch === '"' || ch === "'") {
|
|
4244
|
+
const triple = src.startsWith(ch.repeat(3), i);
|
|
4245
|
+
const delim = triple ? ch.repeat(3) : ch;
|
|
4246
|
+
out += delim;
|
|
4247
|
+
i += delim.length;
|
|
4248
|
+
let closed = false;
|
|
4249
|
+
while (i < src.length) {
|
|
4250
|
+
if (src[i] === "\\") {
|
|
4251
|
+
i += 2;
|
|
4252
|
+
continue;
|
|
4253
|
+
}
|
|
4254
|
+
if (src.startsWith(delim, i)) {
|
|
4255
|
+
out += delim;
|
|
4256
|
+
i += delim.length;
|
|
4257
|
+
closed = true;
|
|
4258
|
+
break;
|
|
4259
|
+
}
|
|
4260
|
+
if (!triple && src[i] === "\n") break;
|
|
4261
|
+
i++;
|
|
4262
|
+
}
|
|
4263
|
+
if (!closed) return null;
|
|
4264
|
+
continue;
|
|
4265
|
+
}
|
|
4266
|
+
out += ch;
|
|
4267
|
+
i++;
|
|
4268
|
+
}
|
|
4269
|
+
return out;
|
|
4270
|
+
}
|
|
4271
|
+
function checkPythonContent(content) {
|
|
4272
|
+
const stripped = stripPythonCommentsAndStringBodies(content);
|
|
4273
|
+
for (const { pattern, label, scope } of DANGEROUS_PYTHON_PATTERNS) {
|
|
4274
|
+
const subject = scope === "raw" || stripped === null ? content : stripped;
|
|
4275
|
+
if (pattern.test(subject)) {
|
|
4276
|
+
return label;
|
|
4277
|
+
}
|
|
4278
|
+
}
|
|
4279
|
+
return null;
|
|
4280
|
+
}
|
|
4281
|
+
function runSecurityGateFindings(hookInput) {
|
|
4282
|
+
const findings = [];
|
|
4283
|
+
const { tool_name, tool_input } = hookInput;
|
|
4284
|
+
if (tool_name === "Bash" && tool_input.command) {
|
|
4285
|
+
const secretFiles = secretFilesInCommand(tool_input.command);
|
|
4286
|
+
if (secretFiles.length > 0) {
|
|
4287
|
+
findings.push({
|
|
4288
|
+
severity: "block",
|
|
4289
|
+
message: `SECURITY GATE: command names a secret file: ${secretFiles.join(", ")}
|
|
4290
|
+
Command: ${tool_input.command.slice(0, 200)}
|
|
4291
|
+
BLOCKED. If this file is a template or fixture, name it with a template suffix (.example/.template/.sample) rather than weakening this check.`
|
|
4292
|
+
});
|
|
4293
|
+
}
|
|
4294
|
+
const violation = checkBashCommand(tool_input.command);
|
|
4295
|
+
if (violation) {
|
|
4296
|
+
findings.push({
|
|
4297
|
+
severity: violation.severity,
|
|
4298
|
+
message: `SECURITY GATE: Dangerous command pattern detected: ${violation.label}
|
|
4299
|
+
Command: ${tool_input.command.slice(0, 200)}` + (violation.severity === "block" ? `
|
|
4300
|
+
BLOCKED. This pattern has no legitimate use in an agent session.` : `
|
|
4301
|
+
Review carefully before proceeding.`)
|
|
4302
|
+
});
|
|
4303
|
+
}
|
|
4304
|
+
}
|
|
4305
|
+
if ((tool_name === "Write" || tool_name === "Edit") && tool_input.file_path) {
|
|
4306
|
+
const violation = checkFilePath(tool_input.file_path);
|
|
4307
|
+
if (violation) {
|
|
4308
|
+
findings.push({
|
|
4309
|
+
severity: "warn",
|
|
4310
|
+
message: `SECURITY GATE: Attempt to write to protected file: ${violation}
|
|
4311
|
+
Path: ${tool_input.file_path}
|
|
4312
|
+
Ensure this is intentional and no secrets will be exposed.`
|
|
4313
|
+
});
|
|
4314
|
+
}
|
|
4315
|
+
}
|
|
4316
|
+
const pyContent = tool_input.content || tool_input.new_string;
|
|
4317
|
+
if ((tool_name === "Write" || tool_name === "Edit") && tool_input.file_path?.endsWith(".py") && pyContent) {
|
|
4318
|
+
const pyViolation = checkPythonContent(pyContent);
|
|
4319
|
+
if (pyViolation) {
|
|
4320
|
+
findings.push({
|
|
4321
|
+
severity: "warn",
|
|
4322
|
+
message: `SECURITY GATE: Dangerous Python pattern detected: ${pyViolation}
|
|
4323
|
+
File: ${tool_input.file_path}
|
|
4324
|
+
Review carefully before proceeding.`
|
|
4325
|
+
});
|
|
4326
|
+
}
|
|
4327
|
+
}
|
|
4328
|
+
return findings;
|
|
4329
|
+
}
|
|
4330
|
+
|
|
4331
|
+
// src/hooks/lib/pre-delete-checks.ts
|
|
4058
4332
|
init_db_driver();
|
|
4059
4333
|
import { existsSync as existsSync9 } from "fs";
|
|
4060
4334
|
|
|
@@ -4150,7 +4424,7 @@ function getFeatureImpact(db, filePaths) {
|
|
|
4150
4424
|
};
|
|
4151
4425
|
}
|
|
4152
4426
|
|
|
4153
|
-
// src/hooks/pre-delete-
|
|
4427
|
+
// src/hooks/lib/pre-delete-checks.ts
|
|
4154
4428
|
init_config();
|
|
4155
4429
|
|
|
4156
4430
|
// src/lib/source-layout.ts
|
|
@@ -4200,58 +4474,7 @@ function isUnderSourceDir(path, layout = getSourceLayout()) {
|
|
|
4200
4474
|
return layout.sourceDirs.some((d) => path.startsWith(`${d}/`));
|
|
4201
4475
|
}
|
|
4202
4476
|
|
|
4203
|
-
// src/hooks/lib/
|
|
4204
|
-
var HOOK_EVENTS = [
|
|
4205
|
-
"UserPromptSubmit",
|
|
4206
|
-
"UserPromptExpansion",
|
|
4207
|
-
"SessionStart",
|
|
4208
|
-
"PreToolUse",
|
|
4209
|
-
"PostToolUse",
|
|
4210
|
-
"Stop",
|
|
4211
|
-
"SubagentStop",
|
|
4212
|
-
"PreCompact"
|
|
4213
|
-
];
|
|
4214
|
-
var ADDITIONAL_CONTEXT_MAX_CHARS = 1e4;
|
|
4215
|
-
function isHookEvent(value) {
|
|
4216
|
-
return typeof value === "string" && HOOK_EVENTS.includes(value);
|
|
4217
|
-
}
|
|
4218
|
-
function writeHookContext(hookEventName, message) {
|
|
4219
|
-
if (!isHookEvent(hookEventName)) {
|
|
4220
|
-
process.stderr.write(
|
|
4221
|
-
`[massu] writeHookContext: refusing to emit \u2014 unrecognised hook event ${JSON.stringify(hookEventName)}. Known: ${HOOK_EVENTS.join(", ")}.
|
|
4222
|
-
`
|
|
4223
|
-
);
|
|
4224
|
-
return;
|
|
4225
|
-
}
|
|
4226
|
-
if (typeof message !== "string" || message.length === 0) return;
|
|
4227
|
-
let additionalContext = message;
|
|
4228
|
-
if (additionalContext.length > ADDITIONAL_CONTEXT_MAX_CHARS) {
|
|
4229
|
-
const marker = `
|
|
4230
|
-
[massu] \u2026truncated at ${ADDITIONAL_CONTEXT_MAX_CHARS} chars (spec cap).`;
|
|
4231
|
-
additionalContext = additionalContext.slice(0, ADDITIONAL_CONTEXT_MAX_CHARS - marker.length) + marker;
|
|
4232
|
-
}
|
|
4233
|
-
process.stdout.write(
|
|
4234
|
-
JSON.stringify({ hookSpecificOutput: { hookEventName, additionalContext } }) + "\n"
|
|
4235
|
-
);
|
|
4236
|
-
}
|
|
4237
|
-
|
|
4238
|
-
// src/hooks/pre-delete-check.ts
|
|
4239
|
-
init_hook_failure_signal();
|
|
4240
|
-
|
|
4241
|
-
// src/hooks/lib/is-direct-invocation.ts
|
|
4242
|
-
import { pathToFileURL } from "url";
|
|
4243
|
-
function isDirectInvocation(moduleUrl) {
|
|
4244
|
-
const entry = process.argv[1];
|
|
4245
|
-
if (!entry) return false;
|
|
4246
|
-
try {
|
|
4247
|
-
return moduleUrl === pathToFileURL(entry).href;
|
|
4248
|
-
} catch {
|
|
4249
|
-
return false;
|
|
4250
|
-
}
|
|
4251
|
-
}
|
|
4252
|
-
|
|
4253
|
-
// src/hooks/pre-delete-check.ts
|
|
4254
|
-
var HOOK_EVENT = "PreToolUse";
|
|
4477
|
+
// src/hooks/lib/pre-delete-checks.ts
|
|
4255
4478
|
var PROJECT_ROOT2 = getProjectRoot();
|
|
4256
4479
|
var KNOWLEDGE_PROTECTED_FILES = [
|
|
4257
4480
|
"knowledge-db.ts",
|
|
@@ -4390,30 +4613,182 @@ function runPreDeleteChecks(hookInput) {
|
|
|
4390
4613
|
}
|
|
4391
4614
|
return messages;
|
|
4392
4615
|
}
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
|
|
4396
|
-
|
|
4397
|
-
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4616
|
+
|
|
4617
|
+
// src/hooks/lib/write-hook-message.ts
|
|
4618
|
+
var HOOK_EVENTS = [
|
|
4619
|
+
"UserPromptSubmit",
|
|
4620
|
+
"UserPromptExpansion",
|
|
4621
|
+
"SessionStart",
|
|
4622
|
+
"PreToolUse",
|
|
4623
|
+
"PostToolUse",
|
|
4624
|
+
"Stop",
|
|
4625
|
+
"SubagentStop",
|
|
4626
|
+
"PreCompact"
|
|
4627
|
+
];
|
|
4628
|
+
var ADDITIONAL_CONTEXT_MAX_CHARS = 1e4;
|
|
4629
|
+
function isHookEvent(value) {
|
|
4630
|
+
return typeof value === "string" && HOOK_EVENTS.includes(value);
|
|
4631
|
+
}
|
|
4632
|
+
function writeHookContext(hookEventName, message) {
|
|
4633
|
+
if (!isHookEvent(hookEventName)) {
|
|
4634
|
+
process.stderr.write(
|
|
4635
|
+
`[massu] writeHookContext: refusing to emit \u2014 unrecognised hook event ${JSON.stringify(hookEventName)}. Known: ${HOOK_EVENTS.join(", ")}.
|
|
4636
|
+
`
|
|
4637
|
+
);
|
|
4638
|
+
return;
|
|
4639
|
+
}
|
|
4640
|
+
if (typeof message !== "string" || message.length === 0) return;
|
|
4641
|
+
let additionalContext = message;
|
|
4642
|
+
if (additionalContext.length > ADDITIONAL_CONTEXT_MAX_CHARS) {
|
|
4643
|
+
const marker = `
|
|
4644
|
+
[massu] \u2026truncated at ${ADDITIONAL_CONTEXT_MAX_CHARS} chars (spec cap).`;
|
|
4645
|
+
additionalContext = additionalContext.slice(0, ADDITIONAL_CONTEXT_MAX_CHARS - marker.length) + marker;
|
|
4403
4646
|
}
|
|
4404
|
-
process.
|
|
4647
|
+
process.stdout.write(
|
|
4648
|
+
JSON.stringify({ hookSpecificOutput: { hookEventName, additionalContext } }) + "\n"
|
|
4649
|
+
);
|
|
4405
4650
|
}
|
|
4406
|
-
|
|
4407
|
-
|
|
4651
|
+
|
|
4652
|
+
// src/hooks/lib/read-stdin.ts
|
|
4653
|
+
var StdinTimeoutError = class extends Error {
|
|
4654
|
+
bytesRead;
|
|
4655
|
+
constructor(ms, bytesRead) {
|
|
4656
|
+
super(
|
|
4657
|
+
`stdin did not reach EOF within ${ms}ms (${bytesRead} bytes read). The payload is INCOMPLETE and must not be treated as valid input.`
|
|
4658
|
+
);
|
|
4659
|
+
this.name = "StdinTimeoutError";
|
|
4660
|
+
this.bytesRead = bytesRead;
|
|
4661
|
+
}
|
|
4662
|
+
};
|
|
4663
|
+
function readStdinToEof(deadlineMs = 4e3) {
|
|
4664
|
+
return new Promise((resolve5, reject) => {
|
|
4408
4665
|
let data = "";
|
|
4666
|
+
let settled = false;
|
|
4667
|
+
const timer = setTimeout(() => {
|
|
4668
|
+
if (settled) return;
|
|
4669
|
+
settled = true;
|
|
4670
|
+
reject(new StdinTimeoutError(deadlineMs, data.length));
|
|
4671
|
+
}, deadlineMs);
|
|
4672
|
+
const finish = (fn) => {
|
|
4673
|
+
if (settled) return;
|
|
4674
|
+
settled = true;
|
|
4675
|
+
clearTimeout(timer);
|
|
4676
|
+
fn();
|
|
4677
|
+
};
|
|
4409
4678
|
process.stdin.setEncoding("utf-8");
|
|
4410
4679
|
process.stdin.on("data", (chunk) => {
|
|
4411
4680
|
data += chunk;
|
|
4412
4681
|
});
|
|
4413
|
-
process.stdin.on("end", () => resolve5(data));
|
|
4414
|
-
|
|
4682
|
+
process.stdin.on("end", () => finish(() => resolve5(data)));
|
|
4683
|
+
process.stdin.on("error", (err) => finish(() => reject(err)));
|
|
4415
4684
|
});
|
|
4416
4685
|
}
|
|
4686
|
+
|
|
4687
|
+
// src/hooks/lib/pre-tool-use-decision.ts
|
|
4688
|
+
init_hook_failure_signal();
|
|
4689
|
+
var EXIT_DENY = 2;
|
|
4690
|
+
var EXIT_ALLOW = 0;
|
|
4691
|
+
function deny(reason, detail) {
|
|
4692
|
+
process.stderr.write(
|
|
4693
|
+
`MASSU SECURITY GATE \u2014 BLOCKED
|
|
4694
|
+
|
|
4695
|
+
${reason}
|
|
4696
|
+
` + (detail ? `
|
|
4697
|
+
${detail}
|
|
4698
|
+
` : "") + `
|
|
4699
|
+
The gate denies by default when it cannot verify a command is safe.
|
|
4700
|
+
`
|
|
4701
|
+
);
|
|
4702
|
+
process.exit(EXIT_DENY);
|
|
4703
|
+
}
|
|
4704
|
+
async function runPreToolUseDecision(hookName, event) {
|
|
4705
|
+
let hookInput;
|
|
4706
|
+
let input = "";
|
|
4707
|
+
try {
|
|
4708
|
+
input = await readStdinToEof();
|
|
4709
|
+
if (!input.trim()) {
|
|
4710
|
+
recordHookFailure(hookName, new Error("empty stdin payload"));
|
|
4711
|
+
deny("The gate received an EMPTY payload and could not determine what tool call was requested.");
|
|
4712
|
+
}
|
|
4713
|
+
hookInput = JSON.parse(input);
|
|
4714
|
+
} catch (err) {
|
|
4715
|
+
recordHookFailure(hookName, err, {
|
|
4716
|
+
phase: "read-parse",
|
|
4717
|
+
bytesRead: input.length,
|
|
4718
|
+
endsWithBrace: input.trimEnd().endsWith("}")
|
|
4719
|
+
});
|
|
4720
|
+
deny(
|
|
4721
|
+
"The gate could not READ or PARSE the tool-call payload, so it cannot know what it was being asked to approve.",
|
|
4722
|
+
err instanceof Error ? `Cause: ${err.message}` : void 0
|
|
4723
|
+
);
|
|
4724
|
+
}
|
|
4725
|
+
let findings;
|
|
4726
|
+
try {
|
|
4727
|
+
const security = runSecurityGateFindings(hookInput);
|
|
4728
|
+
const deletes = runPreDeleteChecks(hookInput).map(
|
|
4729
|
+
(message) => ({ severity: "warn", message })
|
|
4730
|
+
);
|
|
4731
|
+
findings = [...security, ...deletes];
|
|
4732
|
+
} catch (err) {
|
|
4733
|
+
recordHookFailure(hookName, err, { phase: "evaluate" });
|
|
4734
|
+
deny(
|
|
4735
|
+
"A security check CRASHED while evaluating this tool call. A check that did not run has not passed.",
|
|
4736
|
+
err instanceof Error ? `Cause: ${err.message}` : void 0
|
|
4737
|
+
);
|
|
4738
|
+
}
|
|
4739
|
+
for (const f of findings.filter((x) => x.severity === "warn")) {
|
|
4740
|
+
writeHookContext(event, f.message);
|
|
4741
|
+
}
|
|
4742
|
+
const blocking = findings.filter((f) => f.severity === "block");
|
|
4743
|
+
if (blocking.length > 0) deny(blocking.map((f) => f.message).join("\n\n"));
|
|
4744
|
+
process.exit(EXIT_ALLOW);
|
|
4745
|
+
}
|
|
4746
|
+
|
|
4747
|
+
// src/lib/safe-write.ts
|
|
4748
|
+
import {
|
|
4749
|
+
writeFileSync as writeFileSync3,
|
|
4750
|
+
renameSync,
|
|
4751
|
+
openSync as openSync2,
|
|
4752
|
+
fsyncSync as fsyncSync2,
|
|
4753
|
+
closeSync as closeSync2,
|
|
4754
|
+
existsSync as existsSync10,
|
|
4755
|
+
realpathSync,
|
|
4756
|
+
unlinkSync as unlinkSync2,
|
|
4757
|
+
accessSync as accessSync2,
|
|
4758
|
+
statSync as statSync2,
|
|
4759
|
+
chmodSync as chmodSync3,
|
|
4760
|
+
constants
|
|
4761
|
+
} from "fs";
|
|
4762
|
+
function isSameRealPath(a, b) {
|
|
4763
|
+
try {
|
|
4764
|
+
return realpathSync(a) === realpathSync(b);
|
|
4765
|
+
} catch {
|
|
4766
|
+
return false;
|
|
4767
|
+
}
|
|
4768
|
+
}
|
|
4769
|
+
|
|
4770
|
+
// src/hooks/lib/is-direct-invocation.ts
|
|
4771
|
+
import { fileURLToPath as fileURLToPath2, pathToFileURL } from "url";
|
|
4772
|
+
function isDirectInvocation(moduleUrl) {
|
|
4773
|
+
const entry = process.argv[1];
|
|
4774
|
+
if (!entry) return false;
|
|
4775
|
+
try {
|
|
4776
|
+
if (moduleUrl === pathToFileURL(entry).href) return true;
|
|
4777
|
+
} catch {
|
|
4778
|
+
return false;
|
|
4779
|
+
}
|
|
4780
|
+
try {
|
|
4781
|
+
return isSameRealPath(fileURLToPath2(moduleUrl), entry);
|
|
4782
|
+
} catch {
|
|
4783
|
+
return false;
|
|
4784
|
+
}
|
|
4785
|
+
}
|
|
4786
|
+
|
|
4787
|
+
// src/hooks/pre-delete-check.ts
|
|
4788
|
+
var HOOK_EVENT = "PreToolUse";
|
|
4789
|
+
async function main() {
|
|
4790
|
+
await runPreToolUseDecision("pre-delete-check", HOOK_EVENT);
|
|
4791
|
+
}
|
|
4417
4792
|
if (isDirectInvocation(import.meta.url)) {
|
|
4418
4793
|
main();
|
|
4419
4794
|
}
|