thumbgate 1.0.0 → 1.2.0

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 (38) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.well-known/mcp/server-card.json +1 -1
  4. package/README.md +16 -5
  5. package/adapters/README.md +1 -1
  6. package/adapters/claude/.mcp.json +2 -2
  7. package/adapters/codex/config.toml +2 -2
  8. package/adapters/mcp/server-stdio.js +19 -7
  9. package/adapters/opencode/opencode.json +1 -1
  10. package/config/github-about.json +1 -1
  11. package/config/mcp-allowlists.json +1 -0
  12. package/package.json +22 -11
  13. package/plugins/claude-codex-bridge/.claude-plugin/plugin.json +1 -1
  14. package/plugins/claude-codex-bridge/.mcp.json +1 -1
  15. package/plugins/codex-profile/.codex-plugin/plugin.json +1 -1
  16. package/plugins/codex-profile/.mcp.json +1 -1
  17. package/plugins/codex-profile/INSTALL.md +1 -1
  18. package/plugins/codex-profile/README.md +1 -1
  19. package/plugins/cursor-marketplace/.cursor-plugin/plugin.json +1 -1
  20. package/plugins/opencode-profile/INSTALL.md +1 -1
  21. package/public/compare.html +302 -0
  22. package/public/index.html +41 -11
  23. package/scripts/__pycache__/train_from_feedback.cpython-312.pyc +0 -0
  24. package/scripts/ai-search-visibility.js +142 -0
  25. package/scripts/changeset-check.js +372 -0
  26. package/scripts/check-congruence.js +7 -4
  27. package/scripts/computer-use-firewall.js +45 -15
  28. package/scripts/docker-sandbox-planner.js +208 -0
  29. package/scripts/export-hf-dataset.js +293 -0
  30. package/scripts/github-about.js +56 -0
  31. package/scripts/operational-integrity.js +7 -1
  32. package/scripts/published-cli.js +10 -1
  33. package/scripts/statusline-links.js +238 -0
  34. package/scripts/statusline.sh +39 -4
  35. package/scripts/sync-github-about.js +7 -4
  36. package/scripts/tool-registry.js +11 -0
  37. package/scripts/workflow-sentinel.js +83 -35
  38. package/src/api/server.js +12 -1
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const http = require('http');
8
+ const { spawn } = require('child_process');
9
+
10
+ const { getHomeDir, getRuntimeDir, resolveProjectDir } = require('./feedback-paths');
11
+ const { resolveProKey } = require('./pro-local-dashboard');
12
+
13
+ const DEFAULT_ORIGIN = 'http://localhost:3456';
14
+ const DEFAULT_TIMEOUT_MS = 150;
15
+ const DEFAULT_BOOT_GRACE_MS = 5000;
16
+ const PKG_ROOT = path.join(__dirname, '..');
17
+
18
+ function parseOrigin(origin) {
19
+ const url = new URL(origin || DEFAULT_ORIGIN);
20
+ return {
21
+ origin: url.origin,
22
+ host: url.hostname,
23
+ port: Number(url.port || (url.protocol === 'https:' ? 443 : 80)),
24
+ protocol: url.protocol,
25
+ };
26
+ }
27
+
28
+ function isLoopbackHost(host) {
29
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1';
30
+ }
31
+
32
+ function runtimeStatePath(options = {}) {
33
+ return path.join(getRuntimeDir(options), 'statusline-api.json');
34
+ }
35
+
36
+ function readRuntimeState(options = {}) {
37
+ try {
38
+ return JSON.parse(fs.readFileSync(runtimeStatePath(options), 'utf8'));
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ function writeRuntimeState(payload, options = {}) {
45
+ const targetPath = runtimeStatePath(options);
46
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
47
+ fs.writeFileSync(targetPath, JSON.stringify(payload, null, 2) + '\n');
48
+ return targetPath;
49
+ }
50
+
51
+ function isPidAlive(pid) {
52
+ const numericPid = Number(pid);
53
+ if (!Number.isInteger(numericPid) || numericPid <= 0) return false;
54
+ try {
55
+ process.kill(numericPid, 0);
56
+ return true;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ function shouldReuseBootingState(state, now = Date.now()) {
63
+ if (!state || !isPidAlive(state.pid)) return false;
64
+ const startedAt = Date.parse(state.startedAt || 0);
65
+ if (!Number.isFinite(startedAt)) return true;
66
+ return now - startedAt < DEFAULT_BOOT_GRACE_MS;
67
+ }
68
+
69
+ function requestOk(url, timeoutMs = DEFAULT_TIMEOUT_MS) {
70
+ return new Promise((resolve) => {
71
+ const req = http.get(url, (res) => {
72
+ res.resume();
73
+ resolve(res.statusCode >= 200 && res.statusCode < 500);
74
+ });
75
+ req.on('error', () => resolve(false));
76
+ req.setTimeout(timeoutMs, () => {
77
+ req.destroy();
78
+ resolve(false);
79
+ });
80
+ });
81
+ }
82
+
83
+ async function probeLocalServer(origin, options = {}) {
84
+ const timeoutMs = Number(options.timeoutMs || DEFAULT_TIMEOUT_MS);
85
+ return requestOk(`${origin}/health`, timeoutMs);
86
+ }
87
+
88
+ function launchLocalServer(options = {}) {
89
+ const env = options.env || process.env;
90
+ const origin = parseOrigin(options.origin || env.THUMBGATE_LOCAL_API_ORIGIN || DEFAULT_ORIGIN);
91
+ const homeDir = options.homeDir || getHomeDir({ env });
92
+ const resolvedKey = (options.resolveKey || resolveProKey)({ env, homeDir });
93
+ const projectDir = resolveProjectDir({ env, cwd: options.cwd || process.cwd() });
94
+ const childEnv = {
95
+ ...env,
96
+ HOST: origin.host,
97
+ PORT: String(origin.port),
98
+ THUMBGATE_LOCAL_API_ORIGIN: origin.origin,
99
+ THUMBGATE_PROJECT_DIR: projectDir,
100
+ THUMBGATE_PRO_MODE: '1',
101
+ };
102
+
103
+ if (resolvedKey && resolvedKey.key) {
104
+ childEnv.THUMBGATE_API_KEY = resolvedKey.key;
105
+ }
106
+
107
+ const child = spawn(
108
+ process.execPath,
109
+ [path.join(PKG_ROOT, 'bin', 'cli.js'), 'start-api'],
110
+ {
111
+ cwd: projectDir,
112
+ env: childEnv,
113
+ detached: true,
114
+ stdio: 'ignore',
115
+ }
116
+ );
117
+ child.unref();
118
+
119
+ const state = {
120
+ pid: child.pid,
121
+ projectDir,
122
+ origin: origin.origin,
123
+ startedAt: new Date().toISOString(),
124
+ };
125
+ writeRuntimeState(state, { env, home: homeDir });
126
+ return state;
127
+ }
128
+
129
+ function buildLinkState({
130
+ ready,
131
+ booting,
132
+ origin,
133
+ canBootstrap,
134
+ }) {
135
+ if (ready) {
136
+ return {
137
+ state: 'ready',
138
+ dashboardLabel: 'Dashboard',
139
+ lessonsLabel: 'Lessons',
140
+ upLabel: '👍',
141
+ downLabel: '👎',
142
+ dashboardUrl: `${origin}/dashboard`,
143
+ lessonsUrl: `${origin}/lessons`,
144
+ upUrl: `${origin}/feedback/quick?signal=up`,
145
+ downUrl: `${origin}/feedback/quick?signal=down`,
146
+ };
147
+ }
148
+
149
+ if (booting) {
150
+ return {
151
+ state: 'booting',
152
+ dashboardLabel: 'Dashboard…',
153
+ lessonsLabel: 'Lessons…',
154
+ upLabel: '👍',
155
+ downLabel: '👎',
156
+ dashboardUrl: '',
157
+ lessonsUrl: '',
158
+ upUrl: '',
159
+ downUrl: '',
160
+ };
161
+ }
162
+
163
+ return {
164
+ state: canBootstrap ? 'offline' : 'unavailable',
165
+ dashboardLabel: canBootstrap ? 'Dash: thumbgate pro' : 'Dashboard',
166
+ lessonsLabel: 'Learn: thumbgate lessons',
167
+ upLabel: '👍',
168
+ downLabel: '👎',
169
+ dashboardUrl: '',
170
+ lessonsUrl: '',
171
+ upUrl: '',
172
+ downUrl: '',
173
+ };
174
+ }
175
+
176
+ async function getStatuslineLinks(options = {}) {
177
+ const env = options.env || process.env;
178
+ if (env._TEST_THUMBGATE_STATUSLINE_LINKS_JSON) {
179
+ return JSON.parse(env._TEST_THUMBGATE_STATUSLINE_LINKS_JSON);
180
+ }
181
+
182
+ const homeDir = options.homeDir || getHomeDir({ env });
183
+ const parsedOrigin = parseOrigin(options.origin || env.THUMBGATE_LOCAL_API_ORIGIN || DEFAULT_ORIGIN);
184
+ const origin = parsedOrigin.origin;
185
+ const allowLocalBootstrap = isLoopbackHost(parsedOrigin.host);
186
+ const probe = options.probeLocalServer || probeLocalServer;
187
+ const resolveKey = options.resolveKey || resolveProKey;
188
+ const startServer = options.launchLocalServer || launchLocalServer;
189
+ const key = resolveKey({ env, homeDir });
190
+ const canBootstrap = allowLocalBootstrap && Boolean(key && key.key);
191
+
192
+ const ready = allowLocalBootstrap ? await probe(origin, options) : false;
193
+ if (ready) {
194
+ return buildLinkState({ ready: true, booting: false, origin, canBootstrap });
195
+ }
196
+
197
+ const state = readRuntimeState({ env, home: homeDir });
198
+ if (shouldReuseBootingState(state)) {
199
+ return buildLinkState({ ready: false, booting: true, origin, canBootstrap });
200
+ }
201
+
202
+ if (canBootstrap) {
203
+ startServer({
204
+ env,
205
+ homeDir,
206
+ origin,
207
+ cwd: options.cwd || process.cwd(),
208
+ resolveKey: () => key,
209
+ });
210
+ return buildLinkState({ ready: false, booting: true, origin, canBootstrap });
211
+ }
212
+
213
+ return buildLinkState({ ready: false, booting: false, origin, canBootstrap });
214
+ }
215
+
216
+ if (require.main === module) {
217
+ getStatuslineLinks()
218
+ .then((payload) => {
219
+ process.stdout.write(JSON.stringify(payload));
220
+ })
221
+ .catch(() => {
222
+ process.exit(0);
223
+ });
224
+ }
225
+
226
+ module.exports = {
227
+ buildLinkState,
228
+ getStatuslineLinks,
229
+ isPidAlive,
230
+ launchLocalServer,
231
+ parseOrigin,
232
+ isLoopbackHost,
233
+ probeLocalServer,
234
+ readRuntimeState,
235
+ runtimeStatePath,
236
+ shouldReuseBootingState,
237
+ writeRuntimeState,
238
+ };
@@ -6,6 +6,7 @@
6
6
  # Resolve script directory safely (CodeQL: no uncontrolled paths)
7
7
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
8
8
  case "$SCRIPT_DIR" in *[!a-zA-Z0-9/_.-]*) echo "ThumbGate: invalid script path"; exit 1;; esac
9
+ LOCAL_API_ORIGIN="${THUMBGATE_LOCAL_API_ORIGIN:-http://localhost:3456}"
9
10
 
10
11
  # ── Parse Claude Code session JSON from stdin ─────────────────────
11
12
  eval "$(cat | jq -r '
@@ -63,7 +64,7 @@ fi
63
64
  # Background refresh from REST API when cache is stale (>120s)
64
65
  if [ $(( _NOW - ${CACHE_TS:-0} )) -gt 120 ]; then
65
66
  (
66
- _R=$(curl -s --max-time 3 "http://localhost:3456/v1/feedback/stats" -H "Authorization: Bearer ${THUMBGATE_API_KEY:-tg_creator_dev_enterprise}" 2>/dev/null)
67
+ _R=$(curl -s --max-time 3 "${LOCAL_API_ORIGIN}/v1/feedback/stats" -H "Authorization: Bearer ${THUMBGATE_API_KEY:-tg_creator_dev_enterprise}" 2>/dev/null)
67
68
  [ -z "$_R" ] && exit 0
68
69
  echo "$_R" | python3 -c "
69
70
  import json,sys,time,os
@@ -78,6 +79,23 @@ except:pass
78
79
  disown 2>/dev/null
79
80
  fi
80
81
 
82
+ # ── Clickable statusline affordances ─────────────────────────────
83
+ LINK_STATE="offline"
84
+ UP_URL=""; DOWN_URL=""; DASHBOARD_URL=""; LESSONS_URL=""
85
+ DASHBOARD_LABEL="Dashboard"; LESSONS_LABEL="Lessons"
86
+ _LINKS_JSON=$(node "${SCRIPT_DIR}/statusline-links.js" 2>/dev/null)
87
+ if [ -n "$_LINKS_JSON" ]; then
88
+ eval "$(echo "$_LINKS_JSON" | jq -r '
89
+ @sh "LINK_STATE=\(.state // "offline")",
90
+ @sh "UP_URL=\(.upUrl // "")",
91
+ @sh "DOWN_URL=\(.downUrl // "")",
92
+ @sh "DASHBOARD_URL=\(.dashboardUrl // "")",
93
+ @sh "LESSONS_URL=\(.lessonsUrl // "")",
94
+ @sh "DASHBOARD_LABEL=\(.dashboardLabel // "Dashboard")",
95
+ @sh "LESSONS_LABEL=\(.lessonsLabel // "Lessons")"
96
+ ' 2>/dev/null)"
97
+ fi
98
+
81
99
  # ── ThumbGate package metadata ────────────────────────────────────────
82
100
  TG_VERSION="unknown"; TG_TIER="Free"
83
101
  _META_JSON=$(node "${SCRIPT_DIR}/statusline-meta.js" 2>/dev/null)
@@ -107,17 +125,34 @@ case "${TREND}" in
107
125
  improving) ARROW="↗" ;; degrading) ARROW="↘" ;; stable) ARROW="→" ;; *) ARROW="?" ;;
