@massu/core 2.5.1 → 2.5.3

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.
Files changed (92) hide show
  1. package/CHANGELOG.md +240 -0
  2. package/agents/massu-output-scorer.md +2 -2
  3. package/agents/massu-plan-auditor.md +1 -1
  4. package/commands/_shared-references/test-first-protocol.md +4 -4
  5. package/commands/_shared-references/verification-table.md +5 -5
  6. package/commands/massu-audit-deps.md +2 -2
  7. package/commands/massu-batch.md +3 -3
  8. package/commands/massu-checkpoint.md +7 -7
  9. package/commands/massu-ci-fix.md +1 -1
  10. package/commands/massu-cleanup.md +16 -16
  11. package/commands/massu-commit.md +7 -7
  12. package/commands/massu-create-plan.md +7 -7
  13. package/commands/massu-dead-code.md +3 -3
  14. package/commands/massu-debug/references/common-shortcuts.md +1 -1
  15. package/commands/massu-debug/references/investigation-phases.md +4 -4
  16. package/commands/massu-debug/references/report-format.md +2 -2
  17. package/commands/massu-deploy.md +1 -1
  18. package/commands/massu-deps.md +5 -5
  19. package/commands/massu-doc-gen.md +3 -3
  20. package/commands/massu-docs.md +6 -6
  21. package/commands/massu-estimate.md +3 -3
  22. package/commands/massu-golden-path/references/competitive-mode.md +2 -2
  23. package/commands/massu-golden-path/references/phase-1-plan-creation.md +1 -1
  24. package/commands/massu-golden-path/references/phase-2-implementation.md +2 -2
  25. package/commands/massu-golden-path/references/phase-4-commit.md +3 -3
  26. package/commands/massu-golden-path/references/phase-5-push.md +5 -5
  27. package/commands/massu-guide.md +3 -3
  28. package/commands/massu-hotfix.md +14 -14
  29. package/commands/massu-loop/references/checkpoint-audit.md +3 -3
  30. package/commands/massu-loop/references/iteration-structure.md +3 -3
  31. package/commands/massu-loop/references/loop-controller.md +5 -5
  32. package/commands/massu-loop/references/plan-extraction.md +4 -4
  33. package/commands/massu-loop/references/vr-plan-spec.md +3 -3
  34. package/commands/massu-loop-playwright.md +5 -5
  35. package/commands/massu-loop.md +1 -1
  36. package/commands/massu-new-feature.md +6 -24
  37. package/commands/massu-perf.md +1 -1
  38. package/commands/massu-plan.md +21 -21
  39. package/commands/massu-push-light.md +9 -9
  40. package/commands/massu-recap.md +1 -1
  41. package/commands/massu-refactor.md +8 -8
  42. package/commands/massu-release.md +3 -3
  43. package/commands/massu-review.md +1 -1
  44. package/commands/massu-rollback.md +6 -6
  45. package/commands/massu-scaffold-page.md +1 -1
  46. package/commands/massu-scaffold-router.md +1 -1
  47. package/commands/massu-status.md +9 -9
  48. package/commands/massu-tdd.md +2 -2
  49. package/commands/massu-test.md +13 -13
  50. package/commands/massu-verify-playwright.md +1 -1
  51. package/commands/massu-verify.md +20 -20
  52. package/dist/cli.js +1731 -976
  53. package/dist/hooks/auto-learning-pipeline.js +745 -87
  54. package/dist/hooks/classify-failure.js +747 -45
  55. package/dist/hooks/cost-tracker.js +89 -28
  56. package/dist/hooks/fix-detector.js +536 -48
  57. package/dist/hooks/incident-pipeline.js +96 -29
  58. package/dist/hooks/intent-suggester.js +89 -28
  59. package/dist/hooks/memory-recall.js +89 -28
  60. package/dist/hooks/post-edit-context.js +137 -29
  61. package/dist/hooks/post-tool-use.js +95 -30
  62. package/dist/hooks/pre-compact.js +89 -28
  63. package/dist/hooks/pre-delete-check.js +137 -31
  64. package/dist/hooks/pre-tool-use-gate.js +271 -43
  65. package/dist/hooks/quality-event.js +89 -28
  66. package/dist/hooks/rule-enforcement-pipeline.js +89 -28
  67. package/dist/hooks/security-gate.js +219 -39
  68. package/dist/hooks/session-end.js +92 -28
  69. package/dist/hooks/session-start.js +140 -62
  70. package/dist/hooks/user-prompt.js +89 -28
  71. package/fragments/feature-components.md +15 -0
  72. package/fragments/feature-components.python.md +15 -0
  73. package/fragments/feature-components.typescript.md +15 -0
  74. package/package.json +6 -5
  75. package/patterns/testing-patterns.md +8 -8
  76. package/protocols/plan-implementation.md +2 -2
  77. package/protocols/verification.md +4 -4
  78. package/reference/lessons-learned.md +4 -4
  79. package/reference/vr-verification-reference.md +9 -9
  80. package/templates/aspnet/massu.config.yaml +2 -0
  81. package/templates/go-chi/massu.config.yaml +2 -0
  82. package/templates/multi-runtime/massu.config.yaml +4 -2
  83. package/templates/phoenix/massu.config.yaml +2 -0
  84. package/templates/python-django/massu.config.yaml +3 -1
  85. package/templates/python-fastapi/massu.config.yaml +3 -1
  86. package/templates/python-flask/massu.config.yaml +3 -1
  87. package/templates/rails/massu.config.yaml +4 -2
  88. package/templates/rust-actix/massu.config.yaml +2 -0
  89. package/templates/spring/massu.config.yaml +2 -0
  90. package/templates/swift-ios/massu.config.yaml +3 -1
  91. package/templates/ts-nestjs/massu.config.yaml +2 -0
  92. package/templates/ts-nextjs/massu.config.yaml +2 -0
