thumbgate 1.34.3 → 1.37.1

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 (200) hide show
  1. package/.agents/skills/cyberstrike-compare-not-clone/SKILL.md +36 -0
  2. package/.agents/skills/gitlab-sandbox-allowlist-not-trust/SKILL.md +77 -0
  3. package/.agents/skills/jit-harness-compare-not-clone/SKILL.md +34 -0
  4. package/.agents/skills/openui-catalog-compose-honesty/SKILL.md +64 -0
  5. package/.agents/skills/zvec-grep-compare-not-clone/SKILL.md +34 -0
  6. package/.claude-plugin/plugin.json +1 -1
  7. package/.well-known/llms.txt +1 -0
  8. package/.well-known/mcp/server-card.json +1 -1
  9. package/CONTRIBUTING.md +95 -0
  10. package/README.md +195 -632
  11. package/THIRD_PARTY_NOTICES.md +89 -0
  12. package/adapters/claude/.mcp.json +2 -2
  13. package/adapters/forge/forge.yaml +3 -3
  14. package/adapters/future-agi/.mcp.json +8 -0
  15. package/adapters/future-agi/FUTURE_AGI.md +23 -0
  16. package/adapters/future-agi/config.toml +3 -0
  17. package/adapters/future-agi/future-agi-bridge.js +9 -0
  18. package/adapters/future-agi/opencode.json +8 -0
  19. package/adapters/herdr/herdr-plugin.toml +18 -0
  20. package/adapters/mcp/server-stdio.js +238 -25
  21. package/adapters/opencode/opencode.json +1 -1
  22. package/adapters/workos/WORKOS.md +52 -0
  23. package/bin/cli.js +373 -5
  24. package/bin/futureagi-bridge +9 -0
  25. package/config/gate-templates.json +653 -4
  26. package/config/gates/actor-critic-audit.json +34 -0
  27. package/config/gates/default.json +21 -2
  28. package/config/gates/five-walls-governance.json +34 -0
  29. package/config/gates/future-agi-guardrails.json +34 -0
  30. package/config/gates/radware-threat-defense-2026.json +61 -0
  31. package/config/gates/simatree-data-governance.json +33 -0
  32. package/config/mcp-allowlists.json +4 -0
  33. package/config/merge-quality-checks.json +10 -1
  34. package/config/model-candidates.json +382 -24
  35. package/config/model-tiers.json +18 -0
  36. package/config/post-deploy-marketing-pages.json +10 -0
  37. package/config/progressive/01-wire-only.json +11 -0
  38. package/config/progressive/02-dashboard-empty-ok.json +10 -0
  39. package/config/progressive/03-one-lesson.json +10 -0
  40. package/config/progressive/04-warn-fires.json +11 -0
  41. package/config/progressive/05-strict-optional.json +11 -0
  42. package/config/progressive/README.md +15 -0
  43. package/config/schemas/broker-execution-receipt.schema.json +139 -0
  44. package/config/schemas/provider-execution-attestation-v1.schema.json +58 -0
  45. package/conformance/provider-attestation/vectors.json +320 -0
  46. package/docs/specs/provider-execution-attestation-v1.md +69 -0
  47. package/openapi/openapi.yaml +15 -0
  48. package/package.json +401 -147
  49. package/public/about.html +2 -2
  50. package/public/ai-malpractice-prevention.html +7 -7
  51. package/public/blog/a-10-dollar-vps-is-not-a-computer.html +143 -0
  52. package/public/blog/a-receipt-is-not-world-state.html +388 -0
  53. package/public/blog/git-at-agent-scale.html +374 -0
  54. package/public/blog/no-llm-in-the-gate.html +133 -0
  55. package/public/blog.html +80 -0
  56. package/public/case-studies.html +16 -1
  57. package/public/compare.html +28 -0
  58. package/public/diagnostic.html +216 -7
  59. package/public/docs/connectors.html +39 -0
  60. package/public/federal.html +2 -2
  61. package/public/founders.html +639 -0
  62. package/public/index.html +87 -9
  63. package/public/install.html +8 -8
  64. package/public/learn.html +39 -0
  65. package/public/numbers.html +2 -2
  66. package/public/peter.html +310 -0
  67. package/public/platform-partners.html +119 -0
  68. package/public/pricing.html +24 -3
  69. package/public/privacy.html +117 -0
  70. package/public/pro.html +17 -0
  71. package/public/support.html +62 -0
  72. package/public/terms.html +130 -0
  73. package/public/third-party-notices.html +95 -0
  74. package/public/yt.html +351 -0
  75. package/scripts/action-receipts.js +133 -3
  76. package/scripts/adaptive-governance-arena.js +349 -0
  77. package/scripts/admin-override.js +205 -0
  78. package/scripts/agent-action-inventory.js +869 -0
  79. package/scripts/agent-audit-trace.js +42 -2
  80. package/scripts/agent-egress-policy.js +1117 -0
  81. package/scripts/agent-memory-lifecycle.js +141 -2
  82. package/scripts/agent-operations-planner.js +441 -1
  83. package/scripts/agent-readiness.js +68 -0
  84. package/scripts/agent-security-central.js +647 -0
  85. package/scripts/allowlist-bridge-honesty.js +417 -0
  86. package/scripts/async-job-runner.js +102 -11
  87. package/scripts/audit-trail.js +212 -0
  88. package/scripts/auto-promote-gates.js +178 -27
  89. package/scripts/billing.js +1 -1
  90. package/scripts/broker-execution-receipts.js +719 -0
  91. package/scripts/budget-aware-gates-proof.js +423 -0
  92. package/scripts/claude-feedback-sync.js +29 -3
  93. package/scripts/claw-harness-production.js +237 -0
  94. package/scripts/cli-progress.js +111 -0
  95. package/scripts/cli-schema.js +163 -1
  96. package/scripts/codex-runbook-flywheel.js +318 -0
  97. package/scripts/context-footprint.js +186 -0
  98. package/scripts/contextfs.js +143 -61
  99. package/scripts/dashboard-limits.js +27 -0
  100. package/scripts/dashboard.js +279 -9
  101. package/scripts/deepseek-v4-runtime-guardrails.js +72 -6
  102. package/scripts/docker-sandbox-planner.js +18 -0
  103. package/scripts/double-blind-eval-protocol.js +252 -0
  104. package/scripts/edotenv-rl-gateway.js +259 -0
  105. package/scripts/ensure-production-search-corpus.js +162 -0
  106. package/scripts/eval-holdout.js +311 -0
  107. package/scripts/feedback-aggregate.js +21 -2
  108. package/scripts/feedback-loop.js +87 -5
  109. package/scripts/feedback-quality.js +9 -0
  110. package/scripts/file-ledger-lock.js +4 -1
  111. package/scripts/financial-control-plane.js +41 -1
  112. package/scripts/find-dormant-requires.js +118 -0
  113. package/scripts/fs-utils.js +84 -8
  114. package/scripts/gate-stats.js +2 -2
  115. package/scripts/gates-engine.js +859 -58
  116. package/scripts/generate-case-study-outreach.js +24 -15
  117. package/scripts/git-at-scale.js +628 -0
  118. package/scripts/governance-conflict-audit.js +1650 -0
  119. package/scripts/governance-difficulty-curriculum.js +328 -0
  120. package/scripts/graphrag-retrieval.js +275 -0
  121. package/scripts/gurobi-optimizer.js +324 -0
  122. package/scripts/gurobi_optimizer.py +485 -0
  123. package/scripts/harness-selector.js +82 -1
  124. package/scripts/hidden-entry-points.js +284 -0
  125. package/scripts/human-escalation.js +199 -1
  126. package/scripts/hybrid-feedback-context.js +152 -19
  127. package/scripts/intent-governed-execution.js +602 -0
  128. package/scripts/intervention-policy.js +123 -20
  129. package/scripts/jit-harness-compose.js +628 -0
  130. package/scripts/jsonl-watcher.js +10 -0
  131. package/scripts/lesson-embedding-index.js +95 -12
  132. package/scripts/lesson-retrieval.js +105 -19
  133. package/scripts/local-model-profile.js +19 -2
  134. package/scripts/mailer/resend-mailer.js +1 -1
  135. package/scripts/matryoshka-embedding.js +235 -0
  136. package/scripts/mcp-oauth.js +42 -4
  137. package/scripts/mcp-session-handles.js +1016 -0
  138. package/scripts/mcp-wiring-doctor.js +314 -0
  139. package/scripts/memory-firewall.js +115 -2
  140. package/scripts/memory-scope-readiness.js +299 -0
  141. package/scripts/memory-vs-rag-route.js +161 -0
  142. package/scripts/model-tier-router.js +148 -21
  143. package/scripts/nvidia-specdecode-al-doctor.js +536 -0
  144. package/scripts/openui-catalog-compose-honesty.js +593 -0
  145. package/scripts/operational-integrity.js +19 -1
  146. package/scripts/override-audit.js +213 -0
  147. package/scripts/package-manager-honesty-doctor.js +458 -0
  148. package/scripts/pr-manager.js +63 -1
  149. package/scripts/prove-herdr-adapter.js +52 -0
  150. package/scripts/prove-memory-pyramid-and-symbolic-canvas.js +95 -0
  151. package/scripts/prove-workos.js +73 -0
  152. package/scripts/provider-attestation-conformance.js +192 -0
  153. package/scripts/provider-receipt-contract.js +136 -0
  154. package/scripts/qwen38-max-cost-optimizer.js +401 -0
  155. package/scripts/radware-threat-defense.js +280 -0
  156. package/scripts/rag-embedding-identity.js +221 -0
  157. package/scripts/rag-precision-guardrails.js +112 -2
  158. package/scripts/remote-feedback-capture.js +159 -0
  159. package/scripts/research-agent-harness.js +256 -0
  160. package/scripts/rsi-safety-hillclimb.js +200 -0
  161. package/scripts/rule-sprawl.js +188 -0
  162. package/scripts/schedule-manager.js +147 -0
  163. package/scripts/self-heal.js +8 -0
  164. package/scripts/session-lease.js +415 -0
  165. package/scripts/simatree-data-governance.js +347 -0
  166. package/scripts/slo-alert-engine.js +172 -7
  167. package/scripts/solver-parity.js +539 -0
  168. package/scripts/stealth-memory-injection-gate.js +333 -0
  169. package/scripts/switchyard-router.js +366 -0
  170. package/scripts/telemetry-analytics.js +84 -27
  171. package/scripts/temporal-decay-weighting.js +138 -0
  172. package/scripts/test-all.js +165 -0
  173. package/scripts/token-savings.js +42 -0
  174. package/scripts/tool-kpi-tracker.js +108 -5
  175. package/scripts/tool-registry.js +193 -5
  176. package/scripts/universal-claim-evaluator.js +14 -2
  177. package/scripts/vector-store.js +279 -9
  178. package/scripts/workflow-notebook.js +391 -0
  179. package/scripts/workflow-sentinel.js +111 -12
  180. package/scripts/workos-production-guard.js +260 -0
  181. package/scripts/workspace-search-route.js +515 -0
  182. package/server.json +2 -2
  183. package/src/agent-identity-boundary.js +76 -0
  184. package/src/agent-retrieval-cache.js +155 -0
  185. package/src/alert-noise-ledger.js +502 -0
  186. package/src/api/server.js +802 -185
  187. package/src/git-fast-cache.js +220 -0
  188. package/src/git-wal-sync.js +156 -0
  189. package/src/hash-anchored-edit.js +82 -0
  190. package/src/hermes-platform-protocol.js +475 -0
  191. package/src/hermes-sync-plane.js +241 -0
  192. package/src/index.js +30 -1
  193. package/src/iso42001-compliance-guard.js +97 -0
  194. package/src/latency-budget.js +244 -0
  195. package/src/mcp-writeguard.js +316 -0
  196. package/src/miminions-adapter.js +106 -0
  197. package/src/pipeline-compass.js +104 -0
  198. package/src/ppl-alert-pipeline.js +284 -0
  199. package/src/rendezvous-router.js +90 -0
  200. package/src/security-questionnaire.js +195 -0