108
126
  esac
109
127
 
128
+ osc8_link() {
129
+ local url="$1"
130
+ local label="$2"
131
+ if [ -n "$url" ]; then
132
+ printf '\033]8;;%s\a%s\033]8;;\a' "$url" "$label"
133
+ else
134
+ printf '%s' "$label"
135
+ fi
136
+ }
137
+
138
+ UP_ICON="$(osc8_link "$UP_URL" "👍")"
139
+ DOWN_ICON="$(osc8_link "$DOWN_URL" "👎")"
140
+ DASHBOARD_LINK="$(osc8_link "$DASHBOARD_URL" "$DASHBOARD_LABEL")"
141
+ LESSONS_LINK="$(osc8_link "$LESSONS_URL" "$LESSONS_LABEL")"
142
+
110
143
  # ── Output (single line) ─────────────────────────────────────────
111
144
  LINE="ThumbGate v${TG_VERSION} · ${TG_TIER}"
112
145
  if [ "$UP" = "0" ] && [ "$DOWN" = "0" ]; then
113
- echo -e "${D}${LINE} · no feedback yet${RST}"
146
+ LINE="${D}${LINE} · no feedback yet${RST} · ${C}${DASHBOARD_LINK}${RST} · ${M}${LESSONS_LINK}${RST}"
147
+ printf '%b\n' "$LINE"
114
148
  else
