thumbgate 1.27.19 → 1.27.20

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 (87) hide show
  1. package/.claude/commands/dashboard.md +15 -0
  2. package/.claude/commands/thumbgate-blocked.md +27 -0
  3. package/.claude/commands/thumbgate-dashboard.md +15 -0
  4. package/.claude/commands/thumbgate-doctor.md +30 -0
  5. package/.claude/commands/thumbgate-guard.md +36 -0
  6. package/.claude/commands/thumbgate-protect.md +30 -0
  7. package/.claude/commands/thumbgate-rules.md +30 -0
  8. package/.claude-plugin/plugin.json +1 -1
  9. package/.well-known/mcp/server-card.json +1 -1
  10. package/README.md +0 -3
  11. package/adapters/claude/.mcp.json +2 -2
  12. package/adapters/gcp/dfcx-webhook-gate.js +295 -0
  13. package/adapters/letta/README.md +41 -0
  14. package/adapters/letta/thumbgate-letta-adapter.js +133 -0
  15. package/adapters/mcp/server-stdio.js +1 -1
  16. package/adapters/opencode/opencode.json +1 -1
  17. package/adapters/policy-engine/ethicore-guardian-client.js +68 -0
  18. package/adapters/policy-engine/thumbgate-policy-engine-adapter.js +260 -0
  19. package/bench/observability-eval-suite.json +26 -0
  20. package/bin/cli.js +27 -1
  21. package/bin/dashboard-cli.js +7 -0
  22. package/bin/postinstall.js +14 -23
  23. package/commands/dashboard.md +15 -0
  24. package/commands/thumbgate-dashboard.md +15 -0
  25. package/package.json +225 -100
  26. package/public/about.html +162 -0
  27. package/public/agent-manager.html +179 -0
  28. package/public/agents-cost-savings.html +153 -0
  29. package/public/ai-malpractice-prevention.html +818 -0
  30. package/public/assets/brand/github-social-preview.png +0 -0
  31. package/public/assets/brand/thumbgate-icon-512.png +0 -0
  32. package/public/assets/brand/thumbgate-icon-pro-512.png +0 -0
  33. package/public/assets/brand/thumbgate-icon-team-512.png +0 -0
  34. package/public/assets/brand/thumbgate-logo-1200x360.png +0 -0
  35. package/public/assets/brand/thumbgate-logo-transparent.svg +28 -0
  36. package/public/assets/brand/thumbgate-mark-inline-v3.svg +18 -0
  37. package/public/assets/brand/thumbgate-mark-pro.svg +23 -0
  38. package/public/assets/brand/thumbgate-mark-team.svg +26 -0
  39. package/public/assets/brand/thumbgate-mark.svg +21 -0
  40. package/public/assets/brand/thumbgate-wordmark.svg +20 -0
  41. package/public/assets/claude-thumbgate-statusbar.svg +8 -0
  42. package/public/assets/codex-thumbgate-statusbar-test.svg +9 -0
  43. package/public/assets/legal-intake-control-flow.svg +66 -0
  44. package/public/brand/thumbgate-mark.svg +19 -0
  45. package/public/brand/thumbgate-og.svg +16 -0
  46. package/public/chatgpt-app.html +330 -0
  47. package/public/codex-enterprise.html +123 -0
  48. package/public/diagnostic.html +345 -0
  49. package/public/index.html +2 -2
  50. package/public/install.html +193 -0
  51. package/public/js/buyer-intent.js +672 -0
  52. package/public/numbers.html +2 -2
  53. package/public/pricing.html +399 -0
  54. package/scripts/action-receipts.js +324 -0
  55. package/scripts/activation-quickstart.js +187 -0
  56. package/scripts/agent-operations-planner.js +621 -0
  57. package/scripts/ai-component-inventory.js +367 -0
  58. package/scripts/async-eval-observability.js +236 -0
  59. package/scripts/audit.js +65 -0
  60. package/scripts/aws-blocks-guardrails.js +272 -0
  61. package/scripts/classifier-routing.js +130 -0
  62. package/scripts/dashboard-chat.js +332 -0
  63. package/scripts/feedback-aggregate.js +281 -0
  64. package/scripts/feedback-sanitizer.js +105 -0
  65. package/scripts/hook-stop-anti-claim.js +301 -0
  66. package/scripts/install-shim.js +87 -0
  67. package/scripts/mcp-oauth.js +293 -0
  68. package/scripts/noop-detect.js +285 -0
  69. package/scripts/parallel-workflow-orchestrator.js +293 -0
  70. package/scripts/plan-gate.js +243 -0
  71. package/scripts/plausible-domain-config.js +99 -0
  72. package/scripts/qa-scenario-planner.js +136 -0
  73. package/scripts/repeat-metric.js +137 -0
  74. package/scripts/secret-fixture-tokens.js +61 -0
  75. package/scripts/secret-redaction.js +166 -0
  76. package/scripts/self-harness-optimizer.js +141 -0
  77. package/scripts/self-healing-check.js +193 -0
  78. package/scripts/self-protection.js +90 -0
  79. package/scripts/silent-failure-cluster.js +531 -0
  80. package/scripts/statusline-cache-read.js +57 -0
  81. package/scripts/sync-telemetry-from-prod.js +374 -0
  82. package/scripts/tool-contract-validator.js +76 -0
  83. package/scripts/trajectory-scorer.js +63 -0
  84. package/scripts/verify-marketing-pages-deployed.js +212 -0
  85. package/scripts/visitor-journey.js +172 -0
  86. package/.claude-plugin/marketplace.json +0 -85
  87. package/adapters/chatgpt/openapi.yaml +0 -1707
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * mcp-oauth.js — OAuth 2.1 (PKCE) authorization-server machinery for the remote
6
+ * ThumbGate MCP connector, required by the Claude Connectors Directory
7
+ * ("Use OAuth 2.0 for authenticated services").
8
+ *
9
+ * Pure, dependency-free, IO-free functions + an injectable store, so the full
10
+ * flow is unit-testable without a network. The HTTP wiring (in src/api/server.js)
11
+ * is a thin shell over these.
12
+ *
13
+ * Implements:
14
+ * - RFC 9728 protected-resource metadata
15
+ * - RFC 8414 authorization-server metadata
16
+ * - RFC 7591 dynamic client registration (open registration)
17
+ * - Authorization-code grant with mandatory PKCE S256 (RFC 7636)
18
+ * - Bearer access tokens bound to a ThumbGate API key (so existing gating is unchanged)
19
+ *
20
+ * SECURITY NOTES:
21
+ * - PKCE S256 is mandatory; the plain method is rejected.
22
+ * - Auth codes are single-use and short-lived (60s); access tokens TTL-bound.
23
+ * - redirect_uri is matched exactly against the registered set.
24
+ * - The token never exposes the bound ThumbGate key; it maps server-side only.
25
+ */
26
+
27
+ const crypto = require('crypto');
28
+
29
+ const AUTH_CODE_TTL_MS = 60 * 1000; // 1 minute
30
+ const ACCESS_TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
31
+ const DEFAULT_SCOPE = 'mcp:read mcp:write';
32
+
33
+ // Upper bounds on the in-memory store. The registration and authorization
34
+ // endpoints are reachable pre-auth, so without a cap a malicious caller could
35
+ // grow these Maps unboundedly and exhaust server memory. When a Map is full we
36
+ // evict the oldest entry (FIFO) rather than deny service to legitimate clients.
37
+ const MAX_CLIENTS = 10000;
38
+ const MAX_CODES = 10000;
39
+ const MAX_TOKENS = 50000;
40
+
41
+ function now() {
42
+ return Date.now();
43
+ }
44
+
45
+ function randomToken(bytes = 32) {
46
+ return crypto.randomBytes(bytes).toString('base64url');
47
+ }
48
+
49
+ function base64UrlSha256(input) {
50
+ return crypto.createHash('sha256').update(String(input)).digest('base64url');
51
+ }
52
+
53
+ /**
54
+ * Create a fresh in-memory store.
55
+ *
56
+ * DURABILITY (known limitation): this uses plain Maps, so a process restart or a
57
+ * multi-instance / load-balanced deployment will drop issued tokens and
58
+ * registered clients (clients see 401s, in-flight authorizations break). For
59
+ * single-instance use this is fine; production multi-tenancy needs a durable,
60
+ * shared backing (Redis/DB) — tracked as the per-user-data-scoping follow-up.
61
+ * Entry counts are bounded (see MAX_* and capInsert) to prevent memory
62
+ * exhaustion from anonymous calls to the registration/authorization endpoints.
63
+ */
64
+ function createStore() {
65
+ return {
66
+ clients: new Map(),
67
+ codes: new Map(),
68
+ tokens: new Map(),
69
+ };
70
+ }
71
+
72
+ /** Insert into a Map, evicting the oldest entry (FIFO) once `max` is reached. */
73
+ function capInsert(map, key, value, max) {
74
+ if (map.size >= max && !map.has(key)) {
75
+ const oldest = map.keys().next().value;
76
+ if (oldest !== undefined) map.delete(oldest);
77
+ }
78
+ map.set(key, value);
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Metadata (RFC 9728 / RFC 8414)
83
+ // ---------------------------------------------------------------------------
84
+
85
+ function trimSlash(u) {
86
+ // Non-regex trailing-slash strip (avoids a SonarCloud S5852 false-positive on a
87
+ // provably-linear pattern).
88
+ let s = String(u || '');
89
+ while (s.endsWith('/')) s = s.slice(0, -1);
90
+ return s;
91
+ }
92
+
93
+ function buildProtectedResourceMetadata(baseUrl) {
94
+ const base = trimSlash(baseUrl);
95
+ return {
96
+ resource: `${base}/mcp`,
97
+ authorization_servers: [base],
98
+ bearer_methods_supported: ['header'],
99
+ scopes_supported: ['mcp:read', 'mcp:write'],
100
+ resource_documentation: `${base}/docs/connectors`,
101
+ };
102
+ }
103
+
104
+ function buildAuthServerMetadata(baseUrl) {
105
+ const base = trimSlash(baseUrl);
106
+ return {
107
+ issuer: base,
108
+ authorization_endpoint: `${base}/oauth/authorize`,
109
+ token_endpoint: `${base}/oauth/token`,
110
+ registration_endpoint: `${base}/oauth/register`,
111
+ scopes_supported: ['mcp:read', 'mcp:write'],
112
+ response_types_supported: ['code'],
113
+ grant_types_supported: ['authorization_code'],
114
+ code_challenge_methods_supported: ['S256'],
115
+ token_endpoint_auth_methods_supported: ['none'],
116
+ };
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Dynamic client registration (RFC 7591)
121
+ // ---------------------------------------------------------------------------
122
+
123
+ // The MCP authorization spec is explicit: "All redirect URIs MUST be either
124
+ // `localhost` or use HTTPS." We therefore accept only HTTPS and loopback
125
+ // (http://localhost | http://127.0.0.1) and reject every other scheme —
126
+ // including native-app custom schemes (myapp://, intent://, etc.), which the MCP
127
+ // profile does not sanction and the real client (Claude) does not use.
128
+ function isAllowedRedirectUri(uri) {
129
+ const u = String(uri || '');
130
+ if (/^https:\/\//i.test(u)) return true;
131
+ if (/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?(\/|$)/i.test(u)) return true;
132
+ return false;
133
+ }
134
+
135
+ function registerClient(store, body = {}) {
136
+ const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris.filter(Boolean) : [];
137
+ if (redirectUris.length === 0) {
138
+ return { error: 'invalid_redirect_uri', error_description: 'redirect_uris is required' };
139
+ }
140
+ for (const uri of redirectUris) {
141
+ if (!isAllowedRedirectUri(uri)) {
142
+ return { error: 'invalid_redirect_uri', error_description: `unsupported redirect_uri: ${uri}` };
143
+ }
144
+ }
145
+ const clientId = `tg_${randomToken(16)}`;
146
+ const record = {
147
+ client_id: clientId,
148
+ redirect_uris: redirectUris,
149
+ token_endpoint_auth_method: 'none',
150
+ grant_types: ['authorization_code'],
151
+ response_types: ['code'],
152
+ client_name: typeof body.client_name === 'string' ? body.client_name.slice(0, 200) : 'mcp-client',
153
+ created_at: now(),
154
+ };
155
+ capInsert(store.clients, clientId, record, MAX_CLIENTS);
156
+ return record;
157
+ }
158
+
159
+ function getClient(store, clientId) {
160
+ return store.clients.get(clientId) || null;
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Authorization code (PKCE S256)
165
+ // ---------------------------------------------------------------------------
166
+
167
+ /**
168
+ * Issue an auth code. `boundKey` is the ThumbGate API key the resulting access
169
+ * token will act as (resolved by the authorize step once the user consents).
170
+ */
171
+ function createAuthorizationCode(store, {
172
+ clientId, redirectUri, codeChallenge, codeChallengeMethod, scope, boundKey, state, resource,
173
+ } = {}) {
174
+ const client = getClient(store, clientId);
175
+ if (!client) return { error: 'invalid_client' };
176
+ if (!client.redirect_uris.includes(redirectUri)) return { error: 'invalid_request', error_description: 'redirect_uri mismatch' };
177
+ if (codeChallengeMethod !== 'S256') return { error: 'invalid_request', error_description: 'code_challenge_method must be S256' };
178
+ if (!codeChallenge || String(codeChallenge).length < 16) return { error: 'invalid_request', error_description: 'code_challenge required' };
179
+
180
+ const code = randomToken(24);
181
+ capInsert(store.codes, code, {
182
+ clientId,
183
+ redirectUri,
184
+ codeChallenge,
185
+ scope: scope || DEFAULT_SCOPE,
186
+ boundKey: boundKey || '',
187
+ resource: resource || '', // RFC 8707 resource indicator (the MCP server URL)
188
+ expiresAt: now() + AUTH_CODE_TTL_MS,
189
+ used: false,
190
+ }, MAX_CODES);
191
+ return { code, state };
192
+ }
193
+
194
+ function verifyPkce(codeChallenge, codeVerifier) {
195
+ if (!codeChallenge || !codeVerifier) return false;
196
+ // RFC 7636: verifier 43–128 chars.
197
+ if (String(codeVerifier).length < 43 || String(codeVerifier).length > 128) return false;
198
+ try {
199
+ return crypto.timingSafeEqual(
200
+ Buffer.from(base64UrlSha256(codeVerifier)),
201
+ Buffer.from(String(codeChallenge)),
202
+ );
203
+ } catch {
204
+ return false;
205
+ }
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Token exchange + validation
210
+ // ---------------------------------------------------------------------------
211
+
212
+ function exchangeCode(store, { code, codeVerifier, clientId, redirectUri, resource } = {}) {
213
+ const entry = store.codes.get(code);
214
+ if (!entry) return { error: 'invalid_grant', error_description: 'unknown code' };
215
+ // Single-use + expiry: consume regardless of outcome.
216
+ store.codes.delete(code);
217
+ if (entry.used) return { error: 'invalid_grant', error_description: 'code already used' };
218
+ if (now() > entry.expiresAt) return { error: 'invalid_grant', error_description: 'code expired' };
219
+ if (entry.clientId !== clientId) return { error: 'invalid_grant', error_description: 'client mismatch' };
220
+ if (entry.redirectUri !== redirectUri) return { error: 'invalid_grant', error_description: 'redirect_uri mismatch' };
221
+ if (!verifyPkce(entry.codeChallenge, codeVerifier)) return { error: 'invalid_grant', error_description: 'PKCE verification failed' };
222
+ // RFC 8707: the resource at token time must match the one bound at authorize time.
223
+ if (entry.resource && resource && entry.resource !== resource) {
224
+ return { error: 'invalid_target', error_description: 'resource indicator mismatch' };
225
+ }
226
+
227
+ const accessToken = `tgat_${randomToken(32)}`;
228
+ capInsert(store.tokens, accessToken, {
229
+ boundKey: entry.boundKey,
230
+ scope: entry.scope,
231
+ clientId,
232
+ aud: entry.resource || resource || '',
233
+ expiresAt: now() + ACCESS_TOKEN_TTL_MS,
234
+ }, MAX_TOKENS);
235
+ return {
236
+ access_token: accessToken,
237
+ token_type: 'Bearer',
238
+ expires_in: Math.floor(ACCESS_TOKEN_TTL_MS / 1000),
239
+ scope: entry.scope,
240
+ };
241
+ }
242
+
243
+ /** Resolve an access token to its session, or null if invalid/expired. */
244
+ function resolveAccessToken(store, token) {
245
+ if (!token) return null;
246
+ const entry = store.tokens.get(token);
247
+ if (!entry) return null;
248
+ if (now() > entry.expiresAt) {
249
+ store.tokens.delete(token);
250
+ return null;
251
+ }
252
+ return { boundKey: entry.boundKey, scope: entry.scope, clientId: entry.clientId, aud: entry.aud };
253
+ }
254
+
255
+ /**
256
+ * RFC 8707 audience validation: a token is valid for `expectedResource` only if it
257
+ * was issued for it (or carries no audience, for back-compat). MCP servers MUST
258
+ * reject tokens minted for a different resource.
259
+ */
260
+ function tokenAudienceValid(session, expectedResource) {
261
+ if (!session) return false;
262
+ if (!session.aud) return true; // no audience recorded — accept (back-compat)
263
+ return session.aud === expectedResource;
264
+ }
265
+
266
+ /** Best-effort GC of expired codes/tokens (call opportunistically). */
267
+ function pruneExpired(store) {
268
+ const t = now();
269
+ for (const [k, v] of store.codes) if (t > v.expiresAt) store.codes.delete(k);
270
+ for (const [k, v] of store.tokens) if (t > v.expiresAt) store.tokens.delete(k);
271
+ }
272
+
273
+ module.exports = {
274
+ createStore,
275
+ buildProtectedResourceMetadata,
276
+ buildAuthServerMetadata,
277
+ registerClient,
278
+ getClient,
279
+ createAuthorizationCode,
280
+ verifyPkce,
281
+ exchangeCode,
282
+ resolveAccessToken,
283
+ tokenAudienceValid,
284
+ pruneExpired,
285
+ isAllowedRedirectUri,
286
+ base64UrlSha256,
287
+ AUTH_CODE_TTL_MS,
288
+ ACCESS_TOKEN_TTL_MS,
289
+ DEFAULT_SCOPE,
290
+ MAX_CLIENTS,
291
+ MAX_CODES,
292
+ MAX_TOKENS,
293
+ };
@@ -0,0 +1,285 @@
1
+ 'use strict';
2
+
3
+ // scripts/noop-detect.js
4
+ //
5
+ // No-op / redundant-action detection for ThumbGate.
6
+ //
7
+ // Given an action's pre/post state (file diff, command exit code + output),
8
+ // this module decides whether the action actually changed anything OR whether
9
+ // it is byte-identical to an attempt already seen this session. Both are strong,
10
+ // cheap "the agent is looping" repeat signals that plug into
11
+ // track_action -> prevention_rules.
12
+ //
13
+ // Design goals:
14
+ // * Pure / file-local. No edits to shared modules from inside here.
15
+ // * Normalize volatile fields (timestamps, shas, ANSI, trailing whitespace)
16
+ // before hashing so non-deterministic output does not defeat detection.
17
+ // * Guard partial writes: a truncated after-state that is a strict prefix of
18
+ // the before-state must NOT be reported as a no-op.
19
+ //
20
+ // Persistence lives beside session-actions.json (derived from
21
+ // gates-engine.SESSION_ACTIONS_PATH) so it picks up THUMBGATE_STATE_DIR
22
+ // overrides and shares the same TTL semantics.
23
+
24
+ const crypto = require('crypto');
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+
28
+ const gatesEngine = require('./gates-engine');
29
+
30
+ // Match the same 1h session window the engine uses for session actions.
31
+ const ATTEMPT_TTL_MS = 60 * 60 * 1000;
32
+
33
+ // -- volatile-field normalization ------------------------------------------
34
+ //
35
+ // Each pattern strips a class of non-deterministic noise so that two outputs
36
+ // which differ only by a timestamp / sha / uuid / ANSI color / trailing
37
+ // whitespace hash-equal. Exported so the test suite can assert the contract.
38
+ const VOLATILE_PATTERNS = [
39
+ // ANSI escape / color codes (e.g. \x1b[0m). Strip first so later patterns
40
+ // see clean text.
41
+ { name: 'ansi', re: /\x1b\[[0-9;]*[A-Za-z]/g, replacement: '' },
42
+ // ISO-8601 timestamps: 2026-05-31T12:34:56(.123)?(Z|+00:00)
43
+ {
44
+ name: 'iso8601',
45
+ re: /\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?/g,
46
+ replacement: '<TS>',
47
+ },
48
+ // UUIDs (dashed form) — must run before the hexblob pattern, otherwise the
49
+ // trailing 12-char hex segment gets rewritten first and the UUID never matches.
50
+ {
51
+ name: 'uuid',
52
+ re: /\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/g,
53
+ replacement: '<UUID>',
54
+ },
55
+ // Epoch integers wider than 10 digits (ms/ns timestamps) — run before the
56
+ // hexblob pattern so all-decimal epochs are not swallowed as hex first.
57
+ { name: 'epoch', re: /\b\d{11,}\b/g, replacement: '<EPOCH>' },
58
+ // Long hex blobs (commit shas, uuids without dashes, content hashes): 12+ hex chars.
59
+ { name: 'hexblob', re: /\b[0-9a-fA-F]{12,}\b/g, replacement: '<HEX>' },
60
+ // Trailing whitespace is stripped in normalizeVolatile() via a bounded linear
61
+ // scan (not a regex) to avoid super-linear backtracking on adversarial input.
62
+ ];
63
+
64
+ // Strip trailing spaces/tabs from a single line via a bounded reverse scan.
65
+ // Linear in line length; replaces /[ \t]+$/ without any regex backtracking.
66
+ function stripTrailingSpaceTab(line) {
67
+ let end = line.length;
68
+ while (end > 0) {
69
+ const c = line.charCodeAt(end - 1);
70
+ if (c !== 32 && c !== 9) break; // 32 = space, 9 = tab
71
+ end -= 1;
72
+ }
73
+ return line.slice(0, end);
74
+ }
75
+
76
+ // Normalize a string by applying every volatile pattern, then trimming a
77
+ // trailing newline so "foo\n" and "foo" are equivalent.
78
+ function normalizeVolatile(value) {
79
+ if (value === null || value === undefined) return '';
80
+ let out = String(value);
81
+ for (const pattern of VOLATILE_PATTERNS) {
82
+ out = out.replace(pattern.re, pattern.replacement);
83
+ }
84
+ // Strip trailing space/tab per line without a backtracking-prone regex.
85
+ out = out.split('\n').map(stripTrailingSpaceTab).join('\n');
86
+ // Normalize CRLF, then strip the trailing run of newlines via a bounded
87
+ // linear scan (replaces /\n+$/ without regex backtracking).
88
+ out = out.replace(/\r\n/g, '\n');
89
+ let end = out.length;
90
+ while (end > 0 && out.charCodeAt(end - 1) === 10) end -= 1; // 10 = \n
91
+ return out.slice(0, end);
92
+ }
93
+
94
+ function sha256(value) {
95
+ return crypto.createHash('sha256').update(value, 'utf8').digest('hex');
96
+ }
97
+
98
+ // -- state hashing ----------------------------------------------------------
99
+ //
100
+ // computeActionStateHash produces a single stable fingerprint for an action's
101
+ // observable state. For file actions the fingerprint is the normalized content;
102
+ // for command actions it is exit code + normalized stdout/stderr.
103
+ function computeActionStateHash(action = {}) {
104
+ const kind = action.kind === 'command' ? 'command' : 'file';
105
+
106
+ if (kind === 'command') {
107
+ const exitCode = action.exitCode === undefined || action.exitCode === null
108
+ ? ''
109
+ : String(action.exitCode);
110
+ const parts = [
111
+ 'command',
112
+ `exit:${exitCode}`,
113
+ `stdout:${normalizeVolatile(action.stdout)}`,
114
+ `stderr:${normalizeVolatile(action.stderr)}`,
115
+ ];
116
+ return sha256(parts.join('\x00'));
117
+ }
118
+
119
+ // file kind: hash the relevant content (after-content is the canonical state
120
+ // for a single snapshot; for diff comparisons detectNoop compares before/after
121
+ // separately). Include filePath so two unrelated files never collide.
122
+ const content = action.afterContent !== undefined && action.afterContent !== null
123
+ ? action.afterContent
124
+ : action.beforeContent;
125
+ const parts = [
126
+ 'file',
127
+ `path:${action.filePath || ''}`,
128
+ `content:${normalizeVolatile(content)}`,
129
+ ];
130
+ return sha256(parts.join('\x00'));
131
+ }
132
+
133
+ // Hash just a content blob for before/after comparison (no path/kind framing).
134
+ function contentHash(content) {
135
+ return sha256(normalizeVolatile(content));
136
+ }
137
+
138
+ // -- no-op detection --------------------------------------------------------
139
+ //
140
+ // detectNoop({ before, after }) returns { noop, reason }.
141
+ // before/after: { kind, filePath, beforeContent/afterContent OR content,
142
+ // exitCode, stdout, stderr }
143
+ //
144
+ // We accept a flexible shape: the caller may pass a single action object with
145
+ // beforeContent/afterContent, or split before/after sub-objects. Both forms
146
+ // resolve to the same decision.
147
+ function detectNoop(input = {}) {
148
+ const before = input.before || {};
149
+ const after = input.after || {};
150
+
151
+ // Determine kind from whichever side declares it (default file).
152
+ const kind = (before.kind || after.kind || input.kind) === 'command'
153
+ ? 'command'
154
+ : 'file';
155
+
156
+ if (kind === 'command') {
157
+ const beforeExit = before.exitCode;
158
+ const afterExit = after.exitCode;
159
+ if (beforeExit !== undefined && afterExit !== undefined && beforeExit !== afterExit) {
160
+ return { noop: false, reason: 'exit-code-changed' };
161
+ }
162
+ const beforeOut = contentHash(`${normalizeVolatile(before.stdout)}\x00${normalizeVolatile(before.stderr)}`);
163
+ const afterOut = contentHash(`${normalizeVolatile(after.stdout)}\x00${normalizeVolatile(after.stderr)}`);
164
+ if (beforeOut === afterOut) {
165
+ return { noop: true, reason: 'command-output-unchanged' };
166
+ }
167
+ return { noop: false, reason: 'command-output-changed' };
168
+ }
169
+
170
+ // file kind.
171
+ const beforeContent = before.content !== undefined ? before.content
172
+ : (before.beforeContent !== undefined ? before.beforeContent : input.beforeContent);
173
+ const afterContent = after.content !== undefined ? after.content
174
+ : (after.afterContent !== undefined ? after.afterContent : input.afterContent);
175
+
176
+ const beforeStr = beforeContent === undefined || beforeContent === null ? '' : String(beforeContent);
177
+ const afterStr = afterContent === undefined || afterContent === null ? '' : String(afterContent);
178
+
179
+ // Partial-write guard: an after-state that is a strict, shorter prefix of the
180
+ // before-state is a truncation, not a no-op — even though hashes differ, we
181
+ // make the intent explicit so callers never mistake it.
182
+ if (afterStr.length > 0 && afterStr.length < beforeStr.length && beforeStr.startsWith(afterStr)) {
183
+ return { noop: false, reason: 'partial-write-truncation' };
184
+ }
185
+
186
+ if (contentHash(beforeStr) === contentHash(afterStr)) {
187
+ return { noop: true, reason: 'file-content-unchanged' };
188
+ }
189
+ return { noop: false, reason: 'file-content-changed' };
190
+ }
191
+
192
+ // -- attempt persistence ----------------------------------------------------
193
+ //
194
+ // Stores (sessionId -> { "actionId\x00stateHash": timestamp }) so a repeated
195
+ // (actionId, stateHash) tuple within the TTL window is a strong repeat signal.
196
+ function attemptsPath() {
197
+ const sessionActionsPath = gatesEngine.SESSION_ACTIONS_PATH;
198
+ return path.join(path.dirname(sessionActionsPath), 'noop-attempts.json');
199
+ }
200
+
201
+ function loadAttempts() {
202
+ try {
203
+ const raw = fs.readFileSync(attemptsPath(), 'utf8');
204
+ const parsed = JSON.parse(raw);
205
+ if (!parsed || typeof parsed !== 'object') return {};
206
+ return parsed;
207
+ } catch (e) {
208
+ return {};
209
+ }
210
+ }
211
+
212
+ function pruneExpired(store, now) {
213
+ let changed = false;
214
+ for (const sessionId of Object.keys(store)) {
215
+ const bucket = store[sessionId];
216
+ if (!bucket || typeof bucket !== 'object') {
217
+ delete store[sessionId];
218
+ changed = true;
219
+ continue;
220
+ }
221
+ for (const key of Object.keys(bucket)) {
222
+ const ts = bucket[key];
223
+ if (typeof ts !== 'number' || now - ts >= ATTEMPT_TTL_MS) {
224
+ delete bucket[key];
225
+ changed = true;
226
+ }
227
+ }
228
+ if (Object.keys(bucket).length === 0) {
229
+ delete store[sessionId];
230
+ changed = true;
231
+ }
232
+ }
233
+ return changed;
234
+ }
235
+
236
+ function saveAttempts(store) {
237
+ const file = attemptsPath();
238
+ fs.mkdirSync(path.dirname(file), { recursive: true });
239
+ fs.writeFileSync(file, JSON.stringify(store, null, 2) + '\n');
240
+ }
241
+
242
+ function attemptKey(actionId, stateHash) {
243
+ return `${String(actionId)}\x00${String(stateHash)}`;
244
+ }
245
+
246
+ // recordActionAttempt persists that (sessionId, actionId, stateHash) was seen.
247
+ // Returns { recorded: boolean, alreadySeen: boolean }.
248
+ function recordActionAttempt(sessionId, actionId, stateHash) {
249
+ const sid = String(sessionId || 'default');
250
+ const now = Date.now();
251
+ const store = loadAttempts();
252
+ pruneExpired(store, now);
253
+
254
+ if (!store[sid] || typeof store[sid] !== 'object') store[sid] = {};
255
+ const key = attemptKey(actionId, stateHash);
256
+ const alreadySeen = Object.prototype.hasOwnProperty.call(store[sid], key);
257
+ store[sid][key] = now;
258
+ saveAttempts(store);
259
+ return { recorded: true, alreadySeen };
260
+ }
261
+
262
+ // isRepeatAttempt returns true when this exact (actionId, stateHash) tuple was
263
+ // already recorded for this session within the TTL window.
264
+ function isRepeatAttempt(sessionId, actionId, stateHash) {
265
+ const sid = String(sessionId || 'default');
266
+ const now = Date.now();
267
+ const store = loadAttempts();
268
+ const bucket = store[sid];
269
+ if (!bucket || typeof bucket !== 'object') return false;
270
+ const ts = bucket[attemptKey(actionId, stateHash)];
271
+ if (typeof ts !== 'number') return false;
272
+ return now - ts < ATTEMPT_TTL_MS;
273
+ }
274
+
275
+ module.exports = {
276
+ VOLATILE_PATTERNS,
277
+ ATTEMPT_TTL_MS,
278
+ normalizeVolatile,
279
+ computeActionStateHash,
280
+ detectNoop,
281
+ recordActionAttempt,
282
+ isRepeatAttempt,
283
+ // Exposed for tests / introspection.
284
+ attemptsPath,
285
+ };