@@ -27,6 +27,200 @@ function getAuditLogPath() {
27
27
  return path.join(resolveFeedbackDir(), AUDIT_LOG_FILENAME);
28
28
  }
29
29
 
30
+ /**
31
+ * Resolve the acting non-human identity for audit attribution. Sessions and
32
+ * worktree leases export THUMBGATE_SESSION_AGENT; THUMBGATE_AGENT_ID is the
33
+ * generic override. Null when the caller cannot be attributed — audit records
34
+ * must never invent an identity.
35
+ */
36
+ function resolveAuditAgentId() {
37
+ return process.env.THUMBGATE_SESSION_AGENT
38
+ || process.env.THUMBGATE_AGENT_ID
39
+ || null;
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Agent identity store — registry + observed-agent stream
44
+ //
45
+ // Lives here (not in org-dashboard) because the gates-engine identity gate and
46
+ // audit attribution are public runtime surfaces, while org-dashboard stays out
47
+ // of the npm tarball. org-dashboard re-exports these for its Pro reporting.
48
+ // ---------------------------------------------------------------------------
49
+
50
+ const REGISTRY_FILENAME = 'agent-registry.jsonl';
51
+ const OBSERVED_FILENAME = 'observed-agents.jsonl';
52
+ const OBSERVED_COMPACT_BYTES = 512 * 1024;
53
+
54
+ function getRegistryPath() {
55
+ return path.join(resolveFeedbackDir(), REGISTRY_FILENAME);
56
+ }
57
+
58
+ function getObservedAgentsPath() {
59
+ return path.join(resolveFeedbackDir(), OBSERVED_FILENAME);
60
+ }
61
+
62
+ /**
63
+ * Register an agent session. Called on MCP server startup or agent bootstrap.
64
+ */
65
+ function registerAgent({ agentId, source, project, branch, metadata } = {}) {
66
+ const id = agentId || `agent_${Date.now()}_${crypto.randomUUID().slice(0, 8)}`;
67
+ const record = {
68
+ id,
69
+ registeredAt: new Date().toISOString(),
70
+ lastSeenAt: new Date().toISOString(),
71
+ source: source || 'unknown',
72
+ project: project || path.basename(process.cwd()),
73
+ branch: branch || null,
74
+ toolCalls: 0,
75
+ gateBlocks: 0,
76
+ gateWarns: 0,
77
+ metadata: { lifecycleStatus: 'active', ...(metadata || {}) },
78
+ };
79
+ const registryPath = getRegistryPath();
80
+ ensureDir(path.dirname(registryPath));
81
+ fs.appendFileSync(registryPath, JSON.stringify(record) + '\n');
82
+ return record;
83
+ }
84
+
85
+ /**
86
+ * Record agent activity — called after each tool call evaluation.
87
+ */
88
+ function recordAgentActivity(agentId, decision) {
89
+ const registryPath = getRegistryPath();
90
+ if (!fs.existsSync(registryPath)) return;
91
+ const lines = fs.readFileSync(registryPath, 'utf-8').trim().split('\n');
92
+ const updated = [];
93
+ let found = false;
94
+ for (const line of lines) {
95
+ try {
96
+ const record = JSON.parse(line);
97
+ if (record.id === agentId && !found) {
98
+ record.lastSeenAt = new Date().toISOString();
99
+ record.toolCalls = (record.toolCalls || 0) + 1;
100
+ if (decision === 'deny') record.gateBlocks = (record.gateBlocks || 0) + 1;
101
+ if (decision === 'warn') record.gateWarns = (record.gateWarns || 0) + 1;
102
+ found = true;
103
+ }
104
+ updated.push(JSON.stringify(record));
105
+ } catch {
106
+ updated.push(line);
107
+ }
108
+ }
109
+ fs.writeFileSync(registryPath, updated.join('\n') + '\n');
110
+ }
111
+
112
+ /**
113
+ * Load all registered agent sessions.
114
+ */
115
+ function loadAgentRegistry() {
116
+ const registryPath = getRegistryPath();
117
+ if (!fs.existsSync(registryPath)) return [];
118
+ const raw = fs.readFileSync(registryPath, 'utf-8').trim();
119
+ if (!raw) return [];
120
+ const records = [];
121
+ for (const line of raw.split('\n')) {
122
+ try {
123
+ records.push(JSON.parse(line));
124
+ } catch {
125
+ // Skip corrupt rows; identity checks fail open, never crash.
126
+ }
127
+ }
128
+ return records;
129
+ }
130
+
131
+ /**
132
+ * Retire an agent identity. A retired agent that keeps acting is flagged by
133
+ * the gates-engine identity gate (deny under strict enforcement).
134
+ */
135
+ function retireAgent(agentId, reason) {
136
+ const registryPath = getRegistryPath();
137
+ if (!fs.existsSync(registryPath)) return false;
138
+ const lines = fs.readFileSync(registryPath, 'utf-8').trim().split('\n');
139
+ const updated = [];
140
+ let found = false;
141
+ for (const line of lines) {
142
+ try {
143
+ const record = JSON.parse(line);
144
+ if (record.id === agentId) {
145
+ record.metadata = record.metadata || {};
146
+ record.metadata.lifecycleStatus = 'retired';
147
+ record.metadata.retiredAt = new Date().toISOString();
148
+ if (reason) record.metadata.retireReason = String(reason);
149
+ found = true;
150
+ }
151
+ updated.push(JSON.stringify(record));
152
+ } catch {
153
+ updated.push(line);
154
+ }
155
+ }
156
+ if (found) fs.writeFileSync(registryPath, updated.join('\n') + '\n');
157
+ return found;
158
+ }
159
+
160
+ /**
161
+ * Record one observation of an acting agent — the producer side of shadow-AI
162
+ * detection, called from the gates-engine evaluation path on every attributed
163
+ * tool call. Appends are plain (never lost to a busy lock); compaction runs
164
+ * only under the cross-process ledger lock so a concurrent agent's append can
165
+ * never be truncated by the read-aggregate-rewrite window.
166
+ */
167
+ function recordObservedAgent(agentId) {
168
+ const id = String(agentId || '').trim();
169
+ if (!id) return null;
170
+ const observedPath = getObservedAgentsPath();
171
+ ensureDir(path.dirname(observedPath));
172
+ const event = { id, seenAt: new Date().toISOString() };
173
+ fs.appendFileSync(observedPath, JSON.stringify(event) + '\n');
174
+ try {
175
+ if (fs.statSync(observedPath).size > OBSERVED_COMPACT_BYTES) {
176
+ const { withFileLedgerLock } = require('./file-ledger-lock');
177
+ withFileLedgerLock(observedPath + '.lock', () => {
178
+ const compacted = loadObservedAgents()
179
+ .map((row) => JSON.stringify(row))
180
+ .join('\n');
181
+ fs.writeFileSync(observedPath, compacted + '\n');
182
+ });
183
+ }
184
+ } catch {
185
+ // Busy lock or read error: skip compaction, never drop the observation.
186
+ }
187
+ return event;
188
+ }
189
+
190
+ /**
191
+ * Aggregate observation events into one row per agent id:
192
+ * { id, firstSeenAt, lastSeenAt, observations }.
193
+ */
194
+ function loadObservedAgents() {
195
+ const observedPath = getObservedAgentsPath();
196
+ if (!fs.existsSync(observedPath)) return [];
197
+ const byId = new Map();
198
+ const raw = fs.readFileSync(observedPath, 'utf-8').trim();
199
+ if (!raw) return [];
200
+ for (const line of raw.split('\n')) {
201
+ let event;
202
+ try {
203
+ event = JSON.parse(line);
204
+ } catch {
205
+ continue;
206
+ }
207
+ const id = String(event.id || '').trim();
208
+ if (!id) continue;
209
+ const seenAt = event.seenAt || event.lastSeenAt || new Date().toISOString();
210
+ const row = byId.get(id) || {
211
+ id,
212
+ firstSeenAt: event.firstSeenAt || seenAt,
213
+ lastSeenAt: seenAt,
214
+ observations: 0,
215
+ };
216
+ if (seenAt < row.firstSeenAt) row.firstSeenAt = seenAt;
217
+ if (seenAt > row.lastSeenAt) row.lastSeenAt = seenAt;
218
+ row.observations += Number(event.observations) > 0 ? Number(event.observations) : 1;
219
+ byId.set(id, row);
220
+ }
221
+ return [...byId.values()];
222
+ }
223
+
30
224
 
31
225
  // ---------------------------------------------------------------------------
32
226
  // Core audit record
@@ -53,6 +247,7 @@ function recordAuditEvent(params = {}) {
53
247
  timestamp: new Date().toISOString(),
54
248
  toolName: params.toolName || 'unknown',
55
249
  toolInput: sanitizeToolInput(params.toolInput || {}),
250
+ agentId: params.agentId || resolveAuditAgentId(),
56
251
  decision: params.decision || 'allow',
57
252
  gateId: params.gateId || null,
58
253
  message: params.message || null,
@@ -61,6 +256,13 @@ function recordAuditEvent(params = {}) {
61
256
  source: params.source || 'gates-engine',
62
257
  };
63
258
 
259
+ // Typed override payload (see scripts/override-audit.js). Carried verbatim so
260
+ // an override is filterable by decision === 'override' rather than having to
261
+ // be inferred from toolName, which cannot distinguish it from a normal call.
262
+ if (params.override && typeof params.override === 'object') {
263
+ record.override = params.override;
264
+ }
265
+
64
266
  // Safe stringify: never let circular/toxic tool inputs crash the gate path
65
267
  // (Antithesis-style invariant: evaluation + audit must not throw).
66
268
  let line;
@@ -353,6 +555,16 @@ module.exports = {
353
555
  recordAuditEvent,
354
556
  auditToFeedback,
355
557
  readAuditLog,
558
+ registerAgent,
559
+ recordAgentActivity,
560
+ loadAgentRegistry,
561
+ retireAgent,
562
+ recordObservedAgent,
563
+ loadObservedAgents,
564
+ getRegistryPath,
565
+ getObservedAgentsPath,
566
+ REGISTRY_FILENAME,
567
+ OBSERVED_FILENAME,
356
568
  auditStats,
357
569
  latencyStats,
358
570
  skillAdherence,
@@ -206,13 +206,31 @@ function extractExecutableAction(entry) {
206
206
  const ctx = String(entry.context || entry.whatWentWrong || '').trim();
207
207
  if (ctx.length < 4) return null;
208
208
 
209
+ // Strong signal: known tool prefixes — accept immediately.
210
+ const known = /^(?:sudo\s+)?(?:kubectl|git|npm|npx|yarn|pnpm|python|python3|node|curl|wget|docker|podman|rm|mv|cp|chmod|chown|psql|mysql|mongo|terraform|pulumi|aws|gcloud|az|helm|ssh|scp|rsync|make|cargo|go|ruby|perl|bash|sh|zsh)\b/i.test(ctx);
211
+ if (known && ctx.length <= 240) return ctx;
212
+
213
+ // Reject agent-narration prose before the weak "looks like a command" heuristic.
214
+ // "Agent (grok) auto-sent …" was previously accepted because it starts with a
215
+ // single token + spaces, then became an inert force-gate pattern (AGENT-259).
216
+ if (/^(?:agent|the|user|ceo|claude|grok|codex|gemini|assistant|operator)\b/i.test(ctx)) {
217
+ return null;
218
+ }
219
+ if (/\b(?:without\s+(?:human\s+)?review|thumbs?-?down|auto-sent|always-approve)\b/i.test(ctx)) {
220
+ return null;
221
+ }
222
+ // Parenthetical asides ("Agent (grok, always-approve)") are prose, not argv.
223
+ if (/\([^)]{0,40}\b(?:grok|claude|codex|always-approve|agent)\b/i.test(ctx)) {
224
+ return null;
225
+ }
226
+
209
227
  // Looks like a shell / CLI invocation (not free-form prose).
210
228
  const looksExecutable = /^(?:sudo\s+)?(?:~\/|\.\/|\/)?(?:[A-Za-z0-9._+-]+\/)*[A-Za-z0-9._+-]+(?:\s|$)/.test(ctx)
211
229
  && /\s|^[a-z0-9._+-]+(?:\s|$)/i.test(ctx)
212
- && !/\s+(?:broke|failed|wrong|should|never|please|the agent)\b/i.test(ctx.slice(0, 80));
213
- // Strong signal: known tool prefixes
214
- const known = /^(?:sudo\s+)?(?:kubectl|git|npm|npx|yarn|pnpm|python|python3|node|curl|wget|docker|podman|rm|mv|cp|chmod|chown|psql|mysql|mongo|terraform|pulumi|aws|gcloud|az|helm|ssh|scp|rsync|make|cargo|go|ruby|perl|bash|sh|zsh)\b/i.test(ctx);
215
- if (known || (looksExecutable && /[\s-]/.test(ctx) && ctx.length <= 200)) {
230
+ && !/\s+(?:broke|failed|wrong|should|never|please|the agent|auto-sent|emailed)\b/i.test(ctx.slice(0, 100));
231
+ // Weak path: require a flag or path-like token so "Agent wrote a bad summary" fails.
232
+ const hasCliShape = /(?:\s-{1,2}[A-Za-z][\w-]*|\/[A-Za-z0-9._-]{2,}|\.(?:js|ts|py|sh|json|yml|yaml)\b)/.test(ctx);
233
+ if (looksExecutable && hasCliShape && ctx.length <= 200) {
216
234
  return ctx;
217
235
  }
218
236
  return null;
@@ -307,6 +325,64 @@ function contextToPattern(context) {
307
325
  return raw.slice(0, 120).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
308
326
  }
309
327
 
328
+ /**
329
+ * Known incident classes that appear as English prose in force-gate / feedback
330
+ * but map to a deterministic tool-call surface. Without this, force-promote of
331
+ * "agent emailed X without review" stores the sentence as a regex and never
332
+ * matches any tool call (AGENT-259, June 2026 + Aug 2026 recurrence).
333
+ */
334
+ function deriveSurfacePattern(context) {
335
+ const text = String(context || '');
336
+ if (!text.trim()) return null;
337
+
338
+ const emailish = /\b(gmail|e-?mail|smtp|sendmail|msmtp|mailx|nodemailer|smtplib|messages\/send|send_message|send_draft|send-mail|outbound\s+email|cold\s+outreach)\b/i.test(text);
339
+ const sendish = /\b(send|sent|sending|emailed|mail(ed)?)\b/i.test(text);
340
+ if (emailish && sendish) {
341
+ // Mirrors config/gates/default.json outbound-email-send + the live
342
+ // ~/.thumbgate/bin/outbound-email-guard.js surface set.
343
+ return '(?:(?:^|[_.])send[_-]?(?:message|mail|email|now|draft)s?\\b|emailer[_-]?messages?[_-]?send|users\\/[^/\\s"\']+\\/messages\\/send|\\bmessages\\/send\\b|messages\\s*\\(\\s*\\)\\s*\\.\\s*send\\s*\\(|\\bsendmail\\b|\\bmsmtp\\b|\\bsmtplib\\b|\\bnodemailer\\b)';
344
+ }
345
+
346
+ if (/\bforce[- ]?push\b|git\s+push\s+(?:-f|--force)\b/i.test(text)) {
347
+ return 'git\\s+push\\s+(--force|-f)';
348
+ }
349
+
350
+ if (/\b(rm\s+-rf\s+\/|sudo\s+rm\s+-rf)\b/i.test(text)) {
351
+ return '(?:sudo\\s+)?rm\\s+-rf\\s+/';
352
+ }
353
+
354
+ return null;
355
+ }
356
+
357
+ /**
358
+ * True when a gate pattern is essentially an English sentence — many words,
359
+ * no executable tokens — and therefore cannot fire against tool-call text.
360
+ */
361
+ function isInertProsePattern(pattern) {
362
+ if (!pattern || typeof pattern !== 'string') return true;
363
+ // Intentional multi-alternative surface matchers (email send, force-push classes).
364
+ if (/\(\?:/.test(pattern) && pattern.includes('|')) return false;
365
+ // Unescape common contextToPattern escapes so we can inspect the words.
366
+ const unescaped = pattern
367
+ .replace(/\\([.*+?^${}()|[\]\\])/g, '$1')
368
+ .replace(/\\\\/g, '\\');
369
+ const words = (unescaped.match(/[A-Za-z]{3,}/g) || []);
370
+ if (words.length < 6) return false;
371
+ // Short CLI / known surface tokens only — a long English sentence that merely
372
+ // *mentions* "gmail" is still inert (it will never equal a tool-call string).
373
+ const trimmed = unescaped.trim();
374
+ const looksLikeCli =
375
+ /^(?:sudo\s+)?(?:kubectl|git|npm|npx|yarn|pnpm|python|python3|node|curl|wget|docker|podman|rm|bash|sh|zsh)\b/i.test(trimmed)
376
+ && trimmed.length <= 160;
377
+ const looksLikeSurfaceToken =
378
+ words.length <= 8
379
+ && /(?:send_message|send_draft|messages\/send|sendmail|force-push|git\s+push\s+(?:-f|--force))/i.test(trimmed);
380
+ if (looksLikeCli || looksLikeSurfaceToken) return false;
381
+ return true;
382
+ }
383
+
384
+
385
+
310
386
  /**
311
387
  * A gate that cannot match the very context that produced it is inert — it
312
388
  * shows up in the dashboard as an active blocking rule while enforcing nothing.
@@ -322,39 +398,51 @@ function gateMatchesOwnContext(gate, context) {
322
398
  }
323
399
 
324
400
  function buildGateRule(group, actionOverride) {
325
- const action = actionOverride || (group.count === 'MANUAL' ? group.manualAction || 'block' : (group.count >= BLOCK_THRESHOLD ? 'block' : 'warn'));
401
+ // Tests and callers may pass partial groups; never crash on missing fields.
402
+ const g = group && typeof group === 'object' ? group : {};
403
+ const action = actionOverride || (g.count === 'MANUAL' ? g.manualAction || 'block' : (g.count >= BLOCK_THRESHOLD ? 'block' : 'warn'));
326
404
  const severity = action === 'block' ? 'critical' : action === 'approve' ? 'high' : 'medium';
327
- const executable = (group.latestExecutable || extractExecutableAction({ context: group.latestContext }) || group.latestContext || '').slice(0, 120);
328
- const context = executable;
329
- const kind = group.key.startsWith('diagnosis:')
405
+ const fromExecutable = (g.latestExecutable
406
+ || extractExecutableAction({ context: g.latestContext })
407
+ || '').slice(0, 120);
408
+ // Prefer real executable text; else a derived surface regex (email send, force-push).
409
+ // NEVER fall back to free-form English prose — that produced permanently inert gates.
410
+ const surfacePattern = g.surfacePattern || deriveSurfacePattern(g.latestContext || g.key || '');
411
+ const pattern = fromExecutable
412
+ ? contextToPattern(fromExecutable)
413
+ : (surfacePattern || null);
414
+ const context = (fromExecutable || g.latestContext || g.key || '').slice(0, 120);
415
+ const kind = String(g.key || '').startsWith('diagnosis:')
330
416
  ? 'repeated diagnosis'
331
- : group.key.startsWith('constraint:')
417
+ : String(g.key || '').startsWith('constraint:')
332
418
  ? 'repeated constraint violation'
333
- : 'repeated executable action';
419
+ : (fromExecutable ? 'repeated executable action' : 'derived surface guard');
334
420
 
335
- const occurrencesText = group.count === 'MANUAL' ? 'manual' : `${group.count} occurrences`;
421
+ const occurrencesText = g.count === 'MANUAL' ? 'manual' : `${g.count == null ? 0 : g.count} occurrences`;
336
422
  const suggestedMessage = `Auto-promoted ${kind}: "${context}" (${occurrencesText} in ${WINDOW_DAYS} days)`;
337
423
 
338
424
  // TTL: auto-promoted rules expire after the configured window unless
339
425
  // refreshed by a fresh fire. Manual force-promote bypasses TTL — operator
340
426
  // says "permanent" by going through the force path.
341
427
  const nowMs = Date.now();
342
- const isManual = group.count === 'MANUAL';
428
+ const isManual = g.count === 'MANUAL';
343
429
  const expiresAt = isManual ? null : new Date(nowMs + getRuleTtlMs()).toISOString();
344
430
 
345
431
  return {
346
- id: patternToGateId(group.key),
347
- trigger: `auto:${group.key}`,
348
- // Derived from the executable action, NOT from tag keys — see contextToPattern.
349
- pattern: contextToPattern(executable),
432
+ id: patternToGateId(String(g.key || 'unknown')),
433
+ trigger: `auto:${g.key || 'unknown'}`,
434
+ // Derived from executable action OR known surface class — never raw prose.
435
+ pattern,
350
436
  action,
351
437
  message: suggestedMessage,
352
438
  severity,
353
- occurrences: group.count,
439
+ // Always numeric — string 'MANUAL' concatenated into gate-stats totals (0MANUAL…).
440
+ occurrences: g.count === 'MANUAL' ? 1 : Number(g.count) || 0,
354
441
  promotedAt: new Date().toISOString(),
355
442
  expiresAt,
356
443
  lastFiredAt: null,
357
- source: group.source || 'auto-promote',
444
+ source: g.source || 'auto-promote',
445
+ ...(surfacePattern && !fromExecutable ? { surfaceDerived: true } : {}),
358
446
  };
359
447
  }
360
448
 
@@ -430,21 +518,51 @@ function recordGateFire(data, gateId, now = Date.now()) {
430
518
 
431
519
  function forcePromote(context, action = 'block') {
432
520
  if (!context) throw new Error('context is required for force-promote');
521
+ let executable = extractExecutableAction({ context });
522
+ // If extractExecutableAction returned narration that would become inert prose,
523
+ // drop it and fall through to surface derivation (email send, force-push, …).
524
+ if (executable && isInertProsePattern(contextToPattern(executable))) {
525
+ executable = null;
526
+ }
527
+ const surfacePattern = deriveSurfacePattern(context);
528
+ // Prefer a known surface class over a weak "command-shaped" string when both exist
529
+ // and the command is not a known CLI prefix — prevents dual wrong-pattern promotion.
530
+ if (executable && surfacePattern && !/^(?:sudo\s+)?(?:kubectl|git|npm|npx|curl|python|python3|node|bash|sh|zsh|rm)\b/i.test(executable)) {
531
+ executable = null;
532
+ }
533
+ if (!executable && !surfacePattern) {
534
+ throw new Error(
535
+ 'force-promote refused: context is prose without a matchable tool surface. '
536
+ + 'Pass an executable command (e.g. "git push --force") or describe a known '
537
+ + 'class (email send / Gmail messages/send, force-push, rm -rf /).',
538
+ );
539
+ }
540
+
433
541
  const data = loadAutoGates();
434
542
  const gateId = patternToGateId(context);
435
-
543
+
436
544
  // Remove existing if any
437
545
  data.gates = data.gates.filter(g => g.id !== gateId);
438
-
546
+
439
547
  const gate = buildGateRule({
440
548
  key: context,
441
549
  latestContext: context,
550
+ latestExecutable: executable || '',
551
+ surfacePattern: surfacePattern || undefined,
442
552
  count: 'MANUAL',
443
553
  manualAction: action,
444
- source: 'force-promote'
554
+ source: 'force-promote',
445
555
  });
556
+
557
+ if (!gate.pattern || isInertProsePattern(gate.pattern)) {
558
+ throw new Error(
559
+ 'force-promote refused: derived pattern is inert prose and would never fire. '
560
+ + `pattern=${JSON.stringify(gate.pattern)}`,
561
+ );
562
+ }
563
+
446
564
  data.gates.unshift(gate);
447
-
565
+
448
566
  if (data.gates.length > MAX_AUTO_GATES) {
449
567
  data.gates = data.gates.slice(0, MAX_AUTO_GATES);
450
568
  }
@@ -454,12 +572,20 @@ function forcePromote(context, action = 'block') {
454
572
  gateId,
455
573
  context,
456
574
  action,
575
+ pattern: gate.pattern,
576
+ surfaceDerived: Boolean(gate.surfaceDerived),
457
577
  promotedAt: new Date().toISOString(),
458
- source: 'force-promote'
578
+ source: 'force-promote',
459
579
  });
460
580
 
461
581
  saveAutoGates(data);
462
- return { gateId, action, totalGates: data.gates.length };
582
+ return {
583
+ gateId,
584
+ action,
585
+ pattern: gate.pattern,
586
+ surfaceDerived: Boolean(gate.surfaceDerived),
587
+ totalGates: data.gates.length,
588
+ };
463
589
  }
464
590
 
465
591
  function promote(feedbackLogPath, options) {
@@ -472,10 +598,22 @@ function promote(feedbackLogPath, options) {
472
598
  // path rather than carrying a near-stale expiresAt.
473
599
  const { data: expiredData, expired } = expireGates(loadAutoGates());
474
600
  const data = expiredData;
475
- if (expired.length > 0) {
601
+ // Drop permanently inert prose gates left by older force-promote / promotion bugs.
602
+ const inertRemoved = [];
603
+ data.gates = (data.gates || []).filter((g) => {
604
+ if (g && isInertProsePattern(g.pattern)) {
605
+ inertRemoved.push(g);
606
+ return false;
607
+ }
608
+ return true;
609
+ });
610
+ if (expired.length > 0 || inertRemoved.length > 0) {
476
611
  saveAutoGates(data);
477
612
  }
478
- const promotions = expired.map((e) => ({ type: 'expired', gateId: e.id, expiredAt: e.expiresAt }));
613
+ const promotions = [
614
+ ...expired.map((e) => ({ type: 'expired', gateId: e.id, expiredAt: e.expiresAt })),
615
+ ...inertRemoved.map((e) => ({ type: 'quarantined-inert-prose', gateId: e.id, pattern: e.pattern })),
616
+ ];
479
617
 
480
618
  for (const group of Object.values(groups)) {
481
619
  if (group.count < WARN_THRESHOLD) continue;
@@ -520,7 +658,9 @@ function promote(feedbackLogPath, options) {
520
658
  // Never persist a gate that cannot match the context that produced it. Such a
521
659
  // gate renders in the dashboard as an active blocking rule while enforcing
522
660
  // nothing, which reads as "the agent learned" when it did not.
523
- if (!gateMatchesOwnContext(gate, group.latestContext)) {
661
+ // Surface-derived patterns (email send, force-push) intentionally do NOT match
662
+ // the English prose incident text — they match the tool surface instead.
663
+ if (!gate.surfaceDerived && !gateMatchesOwnContext(gate, group.latestContext)) {
524
664
  promotions.push({
525
665
  type: 'skipped-unmatchable',
526
666
  gateId: gate.id,
@@ -529,6 +669,15 @@ function promote(feedbackLogPath, options) {
529
669
  });
530
670
  continue;
531
671
  }
672
+ if (!gate.pattern || isInertProsePattern(gate.pattern)) {
673
+ promotions.push({
674
+ type: 'skipped-inert-prose',
675
+ gateId: gate.id,
676
+ reason: 'pattern is English prose and would never match a tool call',
677
+ occurrences: group.count,
678
+ });
679
+ continue;
680
+ }
532
681
 
533
682
  // Self-Harness stage 3: before a feedback rule goes live as a hard block,
534
683
  // regression-test it against prior allowed actions. If it would have blocked
@@ -619,6 +768,8 @@ module.exports = {
619
768
  patternToGateId,
620
769
  buildGateRule,
621
770
  contextToPattern,
771
+ deriveSurfacePattern,
772
+ isInertProsePattern,
622
773
  gateMatchesOwnContext,
623
774
  regressionCheck,
624
775
  getAuditTrailPath,
@@ -110,7 +110,7 @@ const CONFIG = {
110
110
  },
111
111
  RESEND_API_KEY: process.env.RESEND_API_KEY || process.env.THUMBGATE_RESEND_API_KEY || '',
112
112
  TRIAL_EMAIL_FROM: process.env.THUMBGATE_TRIAL_EMAIL_FROM || process.env.RESEND_FROM_EMAIL || process.env.RESEND_FROM || 'onboarding@resend.dev',
113
- TRIAL_EMAIL_REPLY_TO: process.env.THUMBGATE_TRIAL_EMAIL_REPLY_TO || 'igor.ganapolsky@gmail.com',
113
+ TRIAL_EMAIL_REPLY_TO: process.env.THUMBGATE_TRIAL_EMAIL_REPLY_TO || 'support@thumbgate.ai',
114
114
  CREDIT_PACKS: {}
115
115
  };
116
116