@@ -574,6 +574,15 @@ var init_memory_path = __esm({
574
574
  }
575
575
  });
576
576
 
577
+ // src/lib/corrections-md.ts
578
+ var CORRECTIONS_FILENAME;
579
+ var init_corrections_md = __esm({
580
+ "src/lib/corrections-md.ts"() {
581
+ "use strict";
582
+ CORRECTIONS_FILENAME = "corrections.md";
583
+ }
584
+ });
585
+
577
586
  // src/config-memory-schema.ts
578
587
  import { z } from "zod";
579
588
  var MemoryConfigSchema;
@@ -607,7 +616,7 @@ var init_config_memory_schema = __esm({
607
616
  message: "memory.recall.embedEndpoint must be an http(s) URL"
608
617
  }).optional(),
609
618
  embedModel: z.string().min(1).optional()
610
- }).default({}),
619
+ }).prefault({}),
611
620
  // --- Contradiction / supersede gate (plan-living-memory-slice-2-temporal-model, P5-001) ---
612
621
  // When a new high-value memory (decision/correction) is written, find
613
622
  // semantically-related existing records and, if the new one contradicts an
@@ -636,7 +645,7 @@ var init_config_memory_schema = __esm({
636
645
  annotateSuperseded: z.boolean().default(false),
637
646
  // Time budget (ms) for the contradiction check; exceeded → fail-open (ADD).
638
647
  budgetMs: z.number().int().positive().default(800)
639
- }).default({}),
648
+ }).prefault({}),
640
649
  // --- Background consolidation, the "sleep-time" pass
641
650
  // (plan-living-memory-slice-3-consolidation) ---
642
651
  // Keeps memory sharp over years: dedupes, distills dying sessions into
@@ -682,7 +691,7 @@ var init_config_memory_schema = __esm({
682
691
  // Surface optional upgrades (e.g. "a local model was detected") in chat.
683
692
  suggestUpgrades: z.boolean().default(true),
684
693
  suggestIntervalDays: z.number().int().positive().default(30)
685
- }).default({}),
694
+ }).prefault({}),
686
695
  // A-20 (Slice 4) — memory FILES: the mirror, and the one switch that decides whether
687
696
  // Massu may ever WRITE into the user's memory directory.
688
697
  //
@@ -707,7 +716,7 @@ var init_config_memory_schema = __esm({
707
716
  // turn of EVERY session, so an unbounded index is a permanent context tax — and the
708
717
  // per-session cap bounds only the RATE, never the total.
709
718
  indexMaxLines: z.number().int().min(1).default(50)
710
- }).default({}),
719
+ }).prefault({}),
711
720
  // --- Cross-repo memory surfacing (plan-living-memory-slice-5-cross-repo-surfacing) ---
712
721
  // A decision made in one of your repos can surface in another — opt-in per
