thumbgate 1.5.0 โ†’ 1.5.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.
@@ -0,0 +1,362 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Spec Gate โ€” proactive correctness enforcement for agent actions.
6
+ *
7
+ * Prevention rules are reactive (learned from past failures). Spec gates are
8
+ * proactive: operators define "correct" upfront as a lightweight spec, and
9
+ * gates enforce it from the start of a session.
10
+ *
11
+ * Spec format (JSON):
12
+ * {
13
+ * "name": "deployment-safety",
14
+ * "constraints": [
15
+ * { "id": "no-force-push", "scope": "bash", "deny": "git push -f|--force", "reason": "..." },
16
+ * { "id": "no-secrets", "scope": "content", "deny": "AKIA[A-Z0-9]{16}", "reason": "..." }
17
+ * ],
18
+ * "invariants": [
19
+ * { "id": "tests-pass", "require": "npm test", "before": "git commit", "reason": "..." }
20
+ * ]
21
+ * }
22
+ *
23
+ * Integration: feeds into gates-engine as an additive spec layer alongside
24
+ * default gates and auto-promoted prevention rules.
25
+ */
26
+
27
+ const crypto = require('node:crypto');
28
+ const fs = require('node:fs');
29
+ const path = require('node:path');
30
+ const { readJsonl, appendJsonl, ensureParentDir } = require('./fs-utils');
31
+ const { resolveFeedbackDir } = require('./feedback-paths');
32
+
33
+ const SPEC_DIR = path.join(__dirname, '..', 'config', 'specs');
34
+ const SPEC_AUDIT_FILE = 'spec-gate-audit.jsonl';
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Spec Loading
38
+ // ---------------------------------------------------------------------------
39
+
40
+ function loadSpec(specPath) {
41
+ const resolved = path.resolve(specPath);
42
+ const raw = fs.readFileSync(resolved, 'utf8');
43
+ const spec = JSON.parse(raw);
44
+ return validateSpec(spec, resolved);
45
+ }
46
+
47
+ function loadSpecDir(dirPath = SPEC_DIR) {
48
+ if (!fs.existsSync(dirPath)) return [];
49
+ return fs.readdirSync(dirPath)
50
+ .filter((f) => f.endsWith('.json'))
51
+ .map((f) => {
52
+ try {
53
+ return loadSpec(path.join(dirPath, f));
54
+ } catch {
55
+ return null;
56
+ }
57
+ })
58
+ .filter(Boolean);
59
+ }
60
+
61
+ function validateSpec(spec, sourcePath = null) {
62
+ if (!spec || typeof spec !== 'object') {
63
+ throw new Error('Spec must be a JSON object.');
64
+ }
65
+ const name = normalizeText(spec.name, 120);
66
+ if (!name) throw new Error('Spec requires a "name" field.');
67
+
68
+ const constraints = Array.isArray(spec.constraints)
69
+ ? spec.constraints.map((c) => validateConstraint(c)).filter(Boolean)
70
+ : [];
71
+ const invariants = Array.isArray(spec.invariants)
72
+ ? spec.invariants.map((inv) => validateInvariant(inv)).filter(Boolean)
73
+ : [];
74
+
75
+ if (constraints.length === 0 && invariants.length === 0) {
76
+ throw new Error('Spec must have at least one constraint or invariant.');
77
+ }
78
+
79
+ return {
80
+ name,
81
+ description: normalizeText(spec.description, 500) || '',
82
+ version: normalizeText(spec.version, 20) || '1',
83
+ sourcePath: sourcePath || null,
84
+ constraints,
85
+ invariants,
86
+ };
87
+ }
88
+
89
+ function validateConstraint(raw) {
90
+ if (!raw || typeof raw !== 'object') return null;
91
+ const id = normalizeText(raw.id, 80);
92
+ const deny = normalizeText(raw.deny, 500);
93
+ if (!id || !deny) return null;
94
+
95
+ return {
96
+ id,
97
+ scope: normalizeText(raw.scope, 40) || 'any',
98
+ deny,
99
+ reason: normalizeText(raw.reason, 500) || 'Blocked by spec constraint.',
100
+ severity: normalizeSeverity(raw.severity),
101
+ };
102
+ }
103
+
104
+ function validateInvariant(raw) {
105
+ if (!raw || typeof raw !== 'object') return null;
106
+ const id = normalizeText(raw.id, 80);
107
+ const require_ = normalizeText(raw.require, 200);
108
+ const before = normalizeText(raw.before, 200);
109
+ if (!id || !require_ || !before) return null;
110
+
111
+ return {
112
+ id,
113
+ require: require_,
114
+ before,
115
+ reason: normalizeText(raw.reason, 500) || 'Invariant not satisfied.',
116
+ severity: normalizeSeverity(raw.severity),
117
+ };
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Constraint Evaluation
122
+ // ---------------------------------------------------------------------------
123
+
124
+ function evaluateConstraints(spec, { tool, command, content, sandbox } = {}) {
125
+ const results = [];
126
+ const input = buildEvaluationInput({ tool, command, content, sandbox });
127
+
128
+ for (const constraint of spec.constraints) {
129
+ const matched = matchConstraint(constraint, input);
130
+ results.push({
131
+ specName: spec.name,
132
+ constraintId: constraint.id,
133
+ type: 'constraint',
134
+ passed: !matched,
135
+ reason: matched ? constraint.reason : null,
136
+ severity: constraint.severity,
137
+ });
138
+ }
139
+
140
+ return results;
141
+ }
142
+
143
+ function evaluateInvariants(spec, { action, sessionActions = [] } = {}) {
144
+ const results = [];
145
+
146
+ for (const inv of spec.invariants) {
147
+ if (!actionMatches(action, inv.before)) continue;
148
+ const satisfied = sessionActions.some((a) => actionMatches(a, inv.require));
149
+ results.push({
150
+ specName: spec.name,
151
+ invariantId: inv.id,
152
+ type: 'invariant',
153
+ passed: satisfied,
154
+ reason: satisfied ? null : inv.reason,
155
+ severity: inv.severity,
156
+ });
157
+ }
158
+
159
+ return results;
160
+ }
161
+
162
+ function evaluateAction(specs, context = {}) {
163
+ const allResults = [];
164
+
165
+ for (const spec of specs) {
166
+ const constraintResults = evaluateConstraints(spec, context);
167
+ const invariantResults = evaluateInvariants(spec, context);
168
+ allResults.push(...constraintResults, ...invariantResults);
169
+ }
170
+
171
+ const blocked = allResults.filter((r) => !r.passed);
172
+ return {
173
+ allowed: blocked.length === 0,
174
+ results: allResults,
175
+ blocked,
176
+ blockedCount: blocked.length,
177
+ totalChecked: allResults.length,
178
+ evaluatedAt: new Date().toISOString(),
179
+ };
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // Gate Config Generation
184
+ // ---------------------------------------------------------------------------
185
+
186
+ function specToGateConfigs(spec) {
187
+ return spec.constraints.map((c) => ({
188
+ id: `spec:${spec.name}:${c.id}`,
189
+ layer: 'Spec',
190
+ pattern: c.deny,
191
+ action: 'block',
192
+ message: `[Spec: ${spec.name}] ${c.reason}`,
193
+ severity: c.severity,
194
+ source: 'spec',
195
+ specName: spec.name,
196
+ specVersion: spec.version,
197
+ }));
198
+ }
199
+
200
+ function allSpecsToGateConfigs(specs) {
201
+ return specs.flatMap(specToGateConfigs);
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Audit Trail
206
+ // ---------------------------------------------------------------------------
207
+
208
+ function getAuditPath({ feedbackDir } = {}) {
209
+ const dir = feedbackDir || resolveFeedbackDir();
210
+ return path.join(dir, SPEC_AUDIT_FILE);
211
+ }
212
+
213
+ function recordSpecAudit(evaluation, context = {}, options = {}) {
214
+ const entry = {
215
+ id: `specaudit_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`,
216
+ timestamp: new Date().toISOString(),
217
+ allowed: evaluation.allowed,
218
+ blockedCount: evaluation.blockedCount,
219
+ totalChecked: evaluation.totalChecked,
220
+ blocked: evaluation.blocked,
221
+ tool: context.tool || null,
222
+ command: normalizeText(context.command, 200) || null,
223
+ action: normalizeText(context.action, 200) || null,
224
+ };
225
+ appendJsonl(getAuditPath(options), entry);
226
+ return entry;
227
+ }
228
+
229
+ function loadSpecAudit(options = {}) {
230
+ return readJsonl(getAuditPath(options));
231
+ }
232
+
233
+ function summarizeSpecAudit(entries) {
234
+ let totalChecks = 0;
235
+ let totalBlocked = 0;
236
+ const bySpec = new Map();
237
+ const byConstraint = new Map();
238
+
239
+ for (const entry of entries) {
240
+ totalChecks += entry.totalChecked || 0;
241
+ totalBlocked += entry.blockedCount || 0;
242
+ for (const block of entry.blocked || []) {
243
+ const specKey = block.specName || 'unknown';
244
+ bySpec.set(specKey, (bySpec.get(specKey) || 0) + 1);
245
+ const cKey = block.constraintId || block.invariantId || 'unknown';
246
+ byConstraint.set(cKey, (byConstraint.get(cKey) || 0) + 1);
247
+ }
248
+ }
249
+
250
+ return {
251
+ totalEvaluations: entries.length,
252
+ totalChecks,
253
+ totalBlocked,
254
+ blockRate: entries.length > 0 ? Math.round((totalBlocked / Math.max(totalChecks, 1)) * 100) : 0,
255
+ topBlockedSpecs: Array.from(bySpec.entries())
256
+ .sort(([, a], [, b]) => b - a)
257
+ .slice(0, 10)
258
+ .map(([name, count]) => ({ name, count })),
259
+ topBlockedConstraints: Array.from(byConstraint.entries())
260
+ .sort(([, a], [, b]) => b - a)
261
+ .slice(0, 10)
262
+ .map(([id, count]) => ({ id, count })),
263
+ };
264
+ }
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // Helpers
268
+ // ---------------------------------------------------------------------------
269
+
270
+ function buildEvaluationInput({ tool, command, content, sandbox } = {}) {
271
+ return {
272
+ bash: normalizeText(command, 2000) || '',
273
+ content: normalizeText(content, 5000) || '',
274
+ tool: normalizeText(tool, 80) || '',
275
+ sandbox: normalizeText(sandbox, 2000) || '',
276
+ combined: [command, content, tool, sandbox].filter(Boolean).join(' '),
277
+ };
278
+ }
279
+
280
+ function matchConstraint(constraint, input) {
281
+ try {
282
+ const regex = new RegExp(constraint.deny, 'i');
283
+ const scope = constraint.scope || 'any';
284
+ if (scope === 'bash') return regex.test(input.bash);
285
+ if (scope === 'content') return regex.test(input.content);
286
+ if (scope === 'tool') return regex.test(input.tool);
287
+ if (scope === 'sandbox') return regex.test(input.sandbox);
288
+ return regex.test(input.combined);
289
+ } catch {
290
+ return false;
291
+ }
292
+ }
293
+
294
+ function actionMatches(action, pattern) {
295
+ if (!action || !pattern) return false;
296
+ try {
297
+ return new RegExp(pattern, 'i').test(String(action));
298
+ } catch {
299
+ return String(action).toLowerCase().includes(String(pattern).toLowerCase());
300
+ }
301
+ }
302
+
303
+ function normalizeText(value, maxLength = 500) {
304
+ if (value === undefined || value === null) return null;
305
+ const text = String(value).trim();
306
+ return text ? text.slice(0, maxLength) : null;
307
+ }
308
+
309
+ function normalizeSeverity(value) {
310
+ const valid = ['critical', 'warning', 'info'];
311
+ const normalized = normalizeText(value, 20);
312
+ return normalized && valid.includes(normalized) ? normalized : 'critical';
313
+ }
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // CLI
317
+ // ---------------------------------------------------------------------------
318
+
319
+ function isCliInvocation(argv = process.argv) {
320
+ const invokedPath = argv[1];
321
+ return invokedPath ? path.resolve(invokedPath) === __filename : false;
322
+ }
323
+
324
+ if (isCliInvocation()) {
325
+ const command = process.argv[2] || 'check';
326
+ const specDir = process.argv[3] || SPEC_DIR;
327
+
328
+ if (command === 'check') {
329
+ const specs = loadSpecDir(specDir);
330
+ console.log(JSON.stringify({
331
+ specsLoaded: specs.length,
332
+ totalConstraints: specs.reduce((n, s) => n + s.constraints.length, 0),
333
+ totalInvariants: specs.reduce((n, s) => n + s.invariants.length, 0),
334
+ specs: specs.map((s) => ({ name: s.name, version: s.version, constraints: s.constraints.length, invariants: s.invariants.length })),
335
+ }, null, 2));
336
+ } else if (command === 'gates') {
337
+ const specs = loadSpecDir(specDir);
338
+ const gates = allSpecsToGateConfigs(specs);
339
+ console.log(JSON.stringify({ version: 1, gates }, null, 2));
340
+ } else if (command === 'audit') {
341
+ const entries = loadSpecAudit();
342
+ console.log(JSON.stringify(summarizeSpecAudit(entries), null, 2));
343
+ } else {
344
+ console.error(`Unknown command: ${command}. Use: check, gates, audit`);
345
+ process.exit(1);
346
+ }
347
+ }
348
+
349
+ module.exports = {
350
+ SPEC_DIR,
351
+ allSpecsToGateConfigs,
352
+ evaluateAction,
353
+ evaluateConstraints,
354
+ evaluateInvariants,
355
+ loadSpec,
356
+ loadSpecAudit,
357
+ loadSpecDir,
358
+ recordSpecAudit,
359
+ specToGateConfigs,
360
+ summarizeSpecAudit,
361
+ validateSpec,
362
+ };
@@ -1,7 +1,7 @@
1
1
  #!/bin/bash
2
- # ThumbGate Status Line for Claude Code
2
+ # ThumbGate Status Line for Claude Code and Codex
3
3
  # Shows ThumbGate feedback stats + package version/tier at a glance.
4
- # Installed by: npx thumbgate init --agent claude-code
4
+ # Installed by: npx thumbgate init --agent claude-code|codex
5
5
 
6
6
  # Resolve script directory safely (CodeQL: no uncontrolled paths)
7
7
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
@@ -152,22 +152,19 @@ osc_link() {
152
152
  local url="$1"
153
153
  local label="$2"
154
154
  case "$url" in
155
- *localhost*|*127.0.0.1*|"") printf '%s' "$label" ;;
155
+ "") printf '%s' "$label" ;;
156
156
  *) printf '\033]8;;%s\007%s\033]8;;\007' "$url" "$label" ;;
157
157
  esac
158
158
  return 0
159
159
  }