115
- LINE="${LINE} · ${G}${BD}${UP}${RST}👍 ${R}${BD}${DOWN}${RST}👎 ${ARROW}"
149
+ LINE="${LINE} · ${G}${BD}${UP}${RST}${UP_ICON} ${R}${BD}${DOWN}${RST}${DOWN_ICON} ${ARROW}"
116
150
 
117
151
  # Control Tower alerts (if any)
118
152
  [ "${SLO_V:-0}" -gt 0 ] && LINE="${LINE} ${R}${SLO_V} SLO${RST}"
119
153
  [ "${AT_RISK:-0}" -gt 0 ] && LINE="${LINE} ${R}${AT_RISK}⚠${RST}"
120
154
  [ "${ANOMALIES:-0}" -gt 0 ] && LINE="${LINE} ${R}${ANOMALIES}☠${RST}"
155
+ LINE="${LINE} · ${C}${DASHBOARD_LINK}${RST} · ${M}${LESSONS_LINK}${RST}"
121
156
 
122
- echo -e "$LINE"
157
+ printf '%b\n' "$LINE"
123
158
  fi
@@ -6,6 +6,7 @@ const {
6
6
  fetchLiveGitHubAbout,
7
7
  loadGitHubAboutConfig,
8
8
  updateLiveGitHubAbout,
9
+ verifyLiveGitHubAbout,
9
10
  } = require('./github-about');