713
722
  // decision AND opt-in per repo, signed, verified, and materialized ONLY on
@@ -732,8 +741,8 @@ var init_config_memory_schema = __esm({
732
741
  enabled: z.boolean().default(true),
733
742
  maxCrossRepoItems: z.number().int().min(0).default(1),
734
743
  minScore: z.number().optional()
735
- }).default({})
736
- }).default({})
744
+ }).prefault({})
745
+ }).prefault({})
737
746
  }).optional();
738
747
  }
739
748
  });
@@ -913,13 +922,19 @@ function getResolvedPaths() {
913
922
  settingsLocalPath: resolve2(root, claudeDirName, "settings.local.json")
914
923
  };
915
924
  }
916
- var DomainConfigSchema, PatternRuleConfigSchema, CostModelSchema, AnalyticsConfigSchema, CustomPatternSchema, GovernanceConfigSchema, SecurityPatternSchema, SecurityConfigSchema, TeamConfigSchema, RegressionConfigSchema, AutoLearningConfigSchema, CloudConfigSchema, ConventionsConfigSchema, PythonDomainConfigSchema, PythonConfigSchema, PathsConfigSchema, LanguageFrameworkEntrySchema, FrameworkConfigSchema, DetectedConfigSchema, VerificationEntrySchema, VerificationConfigSchema, CanonicalPathsSchema, VerificationTypesSchema, DetectionRuleEntrySchema, DetectionConfigSchema, WatchConfigSchema, AdapterLocalPathSchema, AdaptersConfigSchema, TelemetryConfigSchema, LSPConfigSchema, RawConfigSchema, _config, _resolvedApiKeySource, _projectRoot;
925
+ var DEFAULT_KNOWLEDGE_SOURCE_FILES, DomainConfigSchema, PatternRuleConfigSchema, CostModelSchema, AnalyticsConfigSchema, CustomPatternSchema, GovernanceConfigSchema, SecurityPatternSchema, SecurityConfigSchema, TeamConfigSchema, RegressionConfigSchema, AutoLearningConfigSchema, CloudConfigSchema, ConventionsConfigSchema, PythonDomainConfigSchema, PythonConfigSchema, PathsConfigSchema, LanguageFrameworkEntrySchema, FrameworkConfigSchema, DetectedConfigSchema, VerificationEntrySchema, VerificationConfigSchema, CanonicalPathsSchema, VerificationTypesSchema, DetectionRuleEntrySchema, DetectionConfigSchema, WatchConfigSchema, AdapterLocalPathSchema, AdaptersConfigSchema, TelemetryConfigSchema, LSPConfigSchema, RawConfigSchema, _config, _resolvedApiKeySource, _projectRoot;
917
926
  var init_config = __esm({
918
927
  "src/config.ts"() {
919
928
  "use strict";
920
929
  init_memory_path();
930
+ init_corrections_md();
921
931
  init_credentials();
922
932
  init_config_memory_schema();
933
+ DEFAULT_KNOWLEDGE_SOURCE_FILES = [
934
+ "CLAUDE.md",
935
+ "MEMORY.md",
936
+ CORRECTIONS_FILENAME
937
+ ];
923
938
  DomainConfigSchema = z2.object({
924
939
  name: z2.string().default("Unknown"),
925
940
  routers: z2.array(z2.string()).default([]),
@@ -1048,34 +1063,40 @@ var init_config = __esm({
1048
1063
  fixDetection: z2.object({
1049
1064
  enabled: z2.boolean().default(true),
1050
1065
  lookbackDays: z2.number().default(7),
1051
- signals: z2.array(z2.string()).default([
1052
- "removed_broken_code",
1053
- "added_error_handling",
1054
- "method_name_correction",
1055
- "auth_fix",
1056
- "nil_handling_fix",
1057
- "concurrency_fix",
1058
- "async_pattern_fix",
1059
- "added_missing_import"
1060
- ])
1061
- }).default({}),
1066
+ // ⛔ NO DEFAULT LIST HERE, DELIBERATELY — the heuristics are the SSOT.
1067
+ //
1068
+ // This carried a hand-typed copy of all eight signal names while
1069
+ // `fix-detector.ts` already read `config…signals ?? FIX_HEURISTICS.map(h => h.name)`.
1070
+ // Because zod ALWAYS materialised the default, that fallback — the correct expression
1071
+ // — was unreachable, and the duplicate was the only thing in effect. Two authoring
1072
+ // sites for one vocabulary, and the drift is SILENT: a name listed here that no longer
1073
+ // exists simply never matches `enabledSignals.has(...)`, so a retired heuristic lingers
1074
+ // as a dead entry with no error. Retiring `method_name_correction` would have landed at
1075
+ // one site of two (CR-74).
1076
+ //
1077
+ // Optional, so an ABSENT value reaches the `??` and the heuristic list defines itself
1078
+ // (Rule 25). An explicit list in massu.config.yaml still overrides, which is the only
1079
+ // behaviour anyone actually configured — measured: `fix-detector.ts:267` is the sole
1080
+ // consumer and no config file in the repo sets it.
1081
+ signals: z2.array(z2.string()).optional()
1082
+ }).prefault({}),
1062
1083
  failureClassification: z2.object({
1063
1084
  enabled: z2.boolean().default(true),
1064
1085
  thresholds: z2.object({
1065
1086
  known: z2.number().default(5),
1066
1087
  similar: z2.number().default(3)
1067
- }).default({}),
1088
+ }).prefault({}),
1068
1089
  scoring: z2.object({
1069
1090
  diffPatternWeight: z2.number().default(3),
1070
1091
  filePatternWeight: z2.number().default(2),
1071
1092
  promptKeywordWeight: z2.number().default(2)
1072
- }).default({})
1073
- }).default({}),
1093
+ }).prefault({})
1094
+ }).prefault({}),
1074
1095
  pipeline: z2.object({
1075
1096
  requireIncidentReport: z2.boolean().default(true),
1076
1097
  requirePreventionRule: z2.boolean().default(true),
1077
1098
  requireEnforcement: z2.boolean().default(true)
1078
- }).default({}),
1099
+ }).prefault({}),
1079
1100
  // plan-v0.2-interactive-rule-approval P-D-008 / P-D-009: project-configured