160
160
 
161
- UP_ICON="๐Ÿ‘"
162
- DOWN_ICON="๐Ÿ‘Ž"
161
+ UP_LINK="$(osc_link "$UP_URL" "๐Ÿ‘")"
162
+ DOWN_LINK="$(osc_link "$DOWN_URL" "๐Ÿ‘Ž")"
163
163
  DASHBOARD_LINK="$(osc_link "$DASHBOARD_URL" "$DASHBOARD_LABEL")"
164
164
  LESSONS_LINK="$(osc_link "$LESSONS_URL" "$LESSONS_LABEL")"
165
165
  LATEST_LESSON_LINK=""
166
166
  if [ -n "$LESSON_LABEL" ]; then
167
167
  _DISPLAY_LINK="$LESSON_LINK"
168
- case "$_DISPLAY_LINK" in
169
- *localhost*|*127.0.0.1*) _DISPLAY_LINK="" ;;
170
- esac
171
168
  if [ -n "$LESSON_TEXT" ]; then
172
169
  LATEST_LESSON_LINK="$(osc_link "$_DISPLAY_LINK" "${LESSON_LABEL}: ${LESSON_TEXT}")"
173
170
  else
@@ -182,7 +179,7 @@ if [ "$UP" = "0" ] && [ "$DOWN" = "0" ]; then
182
179
  [ -n "$LATEST_LESSON_LINK" ] && LINE="${LINE} ยท ${D}${LATEST_LESSON_LINK}${RST}"
183
180
  printf '%b\n' "$LINE"
184
181
  else
185
- LINE="${LINE} ยท ${G}${BD}${UP}${RST}${UP_ICON} ${R}${BD}${DOWN}${RST}${DOWN_ICON} ${ARROW}"
182
+ LINE="${LINE} ยท ${G}${BD}${UP}${RST}${UP_LINK} ${R}${BD}${DOWN}${RST}${DOWN_LINK} ${ARROW}"
186
183
 
187
184
  # Control Tower alerts (if any)
188
185
  [ "${SLO_V:-0}" -gt 0 ] && LINE="${LINE} ${R}${SLO_V} SLO${RST}"
@@ -92,7 +92,7 @@ Bounded retrieval of relevant feedback history for the current task. The agent g
92
92
  | Dashboard | - | Yes | Yes |
93
93
  | DPO export | - | Yes | Yes |
94
94
  | Seats | 1 | 1 | Per-seat |
95
- | Price | $0 | $19/mo | $99/seat/mo |
95
+ | Price | $0 | $19/mo | $49/seat/mo |
96
96
 
97
97
  Start a 7-day free trial of Pro: <https://buy.stripe.com/fZu9AT3Ug6zcdWh0XN3sI08>
98
98