thumbgate 1.20.0 → 1.21.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.
@@ -1485,9 +1485,17 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
1485
1485
  }
1486
1486
 
1487
1487
  if (gate.action === 'approve') {
1488
- recordStat(gate.id, 'approve', gate);
1489
- const result = { decision: 'approve', gate: gate.id, message, severity: gate.severity, reasoning, requiresApproval: true };
1490
- const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'approve', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
1488
+ const approvalEnabled = process.env.THUMBGATE_APPROVAL_GATES !== '0';
1489
+ if (approvalEnabled) {
1490
+ recordStat(gate.id, 'approve', gate);
1491
+ const result = { decision: 'approve', gate: gate.id, message, severity: gate.severity, reasoning, requiresApproval: true };
1492
+ const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'approve', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
1493
+ auditToFeedback(auditRecord);
1494
+ return result;
1495
+ }
1496
+ recordStat(gate.id, 'warn', gate);
1497
+ const result = { decision: 'warn', gate: gate.id, message: `[approval gate disabled] ${message}`, severity: gate.severity, reasoning };
1498
+ const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'warn', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
1491
1499
  auditToFeedback(auditRecord);
1492
1500
  return result;
1493
1501
  }
@@ -1639,9 +1647,17 @@ function evaluateGates(toolName, toolInput, configPath) {
1639
1647
  }
1640
1648
 
1641
1649
  if (gate.action === 'approve') {
1642
- recordStat(gate.id, 'approve', gate);
1643
- const result = { decision: 'approve', gate: gate.id, message, severity: gate.severity, reasoning, requiresApproval: true };
1644
- const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'approve', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
1650
+ const approvalEnabled = process.env.THUMBGATE_APPROVAL_GATES !== '0';
1651
+ if (approvalEnabled) {
1652
+ recordStat(gate.id, 'approve', gate);
1653
+ const result = { decision: 'approve', gate: gate.id, message, severity: gate.severity, reasoning, requiresApproval: true };
1654
+ const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'approve', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
1655
+ auditToFeedback(auditRecord);
1656
+ return result;
1657
+ }
1658
+ recordStat(gate.id, 'warn', gate);
1659
+ const result = { decision: 'warn', gate: gate.id, message: `[approval gate disabled] ${message}`, severity: gate.severity, reasoning };
1660
+ const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'warn', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
1645
1661
  auditToFeedback(auditRecord);
1646
1662
  return result;
1647
1663
  }
@@ -1811,6 +1827,10 @@ function evaluateSecretGuard(input = {}) {
1811
1827
  }
1812
1828
 
1813
1829
  // ---------------------------------------------------------------------------
1830
+ function isApprovalGatesEnabled() {
1831
+ return process.env.THUMBGATE_APPROVAL_GATES !== '0';
1832
+ }
1833
+
1814
1834
  // PreToolUse hook interface (stdin/stdout JSON)
1815
1835
  // ---------------------------------------------------------------------------
1816
1836
 
@@ -1849,6 +1869,18 @@ function formatOutput(result, behavioralContext) {
1849
1869
  });
1850
1870
  }
1851
1871
 