1080
1101
  // custom destinations for the rule-candidate funnel. The classifier matches
1081
1102
  // a candidate to one of these entries when none of the framework
@@ -1108,7 +1129,7 @@ var init_config = __esm({
1108
1129
  memory: z2.boolean().default(true),
1109
1130
  analytics: z2.boolean().default(true),
1110
1131
  audit: z2.boolean().default(true)
1111
- }).default({ memory: true, analytics: true, audit: true })
1132
+ }).prefault({ memory: true, analytics: true, audit: true })
1112
1133
  }).optional();
1113
1134
  ConventionsConfigSchema = z2.object({
1114
1135
  claudeDirName: z2.string().default(".claude").refine(
@@ -1139,7 +1160,7 @@ var init_config = __esm({
1139
1160
  "session-state",
1140
1161
  "agents"
1141
1162
  ]),
1142
- knowledgeSourceFiles: z2.array(z2.string()).default(["CLAUDE.md", "MEMORY.md", "corrections.md"]),
1163
+ knowledgeSourceFiles: z2.array(z2.string()).default([...DEFAULT_KNOWLEDGE_SOURCE_FILES]),
1143
1164
  excludePatterns: z2.array(z2.string()).default(["/ARCHIVE/", "/SESSION-HISTORY/"])
1144
1165
  }).optional();
1145
1166
  PythonDomainConfigSchema = z2.object({
@@ -1155,6 +1176,23 @@ var init_config = __esm({
1155
1176
  }).optional();
1156
1177
  PathsConfigSchema = z2.object({
1157
1178
  source: z2.string().default("src"),
1179
+ /**
1180
+ * Where this stack's tests live, relative to the project root.
1181
+ *
1182
+ * Added 2026-09-20 for P4.3 of docs/plans/2026-09-20-stack-conformant-asset-rendering.md.
1183
+ * ~25 shipped assets told every customer their tests belong in
1184
+ * `packages/core/src/__tests__/` — massu's OWN layout — and no vocabulary could express
1185
+ * the alternative because none existed. A test directory is a first-class property of
1186
+ * every stack (`tests/` on FastAPI, `test/` on Rails, `src/test/java` on Spring,
1187
+ * `Tests/` on SwiftPM, and Go keeps `_test.go` beside the source), so it belongs here
1188
+ * rather than in prose.
1189
+ *
1190
+ * OPTIONAL, so every config written before this key existed keeps loading unchanged; an
1191
+ * asset that needs it uses `{{paths.tests | default("...")}}`. All 13 shipped templates
1192
+ * declare it, each with the convention it follows recorded at the site (G32 option 2),
1193
+ * and `stack-template-conformance.test.ts` asserts that.
1194
+ */
1195
+ tests: z2.string().optional(),
1158
1196
  aliases: z2.record(z2.string(), z2.string()).default({ "@": "src" }),
1159
1197
  monorepo_roots: z2.array(z2.string()).optional(),
1160
1198
  routers: z2.string().optional(),
@@ -1167,6 +1205,13 @@ var init_config = __esm({
1167
1205
  });
1168
1206
  LanguageFrameworkEntrySchema = z2.object({
1169
1207
  framework: z2.string().optional(),
1208
+ // `source_dirs` is emitted by `init` (commands/init.ts:417) and existence-checked
1209
+ // by config validation (init.ts:868-876), but until 2026-08-14 it survived only via
1210
+ // `.passthrough()` — declared in the file, untyped in the schema. `lib/source-layout.ts`
1211
+ // derives the index builders' candidate set from it, so it is typed here rather than
1212
+ // read as an unknown: a consumer that has to shape-check its own SoT is one branch away
1213
+ // from silently contributing `undefined` to a path predicate.
1214
+ source_dirs: z2.array(z2.string()).optional(),
1170
1215
  test_framework: z2.string().optional(),
1171
1216
  test: z2.string().optional(),
1172
1217
  runtime: z2.string().optional(),
@@ -1257,14 +1302,14 @@ var init_config = __esm({
1257
1302
  project: z2.object({
1258
1303
  name: z2.string().default("my-project"),
1259
1304
  root: z2.string().default("auto")
1260
- }).default({ name: "my-project", root: "auto" }),
1261
- framework: FrameworkConfigSchema.default({
1305
+ }).prefault({ name: "my-project", root: "auto" }),
1306
+ framework: FrameworkConfigSchema.prefault({
1262
1307
  type: "typescript",
1263
1308
  router: "none",
1264
1309
  orm: "none",
1265
1310
  ui: "none"
1266
1311
  }),
1267
- paths: PathsConfigSchema.default({ source: "src", aliases: { "@": "src" } }),
1312
+ paths: PathsConfigSchema.prefault({ source: "src", aliases: { "@": "src" } }),
1268
1313
  toolPrefix: z2.string().default("massu"),
1269
1314
  dbAccessPattern: z2.string().optional(),
1270
1315
  knownMismatches: z2.record(z2.string(), z2.record(z2.string(), z2.string())).optional(),
@@ -4022,6 +4067,20 @@ function writeHookContext(hookEventName, message) {
4022
4067
 
4023
4068
  // src/hooks/security-gate.ts
4024
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
4025
4084
  var HOOK_EVENT = "PreToolUse";
4026
4085
  var DANGEROUS_BASH_PATTERNS = [
4027
4086
  // BLOCK — destructive and irreversible, or arbitrary remote code execution.
@@ -4060,6 +4119,76 @@ var PROTECTED_FILE_PATTERNS = [
4060
4119
  { pattern: /aws\/credentials$/, label: "AWS credentials file" },
4061
4120
  { pattern: /kubeconfig$/, label: "Kubernetes config file" }
4062
4121
  ];
4122
+ var SECRET_BASENAME_EXTENSIONS = ["pem", "key", "p12", "pfx", "secret"];
4123
+ var DOTENV_TEMPLATE_SUFFIXES = ["example", "template", "sample", "defaults", "dist"];
4124
+ var SECRET_EXACT_BASENAMES = [
4125
+ "id_rsa",
4126
+ "id_ed25519",
4127
+ "id_ecdsa",
4128
+ ".netrc",
4129
+ "kubeconfig",
4130
+ "credentials.json",
4131
+ "secrets.yaml",
4132
+ "secrets.yml"
4133
+ ];
4134
+ var CREDENTIALS_PATH_SUFFIX = "/credentials";
4135
+ var DOTENV = ".env";
4136
+ function looksLikeCode(token) {
4137
+ return /[()[\]{};=`$<>|&*?!"']/.test(token) || /\s/.test(token);
4138
+ }
4139
+ function namesSecretFile(token) {
4140
+ if (!token || looksLikeCode(token)) return null;
4141
+ const clean = token.replace(/[,:.]+$/, "");
4142
+ if (!clean) return null;
4143
+ const base = clean.split("/").pop() ?? "";
4144
+ if (!base) return null;
4145
+ if (SECRET_EXACT_BASENAMES.includes(base)) return base;
4146
+ if (clean.endsWith(".ssh/config")) return "SSH config file";
4147
+ if (clean.endsWith(CREDENTIALS_PATH_SUFFIX)) return "credentials file";
4148
+ if (base === DOTENV) return `${DOTENV} file`;
4149
+ if (base.startsWith(`${DOTENV}.`)) {
4150
+ const suffix = base.slice(DOTENV.length + 1);
4151
+ return DOTENV_TEMPLATE_SUFFIXES.includes(suffix) ? null : `${base} file`;
4152
+ }
4153
+ if (base.endsWith(DOTENV) && clean.includes("/")) return `${base} file`;
4154
+ for (const ext of SECRET_BASENAME_EXTENSIONS) {
4155
+ if (base.endsWith(`.${ext}`)) return `${base} file`;
4156
+ }
4157
+ return null;
4158
+ }
4159
+ function shellTokens(command) {
4160
+ const out = [];
4161
+ let cur = "";
4162
+ let quote = null;
4163
+ for (const ch of command) {
4164
+ if (quote) {
4165
+ if (ch === quote) quote = null;
4166
+ else cur += ch;
4167
+ continue;
4168
+ }
4169
+ if (ch === "'" || ch === '"') {
4170
+ quote = ch;
4171
+ cur += ch;
4172
+ continue;
4173
+ }
4174
+ if (/\s/.test(ch)) {
4175
+ if (cur) out.push(cur);
4176
+ cur = "";
4177
+ continue;
4178
+ }
4179
+ cur += ch;
4180
+ }
4181
+ if (cur) out.push(cur);
4182
+ return out;
4183
+ }
4184
+ function secretFilesInCommand(command) {
4185
+ const hits = [];
4186
+ for (const token of shellTokens(command)) {
4187
+ const label = namesSecretFile(token);
4188
+ if (label) hits.push(label);
4189
+ }
4190
+ return hits;
4191
+ }
4063
4192
  function checkBashCommand(command) {
4064
4193
  for (const { pattern, label, severity } of DANGEROUS_BASH_PATTERNS) {
4065
4194
  if (pattern.test(command)) {
@@ -4077,18 +4206,57 @@ function checkFilePath(filePath) {
4077
4206
  return null;
4078
4207
  }
4079
4208
  var DANGEROUS_PYTHON_PATTERNS = [
4080
- { pattern: /\beval\s*\(/, label: "Python eval() \u2014 arbitrary code execution" },
4081
- { pattern: /\bexec\s*\(/, label: "Python exec() \u2014 arbitrary code execution" },
4082
- { pattern: /\b__import__\s*\(/, label: "Python __import__() \u2014 dynamic import (potential code injection)" },
4083
- { pattern: /subprocess\.call\([^)]*shell\s*=\s*True/, label: "subprocess.call(shell=True) \u2014 shell injection risk" },
4084
- { pattern: /subprocess\.Popen\([^)]*shell\s*=\s*True/, label: "subprocess.Popen(shell=True) \u2014 shell injection risk" },
4085
- { pattern: /os\.system\s*\(/, label: "os.system() \u2014 shell injection risk" },
4086
- { pattern: /\bf['"].*\{.*\}.*['"].*(?:execute|cursor|query)/, label: "f-string in SQL \u2014 SQL injection risk" },
4087
- { pattern: /['"].*%s.*['"].*%.*(?:execute|cursor|query)/, label: "String formatting in SQL \u2014 SQL injection risk" }
4209
+ { pattern: /\beval\s*\(/, label: "Python eval() \u2014 arbitrary code execution", scope: "code" },
4210
+ { pattern: /\bexec\s*\(/, label: "Python exec() \u2014 arbitrary code execution", scope: "code" },
4211
+ { pattern: /\b__import__\s*\(/, label: "Python __import__() \u2014 dynamic import (potential code injection)", scope: "code" },
4212
+ { pattern: /subprocess\.call\([^)]*shell\s*=\s*True/, label: "subprocess.call(shell=True) \u2014 shell injection risk", scope: "code" },
4213
+ { pattern: /subprocess\.Popen\([^)]*shell\s*=\s*True/, label: "subprocess.Popen(shell=True) \u2014 shell injection risk", scope: "code" },
4214
+ { pattern: /os\.system\s*\(/, label: "os.system() \u2014 shell injection risk", scope: "code" },
4215
+ { pattern: /\bf['"].*\{.*\}.*['"].*(?:execute|cursor|query)/, label: "f-string in SQL \u2014 SQL injection risk", scope: "raw" },
4216
+ { pattern: /['"].*%s.*['"].*%.*(?:execute|cursor|query)/, label: "String formatting in SQL \u2014 SQL injection risk", scope: "raw" }
4088
4217
  ];
4218
+ function stripPythonCommentsAndStringBodies(src) {
4219
+ let out = "";
4220
+ let i = 0;
4221
+ while (i < src.length) {
4222
+ const ch = src[i];
4223
+ if (ch === "#") {
4224
+ while (i < src.length && src[i] !== "\n") i++;
4225
+ continue;
4226
+ }
4227
+ if (ch === '"' || ch === "'") {
4228
+ const triple = src.startsWith(ch.repeat(3), i);
4229
+ const delim = triple ? ch.repeat(3) : ch;
4230
+ out += delim;
4231
+ i += delim.length;
4232
+ let closed = false;
4233
+ while (i < src.length) {
4234
+ if (src[i] === "\\") {
4235
+ i += 2;
4236
+ continue;
4237
+ }
4238
+ if (src.startsWith(delim, i)) {
4239
+ out += delim;
4240
+ i += delim.length;
4241
+ closed = true;
4242
+ break;
4243
+ }
4244
+ if (!triple && src[i] === "\n") break;
4245
+ i++;
4246
+ }
4247
+ if (!closed) return null;
4248
+ continue;
4249
+ }
4250
+ out += ch;
4251
+ i++;
4252
+ }
4253
+ return out;
4254
+ }
4089
4255
  function checkPythonContent(content) {
4090
- for (const { pattern, label } of DANGEROUS_PYTHON_PATTERNS) {
4091
- if (pattern.test(content)) {
4256
+ const stripped = stripPythonCommentsAndStringBodies(content);
4257
+ for (const { pattern, label, scope } of DANGEROUS_PYTHON_PATTERNS) {
4258
+ const subject = scope === "raw" || stripped === null ? content : stripped;
4259
+ if (pattern.test(subject)) {
4092
4260
  return label;
4093
4261
  }
4094
4262
  }
@@ -4098,6 +4266,15 @@ function runSecurityGateFindings(hookInput) {
4098
4266
  const findings = [];
4099
4267
  const { tool_name, tool_input } = hookInput;
4100
4268
  if (tool_name === "Bash" && tool_input.command) {
4269
+ const secretFiles = secretFilesInCommand(tool_input.command);
4270
+ if (secretFiles.length > 0) {
4271
+ findings.push({
4272
+ severity: "block",
4273
+ message: `SECURITY GATE: command names a secret file: ${secretFiles.join(", ")}
4274
+ Command: ${tool_input.command.slice(0, 200)}
4275
+ BLOCKED. If this file is a template or fixture, name it with a template suffix (.example/.template/.sample) rather than weakening this check.`
4276
+ });
4277
+ }
4101
4278
  const violation = checkBashCommand(tool_input.command);
4102
4279
  if (violation) {
4103
4280
  findings.push({
@@ -4161,7 +4338,7 @@ function readStdin() {
4161
4338
  setTimeout(() => resolve5(data), 400);
4162
4339
  });
4163
4340
  }
4164
- if (process.argv[1]?.endsWith("security-gate.js") || process.argv[1]?.endsWith("security-gate")) {
4341
+ if (isDirectInvocation(import.meta.url)) {
4165
4342
  main();
4166
4343
  }
4167
4344
 
@@ -4263,6 +4440,55 @@ function getFeatureImpact(db, filePaths) {
4263
4440
 
4264
4441
  // src/hooks/pre-delete-check.ts
4265
4442
  init_config();
4443
+
4444
+ // src/lib/source-layout.ts
4445
+ init_config();
4446
+ function normalizeDir(raw) {
4447
+ if (typeof raw !== "string") return null;
4448
+ const posix = raw.replace(/\\/g, "/");
4449
+ if (posix.startsWith("/") || /^[A-Za-z]:\//.test(posix)) return null;
4450
+ const parts = posix.split("/").filter((p) => p !== "" && p !== ".");
4451
+ if (parts.some((p) => p === "..")) return null;
4452
+ if (parts.length === 0) return ".";
4453
+ return parts.join("/");
4454
+ }
4455
+ function declaredLanguageSourceDirs() {
4456
+ const languages = getConfig().framework.languages;
4457
+ if (!languages) return [];
4458
+ const out = [];
4459
+ for (const entry of Object.values(languages)) {
4460
+ if (Array.isArray(entry.source_dirs)) out.push(...entry.source_dirs);
4461
+ }
4462
+ return out;
4463
+ }
4464
+ function getSourceLayout() {
4465
+ const config = getConfig();
4466
+ const paths = config.paths;
4467
+ const declared = [
4468
+ paths.source,
4469
+ ...paths.monorepo_roots ?? [],
4470
+ ...declaredLanguageSourceDirs()
4471
+ ];
4472
+ const normalized = declared.map(normalizeDir).filter((d) => d !== null);
4473
+ const includesRoot = normalized.includes(".");
4474
+ const sourceDirs = includesRoot ? [] : [...new Set(normalized)].sort();
4475
+ const source = normalizeDir(paths.source) ?? ".";
4476
+ const under = (child) => source === "." ? child : `${source}/${child}`;
4477
+ return {
4478
+ sourceDirs,
4479
+ includesRoot,
4480
+ extensions: getResolvedPaths().extensions,
4481
+ pagesDir: normalizeDir(paths.pages ?? under("app")) ?? under("app"),
4482
+ componentsDir: normalizeDir(paths.components ?? under("components")) ?? under("components")
4483
+ };
4484
+ }
4485
+ function isUnderSourceDir(path, layout = getSourceLayout()) {
4486
+ if (normalizeDir(path) === null) return false;
4487
+ if (layout.includesRoot) return true;
4488
+ return layout.sourceDirs.some((d) => path.startsWith(`${d}/`));
4489
+ }
4490
+
4491
+ // src/hooks/pre-delete-check.ts
4266
4492
  init_hook_failure_signal();
4267
4493
  var HOOK_EVENT2 = "PreToolUse";
4268
4494
  var PROJECT_ROOT2 = getProjectRoot();
@@ -4314,8 +4540,8 @@ function extractDeletedFiles(input) {
4314
4540
  if (rmMatch) {
4315
4541
  const paths = rmMatch[1].split(/\s+/).filter((p) => !p.startsWith("-"));
4316
4542
  for (const p of paths) {
4317
- const relPath = p.startsWith("src/") ? p : p.replace(PROJECT_ROOT2 + "/", "");
4318
- if (relPath.startsWith("src/") || relPath.endsWith(".py")) {
4543
+ const relPath = isUnderSourceDir(p) ? p : p.replace(PROJECT_ROOT2 + "/", "");
4544
+ if (isUnderSourceDir(relPath) || relPath.endsWith(".py")) {
4319
4545
  files.push(relPath);
4320
4546
  }
4321
4547
  }
@@ -4325,7 +4551,7 @@ function extractDeletedFiles(input) {
4325
4551
  const content = input.tool_input.content || "";
4326
4552
  if (content.trim().length === 0) {
4327
4553
  const relPath = input.tool_input.file_path.replace(PROJECT_ROOT2 + "/", "");
4328
- if (relPath.startsWith("src/") || relPath.endsWith(".py")) {
4554
+ if (isUnderSourceDir(relPath) || relPath.endsWith(".py")) {
4329
4555
  files.push(relPath);
4330
4556
  }
4331
4557
  }
@@ -4427,7 +4653,7 @@ function readStdin2() {
4427
4653
  setTimeout(() => resolve5(data), 400);
4428
4654
  });
4429
4655
  }
4430
- if (process.argv[1]?.endsWith("pre-delete-check.js") || process.argv[1]?.endsWith("pre-delete-check")) {
4656
+ if (isDirectInvocation(import.meta.url)) {
4431
4657
  main2();
4432
4658
  }
4433
4659
 
@@ -4525,4 +4751,6 @@ async function main3() {
4525
4751
  }
4526
4752
  process.exit(EXIT_ALLOW);
4527
4753
  }
4528
- main3();
4754
+ if (isDirectInvocation(import.meta.url)) {
4755
+ main3();
4756
+ }