10
11
 
11
12
  async function main() {
@@ -32,11 +33,13 @@ async function main() {
32
33
  console.log(`Syncing GitHub About for ${about.repo}...`);
33
34
  await updateLiveGitHubAbout({ repo: about.repo });
34
35
 
35
- const after = await fetchLiveGitHubAbout({ repo: about.repo });
36
- const remaining = compareGitHubAbout(about, after, `Live GitHub About (${about.repo})`);
37
- if (remaining.length > 0) {
36
+ const verification = await verifyLiveGitHubAbout({
37
+ expected: about,
38
+ repo: about.repo,
39
+ });
40
+ if (verification.errors.length > 0) {
38
41
  console.error(`\n❌ GitHub About sync incomplete for ${about.repo}:\n`);
39
- for (const error of remaining) {
42
+ for (const error of verification.errors) {
40
43
  console.error(` • ${error}`);
41
44
  }
42
45
  console.error('');
@@ -399,6 +399,17 @@ const TOOLS = [
399
399
  },
400
400
  },
401
401
  }),
402
+ destructiveTool({
403
+ name: 'export_hf_dataset',
404
+ description: 'Export ThumbGate agent traces and DPO preference pairs as a HuggingFace-compatible dataset. Produces traces.jsonl, preferences.jsonl, and dataset_info.json with PII-redacted paths. Ready for huggingface-cli upload.',
405
+ inputSchema: {
406
+ type: 'object',
407
+ properties: {
408
+ outputDir: { type: 'string', description: 'Output directory (default: feedback-dir/hf-dataset)' },
409
+ includeProvenance: { type: 'boolean', description: 'Include provenance events in traces (default: true)' },
410
+ },
411
+ },
412
+ }),
402
413
  destructiveTool({
403
414
  name: 'export_databricks_bundle',
404
415
  description: 'Export ThumbGate logs and proof artifacts as a Databricks-ready analytics bundle',
@@ -14,6 +14,7 @@ const {
14
14
  normalizePosix,
15
15
  resolveRepoRoot,
16
16
  } = require('./operational-integrity');
17
+ const { buildDockerSandboxPlan } = require('./docker-sandbox-planner');
17
18
  const { evaluatePretool } = require('./hybrid-feedback-context');
18
19
 
19
20
  const GOVERNANCE_STATE_PATH = path.join(process.env.HOME || '/tmp', '.thumbgate', 'governance-state.json');
@@ -523,12 +524,58 @@ function buildEvidence({
523
524
  return evidence;
524
525
  }
525
526
 
527
+ function addIntegrityRemediations(push, integrity) {
528
+ if (!integrity || !Array.isArray(integrity.blockers)) {
529
+ return;
530
+ }
531
+
532
+ const blockerCodes = new Set(integrity.blockers.map((blocker) => blocker.code));
533
+ const remediationSpecs = [
534
+ {
535
+ codes: ['missing_branch_governance'],
536
+ id: 'set_branch_governance',
537
+ title: 'Declare branch governance',
538
+ action: 'Call set_branch_governance with branchName, baseBranch, and PR/release expectations.',
539
+ why: 'Release, merge, and PR workflows need explicit branch state.',
540
+ },
541
+ {
542
+ codes: ['merge_requires_pr_context'],
543
+ id: 'attach_pr_context',
544
+ title: 'Attach PR context',
545
+ action: 'Update branch governance with prNumber or prUrl before merging.',
546
+ why: 'Merge actions should be tied to one explicit review surface.',
547
+ },
548
+ {
549
+ codes: ['missing_release_version', 'release_version_mismatch'],
550
+ id: 'align_release_version',
551
+ title: 'Align release version',
552
+ action: 'Set branch governance releaseVersion and verify it matches package.json before publish.',
553
+ why: 'Release metadata should match the artifact being published.',
554
+ },
555
+ {
556
+ codes: ['publish_requires_base_branch', 'publish_requires_mainline_head'],
557
+ id: 'switch_to_mainline',
558
+ title: 'Run publish from mainline',
559
+ action: `Move the action onto ${integrity.baseBranch || DEFAULT_BASE_BRANCH} after the merge commit exists.`,
560
+ why: 'Publish and tag flows should execute from the protected mainline branch.',
561
+ },
562
+ ];
563
+
564
+ for (const remediation of remediationSpecs) {
565
+ if (!remediation.codes.some((code) => blockerCodes.has(code))) {
566
+ continue;
567
+ }
568
+ push(remediation.id, remediation.title, remediation.action, remediation.why);
569
+ }
570
+ }
571
+
526
572
  function buildRemediations({
527
573
  integrity,
528
574
  taskScopeViolation,
529
575
  protectedSurface,
530
576
  blastRadius,
531
577
  memoryGuard,
578
+ executionSurface,
532
579
  }) {
533
580
  const remediations = [];
534
581
  const seen = new Set();
@@ -555,41 +602,7 @@ function buildRemediations({
555
602
  'Protected policy files need an explicit time-bounded approval.'
556
603
  );
557
604
  }
558
- if (integrity && Array.isArray(integrity.blockers)) {
559
- const blockerCodes = new Set(integrity.blockers.map((blocker) => blocker.code));
560
- if (blockerCodes.has('missing_branch_governance')) {
561
- push(
562
- 'set_branch_governance',
563
- 'Declare branch governance',
564
- 'Call set_branch_governance with branchName, baseBranch, and PR/release expectations.',
565
- 'Release, merge, and PR workflows need explicit branch state.'
566
- );
567
- }
568
- if (blockerCodes.has('merge_requires_pr_context')) {
569
- push(
570
- 'attach_pr_context',
571
- 'Attach PR context',
572
- 'Update branch governance with prNumber or prUrl before merging.',
573
- 'Merge actions should be tied to one explicit review surface.'
574
- );
575
- }
576
- if (blockerCodes.has('missing_release_version') || blockerCodes.has('release_version_mismatch')) {
577
- push(
578
- 'align_release_version',
579
- 'Align release version',
580
- 'Set branch governance releaseVersion and verify it matches package.json before publish.',
581
- 'Release metadata should match the artifact being published.'
582
- );
583
- }
584
- if (blockerCodes.has('publish_requires_base_branch') || blockerCodes.has('publish_requires_mainline_head')) {
585
- push(
586
- 'switch_to_mainline',
587
- 'Run publish from mainline',
588
- `Move the action onto ${integrity.baseBranch || DEFAULT_BASE_BRANCH} after the merge commit exists.`,
589
- 'Publish and tag flows should execute from the protected mainline branch.'
590
- );
591
- }
592
- }
605
+ addIntegrityRemediations(push, integrity);
593
606
  if (memoryGuard && memoryGuard.mode && memoryGuard.mode !== 'allow') {
594
607
  push(
595
608
  'retrieve_lessons',
@@ -606,6 +619,14 @@ function buildRemediations({
606
619
  'Smaller blast radii are easier to verify and recover.'
607
620
  );
608
621
  }
622
+ if (executionSurface?.shouldSandbox) {
623
+ push(
624
+ 'route_to_docker_sandbox',
625
+ 'Route through Docker Sandboxes',
626
+ `Launch the repo in Docker Sandboxes before retrying. Standalone: ${executionSurface.launchers.standalone}. Docker Desktop: ${executionSurface.launchers.dockerDesktop}.`,
627
+ 'Isolated execution limits host damage when a high-risk local action goes wrong.'
628
+ );
629
+ }
609
630
 
610
631
  return remediations;
611
632
  }
@@ -615,6 +636,9 @@ function buildReasoning(report) {
615
636
  `Workflow sentinel risk ${report.band} (${report.riskScore}) for ${report.toolName}.`,
616
637
  `Blast radius: ${report.blastRadius.summary}.`,
617
638
  ];
639
+ if (report.executionSurface?.shouldSandbox) {
640
+ lines.push(`Execution surface: ${report.executionSurface.summary}`);
641
+ }
618
642
  for (const driver of report.drivers.slice(0, 4)) {
619
643
  lines.push(`Driver ${driver.key} (+${driver.weight}): ${driver.reason}`);
620
644
  }
@@ -624,6 +648,16 @@ function buildReasoning(report) {
624
648
  return lines;
625
649
  }
626
650
 
651
+ function getSentinelActionType(toolName) {
652
+ if (toolName === 'Bash') {
653
+ return 'shell.exec';
654
+ }
655
+ if (EDIT_LIKE_TOOLS.has(toolName)) {
656
+ return 'file.write';
657
+ }
658
+ return '';
659
+ }
660
+
627
661
  function chooseDecision({ riskScore, integrity, memoryGuard, blastRadius, command }) {
628
662
  const hasOperationalBlockers = Boolean(integrity && Array.isArray(integrity.blockers) && integrity.blockers.length > 0);
629
663
  const destructiveBypass = /\bgit\s+push\b.*(?:--force|-f)\b/i.test(command) || /\bgh\s+pr\s+merge\b.*--admin\b/i.test(command);
@@ -713,6 +747,18 @@ function evaluateWorkflowSentinel(toolName, toolInput = {}, options = {}) {
713
747
  taskScopeViolation,
714
748
  protectedSurface: protectedSurfaceForRisk,
715
749
  });
750
+ const executionSurface = buildDockerSandboxPlan({
751
+ toolName,
752
+ actionType: getSentinelActionType(toolName),
753
+ command: toolInput.command,
754
+ repoPath,
755
+ affectedFiles,
756
+ riskBand: risk.band,
757
+ riskScore: risk.score,
758
+ requiresNetwork: Boolean(
759
+ /\b(?:curl|wget|gh\s+pr|git\s+push|npm\s+publish|yarn\s+publish|pnpm\s+publish)\b/i.test(toolInput.command || '')
760
+ ),
761
+ });
716
762
  const decision = chooseDecision({
717
763
  riskScore: risk.score,
718
764
  integrity,
@@ -736,6 +782,7 @@ function evaluateWorkflowSentinel(toolName, toolInput = {}, options = {}) {
736
782
  protectedSurface: protectedSurfaceForRisk,
737
783
  blastRadius,
738
784
  memoryGuard,
785
+ executionSurface,
739
786
  });
740
787
  const summary = decision === 'allow'
741
788
  ? 'No predictive workflow blockers detected.'
@@ -753,6 +800,7 @@ function evaluateWorkflowSentinel(toolName, toolInput = {}, options = {}) {
753
800
  blastRadius,
754
801
  evidence,
755
802
  remediations,
803
+ executionSurface,
756
804
  memoryGuard,
757
805
  taskScopeViolation,
758
806
  operationalIntegrity: {
package/src/api/server.js CHANGED
@@ -169,6 +169,7 @@ const PRO_PAGE_PATH = path.resolve(__dirname, '../../public/pro.html');
169
169
  const DASHBOARD_PAGE_PATH = path.resolve(__dirname, '../../public/dashboard.html');
170
170
  const LESSONS_PAGE_PATH = path.resolve(__dirname, '../../public/lessons.html');
171
171
  const GUIDE_PAGE_PATH = path.resolve(__dirname, '../../public/guide.html');
172
+ const COMPARE_PAGE_PATH = path.resolve(__dirname, '../../public/compare.html');
172
173
  const LEARN_PAGE_PATH = path.resolve(__dirname, '../../public/learn.html');
173
174
  const LEARN_DIR = path.resolve(__dirname, '../../public/learn');
174
175
  const BUYER_INTENT_SCRIPT_PATH = path.resolve(__dirname, '../../public/js/buyer-intent.js');
@@ -2791,6 +2792,16 @@ async function addContext(){
2791
2792
  return;
2792
2793
  }
2793
2794
 
2795
+ if (isGetLikeRequest && pathname === '/compare') {
2796
+ try {
2797
+ const html = fs.readFileSync(COMPARE_PAGE_PATH, 'utf-8');
2798
+ sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
2799
+ } catch {
2800
+ sendJson(res, 404, { error: 'Compare page not found' });
2801
+ }
2802
+ return;
2803
+ }
2804
+
2794
2805
  if (isGetLikeRequest && pathname === '/blog') {
2795
2806
  try {
2796
2807
  const blogPath = path.resolve(__dirname, '../../public/blog.html');
@@ -2848,7 +2859,7 @@ async function addContext(){
2848
2859
  version: pkg.version,
2849
2860
  status: 'ok',
2850
2861
  docs: 'https://github.com/IgorGanapolsky/ThumbGate',
2851
- endpoints: ['/health', '/dashboard', '/guide', '/learn', '/pro', '/v1/feedback/capture', '/v1/feedback/stats', '/v1/feedback/summary', '/v1/lessons/search', '/v1/search', '/v1/dashboard', '/v1/dashboard/render-spec', '/v1/settings/status', '/v1/dpo/export', '/v1/jobs', '/v1/jobs/harness', '/v1/analytics/databricks/export'],
2862
+ endpoints: ['/health', '/dashboard', '/guide', '/compare', '/learn', '/pro', '/v1/feedback/capture', '/v1/feedback/stats', '/v1/feedback/summary', '/v1/lessons/search', '/v1/search', '/v1/dashboard', '/v1/dashboard/render-spec', '/v1/settings/status', '/v1/dpo/export', '/v1/jobs', '/v1/jobs/harness', '/v1/analytics/databricks/export'],
2852
2863
  }, {}, {
2853
2864
  headOnly: isHeadRequest,
2854
2865
  });