1872
+ if (result.decision === 'approve') {
1873
+ const reminder = behavioralContext ? buildReminderOutput(behavioralContext) : {};
1874
+ const reminderSuffix = behavioralContext ? `\n\nSystem reminder:\n${behavioralContext}` : '';
1875
+ return JSON.stringify({
1876
+ hookSpecificOutput: {
1877
+ ...reminder,
1878
+ permissionDecision: 'deny',
1879
+ permissionDecisionReason: `[GATE:${result.gate}] APPROVAL REQUIRED: ${result.message} — Ask the human to confirm this action before proceeding.${reasoningSuffix}${reminderSuffix}`,
1880
+ },
1881
+ });
1882
+ }
1883
+
1852
1884
  if (result.decision === 'warn') {
1853
1885
  const extra = behavioralContext ? `\n${behavioralContext}` : '';
1854
1886
  const context = `[GATE:${result.gate}] WARNING: ${result.message}${reasoningSuffix}${extra}`;
@@ -2223,7 +2255,84 @@ function registerClaimGate(claimPattern, requiredActions, blockMessage) {
2223
2255
  return entry;
2224
2256
  }
2225
2257
 
2226
- function verifyClaimEvidence(claimText) {
2258
+ function normalizeStringArray(value) {
2259
+ if (!Array.isArray(value)) return [];
2260
+ return Array.from(new Set(
2261
+ value
2262
+ .map((item) => String(item || '').trim())
2263
+ .filter(Boolean)
2264
+ ));
2265
+ }
2266
+
2267
+ function normalizeGoalContract(goalContract) {
2268
+ if (!goalContract || typeof goalContract !== 'object' || Array.isArray(goalContract)) {
2269
+ return null;
2270
+ }
2271
+
2272
+ const goal = String(goalContract.goal || '').trim();
2273
+ const doneWhen = normalizeStringArray(goalContract.doneWhen);
2274
+ const requiredActions = normalizeStringArray(goalContract.proveBy);
2275
+ const mustNotChange = normalizeStringArray(goalContract.mustNotChange);
2276
+ const handoff = {
2277
+ workerAgent: String(goalContract.workerAgent || '').trim() || null,
2278
+ reviewerAgent: String(goalContract.reviewerAgent || '').trim() || null,
2279
+ orchestratorAgent: String(goalContract.orchestratorAgent || '').trim() || null,
2280
+ };
2281
+
2282
+ const matched = Boolean(
2283
+ goal ||
2284
+ doneWhen.length > 0 ||
2285
+ requiredActions.length > 0 ||
2286
+ mustNotChange.length > 0 ||
2287
+ handoff.workerAgent ||
2288
+ handoff.reviewerAgent ||
2289
+ handoff.orchestratorAgent
2290
+ );
2291
+
2292
+ if (!matched) return null;
2293
+
2294
+ return {
2295
+ goal: goal || null,
2296
+ doneWhen,
2297
+ requiredActions,
2298
+ mustNotChange,
2299
+ handoff,
2300
+ };
2301
+ }
2302
+
2303
+ function evaluateGoalContract(goalContract, actions = loadSessionActions()) {
2304
+ const normalized = normalizeGoalContract(goalContract);
2305
+ if (!normalized) {
2306
+ return {
2307
+ matched: false,
2308
+ passed: true,
2309
+ goal: null,
2310
+ doneWhen: [],
2311
+ requiredActions: [],
2312
+ missingActions: [],
2313
+ mustNotChange: [],
2314
+ handoff: {
2315
+ workerAgent: null,
2316
+ reviewerAgent: null,
2317
+ orchestratorAgent: null,
2318
+ },
2319
+ };
2320
+ }
2321
+
2322
+ const missingActions = normalized.requiredActions.filter((actionId) => !actions[actionId]);
2323
+ return {
2324
+ matched: true,
2325
+ passed: missingActions.length === 0,
2326
+ goal: normalized.goal,
2327
+ doneWhen: normalized.doneWhen,
2328
+ requiredActions: normalized.requiredActions,
2329
+ missingActions,
2330
+ mustNotChange: normalized.mustNotChange,
2331
+ handoff: normalized.handoff,
2332
+ };
2333
+ }
2334
+
2335
+ function verifyClaimEvidence(claimText, options = {}) {
2227
2336
  const normalizedClaimText = String(claimText || '').trim();
2228
2337
  if (!normalizedClaimText) {
2229
2338
  throw new Error('claimText is required');
@@ -2251,9 +2360,23 @@ function verifyClaimEvidence(claimText) {
2251
2360
  });
2252
2361
  }
2253
2362
 
2363
+ const goalContract = evaluateGoalContract(options.goalContract, actions);
2364
+ if (goalContract.matched) {
2365
+ checks.push({
2366
+ claim: 'goal_contract',
2367
+ passed: goalContract.passed,
2368
+ missing: goalContract.missingActions,
2369
+ message: goalContract.passed
2370
+ ? 'Goal contract evidence present'
2371
+ : `Goal contract requires evidence: ${goalContract.missingActions.join(', ')}`,
2372
+ goalContract,
2373
+ });
2374
+ }
2375
+
2254
2376
  return {
2255
2377
  verified: checks.every((check) => check.passed),
2256
2378
  checks,
2379
+ goalContract,
2257
2380
  };
2258
2381
  }
2259
2382
 
@@ -2289,6 +2412,7 @@ module.exports = {
2289
2412
  evaluateGatesAsync,
2290
2413
  computeExecutableHash,
2291
2414
  formatOutput,
2415
+ isApprovalGatesEnabled,
2292
2416
  run,
2293
2417
  runAsync,
2294
2418
  trackAction,
@@ -2297,6 +2421,8 @@ module.exports = {
2297
2421
  clearSessionActions,
2298
2422
  loadClaimGates,
2299
2423
  registerClaimGate,
2424
+ normalizeGoalContract,
2425
+ evaluateGoalContract,
2300
2426
  verifyClaimEvidence,
2301
2427
  DEFAULT_CONFIG_PATH,
2302
2428
  DEFAULT_CLAIM_GATES_PATH,
@@ -80,7 +80,6 @@ module.exports = {
80
80
  verifyLicense,
81
81
  isProLicensed,
82
82
  activateLicense,
83
- generateLicenseKey,
84
83
  isValidKey,
85
84
  VALID_PREFIXES,
86
85
  LICENSE_PATH,
@@ -39,9 +39,39 @@ const PAYWALL_MESSAGES = {
39
39
  default: 'This feature requires Pro. Start Pro — card required; billed today.',
40
40
  };
41
41
 
42
+ const TRIAL_DAYS = 14;
43
+
44
+ function getInstallAgeDays() {
45
+ try {
46
+ const { INSTALL_ID_PATH } = require('./cli-telemetry');
47
+ if (!fs.existsSync(INSTALL_ID_PATH)) return null;
48
+ const created = fs.statSync(INSTALL_ID_PATH).birthtimeMs || fs.statSync(INSTALL_ID_PATH).mtimeMs;
49
+ if (!Number.isFinite(created) || created <= 0) return null;
50
+ return (Date.now() - created) / (1000 * 60 * 60 * 24);
51
+ } catch (_) {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ function isInTrialPeriod() {
57
+ if (process.env.CI || process.env.GITHUB_ACTIONS) return false;
58
+ if (process.env.THUMBGATE_NO_TRIAL === '1') return false;
59
+ const age = getInstallAgeDays();
60
+ if (age === null) return false;
61
+ if (age < 0.0007) return false; // <1 minute old — just-created install, not a real trial
62
+ return age < TRIAL_DAYS;
63
+ }
64
+
65
+ function trialDaysRemaining() {
66
+ const age = getInstallAgeDays();
67
+ if (age === null) return 0;
68
+ return Math.max(0, Math.ceil(TRIAL_DAYS - age));
69
+ }
70
+
42
71
  function isProTier(authContext) {
43
72
  if (authContext && authContext.tier === 'pro') return true;
44
- if (process.env.THUMBGATE_API_KEY || process.env.THUMBGATE_PRO_MODE === '1' || process.env.THUMBGATE_NO_RATE_LIMIT === '1') return true;
73
+ if (process.env.THUMBGATE_API_KEY) return true;
74
+ if (process.env.THUMBGATE_NO_RATE_LIMIT === '1') return true;
45
75
  // Creator/dogfooding bypass: when the owner has the dev secret + bypass
46
76
  // configured (env or ~/.config/thumbgate/dev.json), treat the install as Pro
47
77
  // so marketing nudges and rate limits stop firing on the maintainer's own
@@ -57,6 +87,8 @@ function isProTier(authContext) {
57
87
  const { isProLicensed } = require('./license');
58
88
  if (isProLicensed()) return true;
59
89
  } catch (_) {}
90
+ // 14-day reverse trial: new installs get full Pro access
91
+ if (isInTrialPeriod()) return true;
60
92
  return false;
61
93
  }
62
94
 
@@ -180,11 +212,14 @@ module.exports = {
180
212
  checkLimit,
181
213
  getUsage,
182
214
  isProTier,
215
+ isInTrialPeriod,
216
+ trialDaysRemaining,
183
217
  loadUsage,
184
218
  saveUsage,
185
219
  todayKey,
186
220
  FREE_TIER_LIMITS,
187
221
  FREE_TIER_MAX_GATES,
222
+ TRIAL_DAYS,
188
223
  UPGRADE_MESSAGE,
189
224
  PAYWALL_MESSAGES,
190
225
  USAGE_FILE,
@@ -19,6 +19,32 @@ function destructiveTool(tool) {
19
19
  };
20
20
  }
21
21
 
22
+ const GOAL_CONTRACT_SCHEMA = {
23
+ type: 'object',
24
+ description: 'Optional agent handoff contract. Use this when a worker/orchestrator/reviewer loop needs explicit done criteria before a done/fixed/shipped claim is allowed.',
25
+ properties: {
26
+ goal: { type: 'string', description: 'The operator-visible goal this claim is trying to close.' },
27
+ doneWhen: {
28
+ type: 'array',
29
+ items: { type: 'string' },
30
+ description: 'Human-readable acceptance criteria for the task.',
31
+ },
32
+ proveBy: {
33
+ type: 'array',
34
+ items: { type: 'string' },
35
+ description: 'Tracked action ids required before the claim can pass, such as tests_passed, review_completed, ci_green, deploy_verified, or operator_approved.',
36
+ },
37
+ mustNotChange: {
38
+ type: 'array',
39
+ items: { type: 'string' },
40
+ description: 'Protected areas or constraints the agents must preserve while completing the goal.',
41
+ },
42
+ workerAgent: { type: 'string', description: 'Agent responsible for implementation.' },
43
+ reviewerAgent: { type: 'string', description: 'Agent responsible for independent verification.' },
44
+ orchestratorAgent: { type: 'string', description: 'Agent responsible for routing and deciding whether done can be claimed.' },
45
+ },
46
+ };
47
+
22
48
  const TOOLS = [
23
49
  readOnlyTool({
24
50
  name: 'capture_feedback',
@@ -843,6 +869,7 @@ const TOOLS = [
843
869
  required: ['claim'],
844
870
  properties: {
845
871
  claim: { type: 'string', description: 'The claim text to verify' },
872
+ goalContract: GOAL_CONTRACT_SCHEMA,
846
873
  },
847
874
  },
848
875
  }),
@@ -1291,6 +1318,7 @@ const TOOLS = [
1291
1318
  claim: { type: 'string', description: 'The completion claim text to verify (e.g. "Fix shipped", "Tests passing")' },
1292
1319
  mode: { type: 'string', enum: ['blocking', 'advisory'], description: 'blocking (default) returns blocking=true when evidence missing; advisory returns blocking=false' },
1293
1320
  sessionId: { type: 'string', description: 'Optional session id to associate with the gate decision' },
1321
+ goalContract: GOAL_CONTRACT_SCHEMA,
1294
1322
  },
1295
1323
  },
1296
1324
  }),
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * verify-marketing-pages-deployed.js — post-deploy probe for top-level
6
+ * marketing pages.
7
+ *
8
+ * Reads `config/post-deploy-marketing-pages.json` and curls every entry
9
+ * against the live production URL (default
10
+ * https://thumbgate-production.up.railway.app, overridable via
11
+ * THUMBGATE_PROD_URL env or --prod-url=…). Each response body must
12
+ * contain the configured sentinel string. Any mismatch fails the run
13
+ * and the route is included in the failure summary.
14
+ *
15
+ * Intended to run as a workflow step in .github/workflows/deploy-verify.yml
16
+ * after the version sentinel check, so the marketing surface is verified
17
+ * in the same gate as /health and /dashboard. Adding a new marketing
18
+ * page? Add it to the JSON manifest — no workflow edit required.
19
+ *
20
+ * Modes:
21
+ * --json machine-readable report on stdout, suitable for piping
22
+ * into the GitHub Actions PR comment step.
23
+ * --quiet suppress per-route lines; only print the final summary.
24
+ *
25
+ * Exit code is 0 when every page passes, 1 if any sentinel is missing
26
+ * or any route returns non-200.
27
+ */
28
+
29
+ const fs = require('node:fs');
30
+ const path = require('node:path');
31
+
32
+ const DEFAULT_PROD_URL = 'https://thumbgate-production.up.railway.app';
33
+ const DEFAULT_TIMEOUT_MS = 12000;
34
+ const DEFAULT_MANIFEST_PATH = path.resolve(__dirname, '..', 'config', 'post-deploy-marketing-pages.json');
35
+
36
+ function parseArgs(argv = []) {
37
+ const out = {
38
+ prodUrl: process.env.THUMBGATE_PROD_URL || DEFAULT_PROD_URL,
39
+ manifestPath: DEFAULT_MANIFEST_PATH,
40
+ json: false,
41
+ quiet: false,
42
+ timeoutMs: DEFAULT_TIMEOUT_MS,
43
+ };
44
+ for (const arg of argv) {
45
+ if (arg === '--json') out.json = true;
46
+ else if (arg === '--quiet') out.quiet = true;
47
+ else if (arg.startsWith('--prod-url=')) out.prodUrl = arg.slice('--prod-url='.length);
48
+ else if (arg.startsWith('--manifest=')) out.manifestPath = arg.slice('--manifest='.length);
49
+ else if (arg.startsWith('--timeout-ms=')) {
50
+ const n = Number(arg.slice('--timeout-ms='.length));
51
+ if (Number.isFinite(n) && n > 0) out.timeoutMs = n;
52
+ }
53
+ }
54
+ return out;
55
+ }
56
+
57
+ function loadManifest(manifestPath) {
58
+ const raw = fs.readFileSync(manifestPath, 'utf-8');
59
+ const parsed = JSON.parse(raw);
60
+ if (!Array.isArray(parsed.pages) || parsed.pages.length === 0) {
61
+ throw new Error(`Manifest has no pages: ${manifestPath}`);
62
+ }
63
+ for (const entry of parsed.pages) {
64
+ if (typeof entry.route !== 'string' || !entry.route.startsWith('/')) {
65
+ throw new Error(`Invalid route in manifest: ${JSON.stringify(entry)}`);
66
+ }
67
+ if (typeof entry.sentinel !== 'string' || entry.sentinel.length === 0) {
68
+ throw new Error(`Invalid sentinel for route ${entry.route}`);
69
+ }
70
+ }
71
+ return parsed;
72
+ }
73
+
74
+ async function probePage({ prodUrl, route, sentinel, fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_TIMEOUT_MS }) {
75
+ if (typeof fetchImpl !== 'function') {
76
+ return { route, ok: false, error: 'fetch_unavailable' };
77
+ }
78
+ const controller = new AbortController();
79
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
80
+ try {
81
+ const url = `${prodUrl.replace(/\/$/, '')}${route}`;
82
+ const res = await fetchImpl(url, {
83
+ signal: controller.signal,
84
+ headers: {
85
+ // A real browser-shaped UA so any bot-deflection interstitials
86
+ // do NOT trigger; we are probing the real visitor's surface.
87
+ 'User-Agent': 'thumbgate-deploy-verify/1.0 (+https://github.com/IgorGanapolsky/ThumbGate)',
88
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
89
+ },
90
+ });
91
+ const body = await res.text().catch(() => '');
92
+ const sentinelPresent = body.includes(sentinel);
93
+ return {
94
+ route,
95
+ url,
96
+ status: res.status,
97
+ ok: res.ok && sentinelPresent,
98
+ sentinelPresent,
99
+ bytes: body.length,
100
+ };
101
+ } catch (error) {
102
+ return {
103
+ route,
104
+ ok: false,
105
+ error: error?.name === 'AbortError' ? `timeout_after_${timeoutMs}ms` : (error?.message || String(error)),
106
+ };
107
+ } finally {
108
+ clearTimeout(timer);
109
+ }
110
+ }
111
+
112
+ async function runVerification({ prodUrl, manifestPath, fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
113
+ const manifest = loadManifest(manifestPath);
114
+ const results = [];
115
+ // Sequential is fine — manifest has <20 entries; parallelism would
116
+ // be over-engineered and would risk masking a per-route rate-limit signal.
117
+ for (const entry of manifest.pages) {
118
+ // eslint-disable-next-line no-await-in-loop
119
+ const result = await probePage({
120
+ prodUrl,
121
+ route: entry.route,
122
+ sentinel: entry.sentinel,
123
+ fetchImpl,
124
+ timeoutMs,
125
+ });
126
+ results.push({ ...result, sentinel: entry.sentinel, description: entry.description });
127
+ }
128
+ const passed = results.filter((r) => r.ok);
129
+ const failed = results.filter((r) => !r.ok);
130
+ return {
131
+ generatedAt: new Date().toISOString(),
132
+ prodUrl,
133
+ manifestVersion: manifest.version,
134
+ totalRoutes: results.length,
135
+ passedCount: passed.length,
136
+ failedCount: failed.length,
137
+ verdict: failed.length === 0 ? 'pass' : 'fail',
138
+ results,
139
+ };
140
+ }
141
+
142
+ function renderHuman(report, { quiet = false } = {}) {
143
+ const lines = [];
144
+ if (!quiet) {
145
+ for (const r of report.results) {
146
+ if (r.ok) {
147
+ lines.push(`✅ ${r.route.padEnd(20)} HTTP ${r.status} bytes=${r.bytes} sentinel-OK`);
148
+ } else if (r.error) {
149
+ lines.push(`❌ ${r.route.padEnd(20)} ERROR ${r.error}`);
150
+ } else if (!r.sentinelPresent) {
151
+ lines.push(`❌ ${r.route.padEnd(20)} HTTP ${r.status} sentinel MISSING (expected: ${JSON.stringify(r.sentinel)})`);
152
+ } else {
153
+ lines.push(`❌ ${r.route.padEnd(20)} HTTP ${r.status}`);
154
+ }
155
+ }
156
+ }
157
+ lines.push(`\nSummary: ${report.passedCount}/${report.totalRoutes} pages passed against ${report.prodUrl} (verdict: ${report.verdict.toUpperCase()})`);
158
+ return lines.join('\n');
159
+ }
160
+
161
+ async function main(argv) {
162
+ const args = parseArgs(argv);
163
+ let report;
164
+ try {
165
+ report = await runVerification({
166
+ prodUrl: args.prodUrl,
167
+ manifestPath: args.manifestPath,
168
+ timeoutMs: args.timeoutMs,
169
+ });
170
+ } catch (error) {
171
+ process.stderr.write(`verify-marketing-pages-deployed FAILED: ${error.message}\n`);
172
+ process.exit(2);
173
+ }
174
+ if (args.json) {
175
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
176
+ } else {
177
+ process.stdout.write(`${renderHuman(report, { quiet: args.quiet })}\n`);
178
+ }
179
+ process.exitCode = report.verdict === 'pass' ? 0 : 1;
180
+ }
181
+
182
+ module.exports = {
183
+ DEFAULT_PROD_URL,
184
+ DEFAULT_TIMEOUT_MS,
185
+ DEFAULT_MANIFEST_PATH,
186
+ parseArgs,
187
+ loadManifest,
188
+ probePage,
189
+ runVerification,
190
+ renderHuman,
191
+ };
192
+
193
+ if (path.resolve(process.argv[1] || '') === path.resolve(__filename)) {
194
+ main(process.argv.slice(2));
195
+ }
@@ -1178,10 +1178,15 @@ function chooseDecision({ riskScore, integrity, memoryGuard, learnedPolicy, blas
1178
1178
  if (lowRiskHandoff) {
1179
1179
  return 'allow';
1180
1180
  }
1181
+ // Background customer-system actions checkpoint (warn), never hard-deny.
1182
+ // The checkpoint IS the mitigation — blocking outright prevents legitimate work.
1183
+ if (backgroundAgent && customerSystemAction) {
1184
+ return 'warn';
1185
+ }
1181
1186
  if (destructiveBypass || learnedHardStop || repeatedHighBlast || (hasOperationalBlockers && riskScore >= 0.72) || riskScore >= 0.86) {
1182
1187
  return 'deny';
1183
1188
  }
1184
- if (economicAction || (backgroundAgent && customerSystemAction) || (backgroundAgent && riskScore >= 0.3)) {
1189
+ if (economicAction || (backgroundAgent && riskScore >= 0.3)) {
1185
1190
  return 'warn';
1186
1191
  }
1187
1192
  if ((workflowControl && workflowControl.mode === 'warn') || (costControl && costControl.mode === 'warn') || riskScore >= 0.45 || (learnedWarning && riskScore >= 0.3) || (learnedRecall && riskScore >= 0.34)) {