thumbgate 1.35.0 → 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.
- package/.agents/skills/cyberstrike-compare-not-clone/SKILL.md +36 -0
- package/.agents/skills/gitlab-sandbox-allowlist-not-trust/SKILL.md +77 -0
- package/.agents/skills/jit-harness-compare-not-clone/SKILL.md +34 -0
- package/.agents/skills/openui-catalog-compose-honesty/SKILL.md +64 -0
- package/.agents/skills/zvec-grep-compare-not-clone/SKILL.md +34 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/llms.txt +1 -0
- package/.well-known/mcp/server-card.json +1 -1
- package/CONTRIBUTING.md +95 -0
- package/README.md +195 -632
- package/THIRD_PARTY_NOTICES.md +89 -0
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/forge/forge.yaml +3 -3
- package/adapters/future-agi/.mcp.json +8 -0
- package/adapters/future-agi/FUTURE_AGI.md +23 -0
- package/adapters/future-agi/config.toml +3 -0
- package/adapters/future-agi/future-agi-bridge.js +9 -0
- package/adapters/future-agi/opencode.json +8 -0
- package/adapters/herdr/herdr-plugin.toml +18 -0
- package/adapters/mcp/server-stdio.js +238 -25
- package/adapters/opencode/opencode.json +1 -1
- package/adapters/workos/WORKOS.md +52 -0
- package/bin/cli.js +336 -3
- package/bin/futureagi-bridge +9 -0
- package/config/gate-templates.json +581 -4
- package/config/gates/actor-critic-audit.json +34 -0
- package/config/gates/default.json +9 -3
- package/config/gates/five-walls-governance.json +34 -0
- package/config/gates/future-agi-guardrails.json +34 -0
- package/config/gates/radware-threat-defense-2026.json +61 -0
- package/config/gates/simatree-data-governance.json +33 -0
- package/config/mcp-allowlists.json +4 -0
- package/config/merge-quality-checks.json +6 -0
- package/config/model-candidates.json +312 -29
- package/config/model-tiers.json +18 -0
- package/config/post-deploy-marketing-pages.json +10 -0
- package/config/progressive/01-wire-only.json +11 -0
- package/config/progressive/02-dashboard-empty-ok.json +10 -0
- package/config/progressive/03-one-lesson.json +10 -0
- package/config/progressive/04-warn-fires.json +11 -0
- package/config/progressive/05-strict-optional.json +11 -0
- package/config/progressive/README.md +15 -0
- package/config/schemas/broker-execution-receipt.schema.json +139 -0
- package/config/schemas/provider-execution-attestation-v1.schema.json +58 -0
- package/conformance/provider-attestation/vectors.json +320 -0
- package/docs/specs/provider-execution-attestation-v1.md +69 -0
- package/openapi/openapi.yaml +15 -0
- package/package.json +390 -148
- package/public/about.html +2 -2
- package/public/ai-malpractice-prevention.html +7 -7
- package/public/blog/a-10-dollar-vps-is-not-a-computer.html +143 -0
- package/public/blog/a-receipt-is-not-world-state.html +388 -0
- package/public/blog/git-at-agent-scale.html +374 -0
- package/public/blog/no-llm-in-the-gate.html +133 -0
- package/public/blog.html +80 -0
- package/public/case-studies.html +16 -1
- package/public/compare.html +28 -0
- package/public/diagnostic.html +216 -7
- package/public/docs/connectors.html +39 -0
- package/public/federal.html +2 -2
- package/public/founders.html +639 -0
- package/public/index.html +87 -9
- package/public/install.html +8 -8
- package/public/learn.html +39 -0
- package/public/numbers.html +2 -2
- package/public/peter.html +310 -0
- package/public/platform-partners.html +119 -0
- package/public/pricing.html +24 -3
- package/public/privacy.html +117 -0
- package/public/pro.html +17 -0
- package/public/support.html +62 -0
- package/public/terms.html +130 -0
- package/public/third-party-notices.html +95 -0
- package/public/yt.html +351 -0
- package/scripts/action-receipts.js +133 -3
- package/scripts/adaptive-governance-arena.js +349 -0
- package/scripts/admin-override.js +205 -0
- package/scripts/agent-action-inventory.js +869 -0
- package/scripts/agent-audit-trace.js +42 -2
- package/scripts/agent-egress-policy.js +1117 -0
- package/scripts/agent-memory-lifecycle.js +141 -2
- package/scripts/agent-operations-planner.js +441 -1
- package/scripts/agent-readiness.js +68 -0
- package/scripts/agent-security-central.js +647 -0
- package/scripts/allowlist-bridge-honesty.js +417 -0
- package/scripts/async-job-runner.js +102 -11
- package/scripts/audit-trail.js +212 -0
- package/scripts/billing.js +1 -1
- package/scripts/broker-execution-receipts.js +719 -0
- package/scripts/budget-aware-gates-proof.js +423 -0
- package/scripts/claude-feedback-sync.js +29 -3
- package/scripts/claw-harness-production.js +237 -0
- package/scripts/cli-schema.js +163 -1
- package/scripts/codex-runbook-flywheel.js +318 -0
- package/scripts/context-footprint.js +186 -0
- package/scripts/contextfs.js +143 -61
- package/scripts/dashboard.js +251 -32
- package/scripts/deepseek-v4-runtime-guardrails.js +72 -6
- package/scripts/docker-sandbox-planner.js +18 -0
- package/scripts/double-blind-eval-protocol.js +252 -0
- package/scripts/edotenv-rl-gateway.js +259 -0
- package/scripts/ensure-production-search-corpus.js +162 -0
- package/scripts/eval-holdout.js +311 -0
- package/scripts/feedback-aggregate.js +21 -2
- package/scripts/feedback-loop.js +87 -5
- package/scripts/feedback-quality.js +9 -0
- package/scripts/file-ledger-lock.js +4 -1
- package/scripts/financial-control-plane.js +41 -1
- package/scripts/find-dormant-requires.js +118 -0
- package/scripts/fs-utils.js +84 -8
- package/scripts/gates-engine.js +810 -63
- package/scripts/generate-case-study-outreach.js +24 -15
- package/scripts/git-at-scale.js +628 -0
- package/scripts/governance-conflict-audit.js +1650 -0
- package/scripts/governance-difficulty-curriculum.js +328 -0
- package/scripts/graphrag-retrieval.js +275 -0
- package/scripts/gurobi-optimizer.js +324 -0
- package/scripts/gurobi_optimizer.py +485 -0
- package/scripts/harness-selector.js +82 -1
- package/scripts/hidden-entry-points.js +284 -0
- package/scripts/human-escalation.js +199 -1
- package/scripts/hybrid-feedback-context.js +152 -19
- package/scripts/intent-governed-execution.js +602 -0
- package/scripts/intervention-policy.js +123 -20
- package/scripts/jit-harness-compose.js +628 -0
- package/scripts/jsonl-watcher.js +10 -0
- package/scripts/lesson-embedding-index.js +95 -12
- package/scripts/lesson-retrieval.js +105 -19
- package/scripts/local-model-profile.js +19 -2
- package/scripts/mailer/resend-mailer.js +1 -1
- package/scripts/matryoshka-embedding.js +235 -0
- package/scripts/mcp-oauth.js +42 -4
- package/scripts/mcp-session-handles.js +1016 -0
- package/scripts/mcp-wiring-doctor.js +314 -0
- package/scripts/memory-firewall.js +115 -2
- package/scripts/memory-scope-readiness.js +299 -0
- package/scripts/memory-vs-rag-route.js +161 -0
- package/scripts/model-tier-router.js +148 -21
- package/scripts/nvidia-specdecode-al-doctor.js +536 -0
- package/scripts/openui-catalog-compose-honesty.js +593 -0
- package/scripts/operational-integrity.js +19 -1
- package/scripts/override-audit.js +213 -0
- package/scripts/package-manager-honesty-doctor.js +458 -0
- package/scripts/pr-manager.js +63 -1
- package/scripts/prove-herdr-adapter.js +52 -0
- package/scripts/prove-memory-pyramid-and-symbolic-canvas.js +95 -0
- package/scripts/prove-workos.js +73 -0
- package/scripts/provider-attestation-conformance.js +192 -0
- package/scripts/provider-receipt-contract.js +136 -0
- package/scripts/qwen38-max-cost-optimizer.js +401 -0
- package/scripts/radware-threat-defense.js +280 -0
- package/scripts/rag-embedding-identity.js +221 -0
- package/scripts/rag-precision-guardrails.js +112 -2
- package/scripts/remote-feedback-capture.js +159 -0
- package/scripts/research-agent-harness.js +256 -0
- package/scripts/rsi-safety-hillclimb.js +200 -0
- package/scripts/rule-sprawl.js +188 -0
- package/scripts/schedule-manager.js +147 -0
- package/scripts/self-heal.js +8 -0
- package/scripts/session-lease.js +415 -0
- package/scripts/simatree-data-governance.js +347 -0
- package/scripts/slo-alert-engine.js +172 -7
- package/scripts/solver-parity.js +539 -0
- package/scripts/stealth-memory-injection-gate.js +333 -0
- package/scripts/switchyard-router.js +366 -0
- package/scripts/telemetry-analytics.js +84 -27
- package/scripts/temporal-decay-weighting.js +138 -0
- package/scripts/test-all.js +165 -0
- package/scripts/token-savings.js +42 -0
- package/scripts/tool-kpi-tracker.js +108 -5
- package/scripts/tool-registry.js +193 -5
- package/scripts/universal-claim-evaluator.js +14 -2
- package/scripts/vector-store.js +279 -9
- package/scripts/workflow-notebook.js +391 -0
- package/scripts/workflow-sentinel.js +111 -12
- package/scripts/workos-production-guard.js +260 -0
- package/scripts/workspace-search-route.js +515 -0
- package/server.json +2 -2
- package/src/agent-identity-boundary.js +76 -0
- package/src/agent-retrieval-cache.js +155 -0
- package/src/alert-noise-ledger.js +502 -0
- package/src/api/server.js +724 -153
- package/src/git-fast-cache.js +220 -0
- package/src/git-wal-sync.js +156 -0
- package/src/hash-anchored-edit.js +82 -0
- package/src/hermes-platform-protocol.js +475 -0
- package/src/hermes-sync-plane.js +241 -0
- package/src/index.js +30 -1
- package/src/iso42001-compliance-guard.js +97 -0
- package/src/latency-budget.js +244 -0
- package/src/mcp-writeguard.js +316 -0
- package/src/miminions-adapter.js +106 -0
- package/src/pipeline-compass.js +104 -0
- package/src/ppl-alert-pipeline.js +284 -0
- package/src/rendezvous-router.js +90 -0
- package/src/security-questionnaire.js +195 -0
package/scripts/gates-engine.js
CHANGED
|
@@ -19,6 +19,9 @@ const {
|
|
|
19
19
|
const {
|
|
20
20
|
evaluateFinancialControl,
|
|
21
21
|
} = require('./financial-control-plane');
|
|
22
|
+
const {
|
|
23
|
+
evaluateBrokerReceiptGate,
|
|
24
|
+
} = require('./broker-execution-receipts');
|
|
22
25
|
const {
|
|
23
26
|
buildCostControl,
|
|
24
27
|
normalizeProviderAction,
|
|
@@ -72,12 +75,14 @@ const {
|
|
|
72
75
|
evaluateSecurityScan,
|
|
73
76
|
} = require('./security-scanner');
|
|
74
77
|
const { evaluatePlanGate } = require('./plan-gate');
|
|
78
|
+
const { evaluateStealthMemoryInjection } = require('./stealth-memory-injection-gate');
|
|
75
79
|
const { getTrajectoryScore } = require('./trajectory-scorer');
|
|
76
80
|
const { evaluateSequenceState } = loadOptionalModule('./sequence-guard', () => ({
|
|
77
81
|
evaluateSequenceState: () => null,
|
|
78
82
|
}));
|
|
79
83
|
const { getAutoGatesPath } = require('./auto-promote-gates');
|
|
80
84
|
const { recordAuditEvent, auditToFeedback } = require('./audit-trail');
|
|
85
|
+
const { consumeVerifiedApproval, listEscalations, requestEscalation } = require('./human-escalation');
|
|
81
86
|
|
|
82
87
|
const DEFAULT_CONFIG_PATH = path.join(__dirname, '..', 'config', 'gates', 'default.json');
|
|
83
88
|
const DEFAULT_CLAIM_GATES_PATH = path.join(__dirname, '..', 'config', 'gates', 'claim-verification.json');
|
|
@@ -103,9 +108,22 @@ const STATS_PATH = path.join(STATE_DIR, 'gate-stats.json');
|
|
|
103
108
|
const SESSION_ACTIONS_PATH = path.join(STATE_DIR, 'session-actions.json');
|
|
104
109
|
const CUSTOM_CLAIM_GATES_PATH = path.join(STATE_DIR, 'claim-verification.json');
|
|
105
110
|
const GOVERNANCE_STATE_PATH = path.join(STATE_DIR, 'governance-state.json');
|
|
111
|
+
const REMEDY_TOOL_NAMES = new Set([
|
|
112
|
+
'satisfy_gate',
|
|
113
|
+
'capture_feedback',
|
|
114
|
+
'capture_memory_feedback',
|
|
115
|
+
'record_task_outcome',
|
|
116
|
+
'diagnose_failure',
|
|
117
|
+
'set_task_scope',
|
|
118
|
+
'approve_protected_action',
|
|
119
|
+
'track_action',
|
|
120
|
+
'verify_claim',
|
|
121
|
+
'break_glass_emergency',
|
|
122
|
+
]);
|
|
106
123
|
const TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
107
124
|
const SESSION_ACTION_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
108
125
|
const PROTECTED_APPROVAL_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
126
|
+
const DEFAULT_ADMIN_OVERRIDE_TTL_MS = 15 * 60 * 1000; // 15 minutes
|
|
109
127
|
const DEFAULT_PROTECTED_FILE_GLOBS = [
|
|
110
128
|
'AGENTS.md',
|
|
111
129
|
'CLAUDE.md',
|
|
@@ -168,8 +186,9 @@ const UNCONDITIONAL_HARD_FLOOR_GATE_IDS = new Set([
|
|
|
168
186
|
// applyEnforcementPosture, never demote spend blocks to warn-by-default.
|
|
169
187
|
// Apollo $588 incident class (2026-08).
|
|
170
188
|
'financial-control',
|
|
171
|
-
// Outbound email: irreversible delivery.
|
|
172
|
-
//
|
|
189
|
+
// Outbound email: irreversible delivery. Keep the hard gate intact until a
|
|
190
|
+
// separately authenticated admin override bound to the exact action digest
|
|
191
|
+
// authorizes one retry (AGENT-259 / District Cyber 2026-08-04).
|
|
173
192
|
'outbound-email-send',
|
|
174
193
|
TASK_SCOPE_LEASE_EXPIRED_GATE_ID,
|
|
175
194
|
...SELF_PROTECT_HARD_FLOOR_GATE_IDS,
|
|
@@ -191,7 +210,7 @@ const CATASTROPHIC_DECLARATIVE_GATE_IDS = new Set([
|
|
|
191
210
|
'git-clean-force',
|
|
192
211
|
'rm-rf-home-or-root',
|
|
193
212
|
'financial-control',
|
|
194
|
-
// Never daily-cap discount
|
|
213
|
+
// Never daily-cap discount a denied autonomous email send (same class as force-push).
|
|
195
214
|
'outbound-email-send',
|
|
196
215
|
]);
|
|
197
216
|
const SELF_PROTECT_CONFIG_TARGET_PATTERN = /(?:^|\/)(?:config\/gates\/|config\/(?:budget|enforcement|mcp-allowlists)\.json$|\.thumbgate\/config\.json$|thumbgate\.json$)/i;
|
|
@@ -204,21 +223,76 @@ function isSelfProtectGate(gateId) {
|
|
|
204
223
|
return SELF_PROTECT_HARD_FLOOR_GATE_IDS.has(gateId);
|
|
205
224
|
}
|
|
206
225
|
|
|
226
|
+
/**
|
|
227
|
+
* Name the existing warn-by-default vs strict postures in Trustwise Control Tower
|
|
228
|
+
* vocabulary (live / sidecar / simulation / batch) without cloning their product.
|
|
229
|
+
* Default remains sidecar (= warn-by-default). Hard floors never demote.
|
|
230
|
+
*/
|
|
231
|
+
function resolveGovernanceMode(env = process.env) {
|
|
232
|
+
// Strict opt-in wins over inherited simulation/batch. Hermes CLI sets
|
|
233
|
+
// THUMBGATE_STRICT_ENFORCEMENT=1 internally; a leftover shadow mode in the
|
|
234
|
+
// process env must not demote those denials.
|
|
235
|
+
if (env.THUMBGATE_STRICT_ENFORCEMENT === '1') return 'live';
|
|
236
|
+
const raw = String(env.THUMBGATE_GOVERNANCE_MODE || '').trim().toLowerCase();
|
|
237
|
+
if (raw === 'simulation' || raw === 'batch') return raw;
|
|
238
|
+
if (raw === 'live') return 'live';
|
|
239
|
+
return 'sidecar';
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function alignmentLayerForResult(result) {
|
|
243
|
+
const gate = String(result && result.gate || '');
|
|
244
|
+
if (
|
|
245
|
+
UNCONDITIONAL_HARD_FLOOR_GATE_IDS.has(gate)
|
|
246
|
+
|| CATASTROPHIC_DECLARATIVE_GATE_IDS.has(gate)
|
|
247
|
+
|| /secret|self-protect|exfil/i.test(gate)
|
|
248
|
+
) {
|
|
249
|
+
return 'safety';
|
|
250
|
+
}
|
|
251
|
+
if (/task-scope|local-only|pr_thread|sla/i.test(gate)) return 'sla';
|
|
252
|
+
return 'business';
|
|
253
|
+
}
|
|
254
|
+
|
|
207
255
|
function applyEnforcementPosture(result) {
|
|
208
256
|
if (!result || (result.decision !== 'deny' && result.decision !== 'approve')) return result;
|
|
257
|
+
const mode = resolveGovernanceMode();
|
|
258
|
+
const alignmentLayer = alignmentLayerForResult(result);
|
|
209
259
|
// Defensive backstop: hard-floor results must never be posture-downgraded.
|
|
210
|
-
if (UNCONDITIONAL_HARD_FLOOR_GATE_IDS.has(result.gate))
|
|
260
|
+
if (UNCONDITIONAL_HARD_FLOOR_GATE_IDS.has(result.gate)) {
|
|
261
|
+
return { ...result, governanceMode: mode, alignmentLayer };
|
|
262
|
+
}
|
|
263
|
+
// Simulation/batch shadow live traffic: record, do not block (except floors).
|
|
264
|
+
// Only denials are shadowed — allow/approve stay as-is.
|
|
265
|
+
if (mode === 'simulation' || mode === 'batch') {
|
|
266
|
+
if (result.decision === 'deny') {
|
|
267
|
+
return {
|
|
268
|
+
...result,
|
|
269
|
+
decision: 'warn',
|
|
270
|
+
warnByDefault: true,
|
|
271
|
+
governanceMode: mode,
|
|
272
|
+
alignmentLayer,
|
|
273
|
+
simulated: true,
|
|
274
|
+
message: `${result.message}\n\n⚠️ ThumbGate governance mode=${mode} — flagged and logged, not blocked.`,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
return { ...result, governanceMode: mode, alignmentLayer, simulated: true };
|
|
278
|
+
}
|
|
211
279
|
// Full hard enforcement opt-in: keep every deny.
|
|
212
|
-
if (process.env.THUMBGATE_STRICT_ENFORCEMENT === '1')
|
|
280
|
+
if (mode === 'live' || process.env.THUMBGATE_STRICT_ENFORCEMENT === '1') {
|
|
281
|
+
return { ...result, governanceMode: 'live', alignmentLayer };
|
|
282
|
+
}
|
|
213
283
|
// Honor the explicit strict-knowledge-conflict opt-in for that gate.
|
|
214
|
-
if (process.env.THUMBGATE_STRICT_KNOWLEDGE_CONFLICT === '1' && result.gate === 'knowledge-conflict-gate')
|
|
215
|
-
|
|
284
|
+
if (process.env.THUMBGATE_STRICT_KNOWLEDGE_CONFLICT === '1' && result.gate === 'knowledge-conflict-gate') {
|
|
285
|
+
return { ...result, governanceMode: mode, alignmentLayer };
|
|
286
|
+
}
|
|
287
|
+
// Sidecar / warn-by-default: the gate still fired and is recorded; the action is allowed through
|
|
216
288
|
// with the warning surfaced instead of hard-blocked, so legitimate work is never blocked.
|
|
217
289
|
return {
|
|
218
290
|
...result,
|
|
219
291
|
decision: 'warn',
|
|
220
292
|
warnByDefault: true,
|
|
221
|
-
|
|
293
|
+
governanceMode: 'sidecar',
|
|
294
|
+
alignmentLayer,
|
|
295
|
+
message: `${result.message}\n\n⚠️ ThumbGate is in warn-by-default mode (sidecar) — this was flagged and logged, not blocked. Set THUMBGATE_STRICT_ENFORCEMENT=1 or THUMBGATE_GOVERNANCE_MODE=live to hard-block other flagged actions.`,
|
|
222
296
|
};
|
|
223
297
|
}
|
|
224
298
|
const BREAK_GLASS_CONDITION = 'thumbgate_break_glass';
|
|
@@ -261,19 +335,51 @@ function commandContainsSequence(words, sequence) {
|
|
|
261
335
|
}
|
|
262
336
|
|
|
263
337
|
function commandHasPostMethod(words) {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
338
|
+
return ghApiHttpMethod(words) === 'post';
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function ghApiHttpMethod(words) {
|
|
342
|
+
const list = Array.isArray(words) ? words : [];
|
|
343
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
344
|
+
const word = list[i];
|
|
345
|
+
if ((word === '-x' || word === '--method') && list[i + 1]) return String(list[i + 1]).toLowerCase();
|
|
346
|
+
if (word.startsWith('--method=')) return word.slice('--method='.length).toLowerCase();
|
|
347
|
+
if (word.startsWith('-x') && word.length > 2 && !word.startsWith('-x=')) {
|
|
348
|
+
return word.slice(2).toLowerCase();
|
|
349
|
+
}
|
|
268
350
|
}
|
|
269
|
-
return
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function ghApiEndpoint(words) {
|
|
355
|
+
const list = Array.isArray(words) ? words : [];
|
|
356
|
+
const apiIndex = list.findIndex((word, i) => word === 'api' && list[i - 1] === 'gh');
|
|
357
|
+
if (apiIndex < 0) return null;
|
|
358
|
+
const flagsWithValue = new Set([
|
|
359
|
+
'-x', '--method', '-f', '--field', '-F', '--raw-field',
|
|
360
|
+
'-h', '--header', '--hostname', '--jq', '--input', '--cache',
|
|
361
|
+
]);
|
|
362
|
+
for (let i = apiIndex + 1; i < list.length; i += 1) {
|
|
363
|
+
const word = list[i];
|
|
364
|
+
if (word.startsWith('-')) {
|
|
365
|
+
if (word.includes('=')) continue;
|
|
366
|
+
if (flagsWithValue.has(word)) i += 1;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
return word;
|
|
370
|
+
}
|
|
371
|
+
return null;
|
|
270
372
|
}
|
|
271
373
|
|
|
272
374
|
function isGhApiPrCreateCommand(command) {
|
|
273
375
|
const words = commandWords(command);
|
|
274
376
|
if (!commandContainsSequence(words, ['gh', 'api'])) return false;
|
|
275
|
-
const
|
|
276
|
-
if (
|
|
377
|
+
const method = ghApiHttpMethod(words);
|
|
378
|
+
if (method && method !== 'post') return false;
|
|
379
|
+
const endpoint = ghApiEndpoint(words);
|
|
380
|
+
if (!endpoint) return false;
|
|
381
|
+
if (/\/pulls\/\d+/.test(endpoint)) return false;
|
|
382
|
+
if (!(endpoint === '/pulls' || /\/pulls$/.test(endpoint))) return false;
|
|
277
383
|
const fieldFlags = new Set(['-f', '--field', '--raw-field']);
|
|
278
384
|
const hasFieldWrite = words.some((word) => (
|
|
279
385
|
fieldFlags.has(word) ||
|
|
@@ -472,8 +578,39 @@ function isTaskScopeExpired(taskScope, nowMs = Date.now()) {
|
|
|
472
578
|
return nowMs >= deadline;
|
|
473
579
|
}
|
|
474
580
|
|
|
475
|
-
function
|
|
476
|
-
|
|
581
|
+
function currentScopeSessionId(explicitSessionId = null) {
|
|
582
|
+
if (explicitSessionId) return String(explicitSessionId).trim();
|
|
583
|
+
const raw = String(
|
|
584
|
+
process.env.THUMBGATE_SESSION_AGENT
|
|
585
|
+
|| process.env.THUMBGATE_SESSION_ID
|
|
586
|
+
|| process.env.CLAUDE_SESSION_ID
|
|
587
|
+
|| ''
|
|
588
|
+
).trim();
|
|
589
|
+
return raw || null;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function sanitizeScopeSessionId(sessionId) {
|
|
593
|
+
if (!sessionId) return '';
|
|
594
|
+
const hash = crypto.createHash('sha256').update(String(sessionId)).digest('hex').slice(0, 16);
|
|
595
|
+
const prefix = String(sessionId).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 32);
|
|
596
|
+
return prefix ? `${prefix}-${hash}` : hash;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Sibling agents share ~/.thumbgate/governance-state.json today, so one
|
|
600
|
+
// set_task_scope rebinds every other live session (#3522). When a session id
|
|
601
|
+
// is present, persist a per-session file next to the legacy slot.
|
|
602
|
+
function governanceStatePath(explicitSessionId = null) {
|
|
603
|
+
const base = module.exports.GOVERNANCE_STATE_PATH;
|
|
604
|
+
const sessionId = currentScopeSessionId(explicitSessionId);
|
|
605
|
+
if (!sessionId) return base;
|
|
606
|
+
const safe = sanitizeScopeSessionId(sessionId);
|
|
607
|
+
if (!safe) return base;
|
|
608
|
+
const parsed = path.parse(base);
|
|
609
|
+
return path.join(parsed.dir, `${parsed.name}.${safe}${parsed.ext}`);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function loadGovernanceState(explicitSessionId = null) {
|
|
613
|
+
const raw = loadJSON(governanceStatePath(explicitSessionId));
|
|
477
614
|
const state = {
|
|
478
615
|
taskScope: raw && raw.taskScope && typeof raw.taskScope === 'object' ? raw.taskScope : null,
|
|
479
616
|
protectedApprovals: Array.isArray(raw && raw.protectedApprovals) ? raw.protectedApprovals : [],
|
|
@@ -494,35 +631,142 @@ function loadGovernanceState() {
|
|
|
494
631
|
const activeApprovals = state.protectedApprovals.filter((entry) => {
|
|
495
632
|
if (!entry || typeof entry !== 'object') return false;
|
|
496
633
|
if (!entry.timestamp || !entry.expiresAt) return false;
|
|
497
|
-
return
|
|
634
|
+
return entry.expiresAt > now;
|
|
498
635
|
});
|
|
499
|
-
|
|
500
|
-
state.protectedApprovals = activeApprovals;
|
|
501
|
-
saveGovernanceState(state);
|
|
502
|
-
}
|
|
636
|
+
state.protectedApprovals = activeApprovals;
|
|
503
637
|
return state;
|
|
504
638
|
}
|
|
505
639
|
|
|
506
|
-
function saveGovernanceState(state) {
|
|
640
|
+
function saveGovernanceState(state, explicitSessionId = null) {
|
|
507
641
|
const next = {
|
|
508
642
|
taskScope: state && state.taskScope ? state.taskScope : null,
|
|
509
643
|
protectedApprovals: Array.isArray(state && state.protectedApprovals) ? state.protectedApprovals : [],
|
|
510
644
|
branchGovernance: state && state.branchGovernance ? state.branchGovernance : null,
|
|
511
645
|
workflowContract: state && state.workflowContract ? state.workflowContract : null,
|
|
512
646
|
};
|
|
513
|
-
saveJSON(
|
|
647
|
+
saveJSON(governanceStatePath(explicitSessionId), next);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function stableCanonicalStringify(value) {
|
|
651
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
652
|
+
if (Array.isArray(value)) return `[${value.map(stableCanonicalStringify).join(',')}]`;
|
|
653
|
+
return `{${Object.keys(value).sort().map((key) => (
|
|
654
|
+
`${JSON.stringify(key)}:${stableCanonicalStringify(value[key])}`
|
|
655
|
+
)).join(',')}}`;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function actionApprovalDigest(toolName, toolInput) {
|
|
659
|
+
return crypto.createHash('sha256').update(stableCanonicalStringify({
|
|
660
|
+
toolInput: toolInput && typeof toolInput === 'object' ? toolInput : {},
|
|
661
|
+
toolName: String(toolName || ''),
|
|
662
|
+
})).digest('hex');
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function adminOverrideTaskId(gateId, digest) {
|
|
666
|
+
return `admin-override:${gateId}:${digest}`;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function selectAdminOverrideAttempt(baseTaskId) {
|
|
670
|
+
const attempts = listEscalations().filter((entry) => (
|
|
671
|
+
entry.taskId === baseTaskId || String(entry.taskId || '').startsWith(`${baseTaskId}:attempt:`)
|
|
672
|
+
));
|
|
673
|
+
const latest = attempts[0] || null;
|
|
674
|
+
if (!latest) return baseTaskId;
|
|
675
|
+
const terminal = ['rejected', 'cancelled', 'expired'].includes(latest.status)
|
|
676
|
+
|| latest.eventType === 'consumed'
|
|
677
|
+
|| Date.parse(latest.expiresAt) <= Date.now();
|
|
678
|
+
if (!terminal) return latest.taskId;
|
|
679
|
+
const highestAttempt = attempts.reduce((highest, entry) => {
|
|
680
|
+
const match = String(entry.taskId || '').match(/:attempt:(\d+)$/);
|
|
681
|
+
return Math.max(highest, match ? Number(match[1]) : 1);
|
|
682
|
+
}, 1);
|
|
683
|
+
return `${baseTaskId}:attempt:${highestAttempt + 1}`;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function evaluateAdminOverride(gate, toolName, toolInput) {
|
|
687
|
+
const policy = gate && gate.adminOverride;
|
|
688
|
+
if (!policy || policy.required !== true) return null;
|
|
689
|
+
|
|
690
|
+
const approvalContextDigest = actionApprovalDigest(toolName, toolInput);
|
|
691
|
+
const baseTaskId = adminOverrideTaskId(gate.id, approvalContextDigest);
|
|
692
|
+
let taskId;
|
|
693
|
+
try {
|
|
694
|
+
taskId = selectAdminOverrideAttempt(baseTaskId);
|
|
695
|
+
} catch {
|
|
696
|
+
return {
|
|
697
|
+
authorized: false,
|
|
698
|
+
approvalContextDigest,
|
|
699
|
+
escalationId: null,
|
|
700
|
+
taskId: baseTaskId,
|
|
701
|
+
approvalUnavailable: true,
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
const ttlMs = Math.min(
|
|
705
|
+
60 * 60 * 1000,
|
|
706
|
+
Math.max(60 * 1000, Number(policy.ttlMs) || DEFAULT_ADMIN_OVERRIDE_TTL_MS),
|
|
707
|
+
);
|
|
708
|
+
let escalation;
|
|
709
|
+
try {
|
|
710
|
+
escalation = requestEscalation({
|
|
711
|
+
taskId,
|
|
712
|
+
reason: `Admin override required for hard gate '${gate.id}'.`,
|
|
713
|
+
severity: gate.severity || 'critical',
|
|
714
|
+
requester: { id: 'thumbgate-gates-engine', kind: 'service' },
|
|
715
|
+
evidence: [`sha256:${approvalContextDigest}`],
|
|
716
|
+
ttlMs,
|
|
717
|
+
idempotencyKey: taskId,
|
|
718
|
+
approvalContextDigest,
|
|
719
|
+
requiredReviewerRole: 'admin',
|
|
720
|
+
});
|
|
721
|
+
} catch {
|
|
722
|
+
return {
|
|
723
|
+
authorized: false,
|
|
724
|
+
approvalContextDigest,
|
|
725
|
+
escalationId: null,
|
|
726
|
+
taskId,
|
|
727
|
+
approvalUnavailable: true,
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
const escalationId = escalation.escalation.escalationId;
|
|
731
|
+
let consumption = null;
|
|
732
|
+
try {
|
|
733
|
+
consumption = consumeVerifiedApproval(escalationId, {
|
|
734
|
+
consumer: { id: 'thumbgate-gates-engine', kind: 'service' },
|
|
735
|
+
});
|
|
736
|
+
} catch {
|
|
737
|
+
consumption = null;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (!consumption || !consumption.consumed) {
|
|
741
|
+
return {
|
|
742
|
+
authorized: false,
|
|
743
|
+
approvalContextDigest,
|
|
744
|
+
escalationId,
|
|
745
|
+
taskId,
|
|
746
|
+
replayed: consumption?.replayed === true,
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
return {
|
|
750
|
+
authorized: true,
|
|
751
|
+
approvalContextDigest,
|
|
752
|
+
escalationId,
|
|
753
|
+
taskId,
|
|
754
|
+
approver: consumption.approval.actor,
|
|
755
|
+
consumptionReceipt: consumption.consumption.eventHash,
|
|
756
|
+
};
|
|
514
757
|
}
|
|
515
758
|
|
|
516
759
|
function setTaskScope(scopeInput = {}) {
|
|
760
|
+
const explicitSessionId = scopeInput && scopeInput.sessionId ? String(scopeInput.sessionId).trim() : null;
|
|
517
761
|
if (scopeInput && scopeInput.clear === true) {
|
|
518
|
-
const currentState = loadGovernanceState();
|
|
762
|
+
const currentState = loadGovernanceState(explicitSessionId);
|
|
519
763
|
const cleared = {
|
|
520
764
|
taskScope: null,
|
|
521
765
|
protectedApprovals: currentState.protectedApprovals,
|
|
522
766
|
branchGovernance: currentState.branchGovernance,
|
|
523
767
|
workflowContract: null,
|
|
524
768
|
};
|
|
525
|
-
saveGovernanceState(cleared);
|
|
769
|
+
saveGovernanceState(cleared, explicitSessionId);
|
|
526
770
|
refreshLocalOnlyConstraint(cleared);
|
|
527
771
|
return null;
|
|
528
772
|
}
|
|
@@ -545,6 +789,7 @@ function setTaskScope(scopeInput = {}) {
|
|
|
545
789
|
const leaseMs = scopeInput.ttlMs == null ? null : clampTtlMs(scopeInput.ttlMs, TASK_SCOPE_LEASE_MS);
|
|
546
790
|
const taskScope = {
|
|
547
791
|
taskId: String(scopeInput.taskId || '').trim() || null,
|
|
792
|
+
sessionId: currentScopeSessionId(explicitSessionId),
|
|
548
793
|
summary: String(scopeInput.summary || '').trim() || null,
|
|
549
794
|
allowedPaths,
|
|
550
795
|
protectedPaths,
|
|
@@ -555,12 +800,12 @@ function setTaskScope(scopeInput = {}) {
|
|
|
555
800
|
leaseMs,
|
|
556
801
|
expiresAt: leaseMs == null ? null : scopeNow + leaseMs,
|
|
557
802
|
};
|
|
558
|
-
const state = loadGovernanceState();
|
|
803
|
+
const state = loadGovernanceState(explicitSessionId);
|
|
559
804
|
state.taskScope = taskScope;
|
|
560
805
|
state.workflowContract = scopeInput.workflowContract && typeof scopeInput.workflowContract === 'object'
|
|
561
806
|
? scopeInput.workflowContract
|
|
562
807
|
: null;
|
|
563
|
-
saveGovernanceState(state);
|
|
808
|
+
saveGovernanceState(state, explicitSessionId);
|
|
564
809
|
if (taskScope.localOnly) {
|
|
565
810
|
setConstraint('local_only', true);
|
|
566
811
|
}
|
|
@@ -606,7 +851,14 @@ function breakGlassEmergency(input = {}) {
|
|
|
606
851
|
const gates = ['pr_create_allowed', 'pr_threads_checked', BREAK_GLASS_CONDITION];
|
|
607
852
|
const satisfied = {};
|
|
608
853
|
for (const gateId of gates) {
|
|
609
|
-
|
|
854
|
+
// Break glass is the highest-scrutiny unlock there is. Without an explicit
|
|
855
|
+
// source these records defaulted to 'cli', hiding emergency overrides among
|
|
856
|
+
// ordinary ones — the exact case an auditor looks for first.
|
|
857
|
+
satisfied[gateId] = satisfyCondition(gateId, evidence, null, {
|
|
858
|
+
source: 'break-glass',
|
|
859
|
+
actor: input.actor || 'operator',
|
|
860
|
+
reason,
|
|
861
|
+
});
|
|
610
862
|
}
|
|
611
863
|
|
|
612
864
|
const approval = approveProtectedAction({
|
|
@@ -665,8 +917,9 @@ function setBranchGovernance(input = {}) {
|
|
|
665
917
|
return governance;
|
|
666
918
|
}
|
|
667
919
|
|
|
668
|
-
function getScopeState() {
|
|
669
|
-
|
|
920
|
+
function getScopeState(options = {}) {
|
|
921
|
+
const sessionId = typeof options === 'string' ? options : (options?.sessionId || process.env.THUMBGATE_SESSION_AGENT || null);
|
|
922
|
+
return loadGovernanceState(sessionId);
|
|
670
923
|
}
|
|
671
924
|
|
|
672
925
|
function getBranchGovernanceState() {
|
|
@@ -709,7 +962,7 @@ function isConditionSatisfied(conditionId) {
|
|
|
709
962
|
return age < TTL_MS;
|
|
710
963
|
}
|
|
711
964
|
|
|
712
|
-
function satisfyCondition(conditionId, evidence, structuredReasoning) {
|
|
965
|
+
function satisfyCondition(conditionId, evidence, structuredReasoning, options = {}) {
|
|
713
966
|
const state = loadState();
|
|
714
967
|
const entry = {
|
|
715
968
|
timestamp: Date.now(),
|
|
@@ -725,6 +978,28 @@ function satisfyCondition(conditionId, evidence, structuredReasoning) {
|
|
|
725
978
|
}
|
|
726
979
|
state[conditionId] = entry;
|
|
727
980
|
saveState(state);
|
|
981
|
+
|
|
982
|
+
// Satisfying a gate condition IS an override: it unlocks an action the gate
|
|
983
|
+
// had blocked. Previously this wrote only to the state store, so an override
|
|
984
|
+
// performed through the CLI left no trace in the audit trail at all — the
|
|
985
|
+
// least-supervised path was also the least-recorded. Record it explicitly.
|
|
986
|
+
// Never let an audit failure break the unlock the caller is relying on.
|
|
987
|
+
try {
|
|
988
|
+
// Lazy require: override-audit depends on audit-trail, which this module
|
|
989
|
+
// already loads; requiring at call time avoids any circular-init surprise.
|
|
990
|
+
const { recordOverride } = require('./override-audit');
|
|
991
|
+
recordOverride({
|
|
992
|
+
gateId: conditionId,
|
|
993
|
+
source: options.source || 'cli',
|
|
994
|
+
actor: options.actor,
|
|
995
|
+
reason: options.reason,
|
|
996
|
+
evidence: entry.evidence,
|
|
997
|
+
structuredReasoning: entry.structuredReasoning,
|
|
998
|
+
});
|
|
999
|
+
} catch {
|
|
1000
|
+
/* audit is best-effort; the gate state is authoritative */
|
|
1001
|
+
}
|
|
1002
|
+
|
|
728
1003
|
return entry;
|
|
729
1004
|
}
|
|
730
1005
|
|
|
@@ -905,14 +1180,111 @@ function safeExecFileLines(binary, args, cwd) {
|
|
|
905
1180
|
}
|
|
906
1181
|
}
|
|
907
1182
|
|
|
1183
|
+
function extractGitMinusCPaths(command) {
|
|
1184
|
+
const found = [];
|
|
1185
|
+
const segments = String(command || '').split(/\r?\n|&&|\|\||[;|&]/);
|
|
1186
|
+
for (const segment of segments) {
|
|
1187
|
+
const tokens = tokenizeShellWords(segment);
|
|
1188
|
+
const gitIdx = tokens.findIndex((token) => token === 'git' || /(?:^|\/)git$/.test(token));
|
|
1189
|
+
if (gitIdx === -1) continue;
|
|
1190
|
+
for (let i = gitIdx + 1; i < tokens.length; i += 1) {
|
|
1191
|
+
const token = tokens[i];
|
|
1192
|
+
if (token === '-C' && tokens[i + 1]) {
|
|
1193
|
+
found.push(tokens[i + 1]);
|
|
1194
|
+
i += 1;
|
|
1195
|
+
continue;
|
|
1196
|
+
}
|
|
1197
|
+
if (token === '--work-tree' && tokens[i + 1]) {
|
|
1198
|
+
found.push(tokens[i + 1]);
|
|
1199
|
+
i += 1;
|
|
1200
|
+
continue;
|
|
1201
|
+
}
|
|
1202
|
+
if (token.startsWith('--work-tree=')) {
|
|
1203
|
+
found.push(token.slice('--work-tree='.length));
|
|
1204
|
+
continue;
|
|
1205
|
+
}
|
|
1206
|
+
if (!token.startsWith('-')) break;
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
return found;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function extractGitContextPair(command) {
|
|
1213
|
+
const segments = String(command || '').split(/\r?\n|&&|\|\||[;|&]/);
|
|
1214
|
+
for (const segment of segments) {
|
|
1215
|
+
const tokens = tokenizeShellWords(segment);
|
|
1216
|
+
const gitIdx = tokens.findIndex((token) => token === 'git' || /(?:^|\/)git$/.test(token));
|
|
1217
|
+
if (gitIdx === -1) continue;
|
|
1218
|
+
let gitDir = null;
|
|
1219
|
+
let workTree = null;
|
|
1220
|
+
for (let i = gitIdx + 1; i < tokens.length; i += 1) {
|
|
1221
|
+
const token = tokens[i];
|
|
1222
|
+
if (token === '--git-dir' && tokens[i + 1]) {
|
|
1223
|
+
gitDir = tokens[i + 1];
|
|
1224
|
+
i += 1;
|
|
1225
|
+
continue;
|
|
1226
|
+
}
|
|
1227
|
+
if (token.startsWith('--git-dir=')) {
|
|
1228
|
+
gitDir = token.slice('--git-dir='.length);
|
|
1229
|
+
continue;
|
|
1230
|
+
}
|
|
1231
|
+
if (token === '--work-tree' && tokens[i + 1]) {
|
|
1232
|
+
workTree = tokens[i + 1];
|
|
1233
|
+
i += 1;
|
|
1234
|
+
continue;
|
|
1235
|
+
}
|
|
1236
|
+
if (token.startsWith('--work-tree=')) {
|
|
1237
|
+
workTree = token.slice('--work-tree='.length);
|
|
1238
|
+
continue;
|
|
1239
|
+
}
|
|
1240
|
+
if (token === '-C' && tokens[i + 1]) {
|
|
1241
|
+
workTree = tokens[i + 1];
|
|
1242
|
+
i += 1;
|
|
1243
|
+
continue;
|
|
1244
|
+
}
|
|
1245
|
+
if (!token.startsWith('-')) break;
|
|
1246
|
+
}
|
|
1247
|
+
if (gitDir || workTree) return { gitDir, workTree };
|
|
1248
|
+
}
|
|
1249
|
+
return null;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
908
1252
|
function resolveRepoRoot(toolInput = {}) {
|
|
909
|
-
const
|
|
1253
|
+
const command = String(toolInput.command || '');
|
|
1254
|
+
const baseCwd = toolInput.cwd ? path.resolve(String(toolInput.cwd)) : process.cwd();
|
|
1255
|
+
const paired = extractGitContextPair(command);
|
|
1256
|
+
if (paired && paired.gitDir && paired.workTree) {
|
|
1257
|
+
try {
|
|
1258
|
+
const resolvedGitDir = path.resolve(baseCwd, paired.gitDir);
|
|
1259
|
+
const resolvedWorkTree = path.resolve(baseCwd, paired.workTree);
|
|
1260
|
+
const root = execFileSync('git', ['--git-dir', resolvedGitDir, '--work-tree', resolvedWorkTree, 'rev-parse', '--show-toplevel'], {
|
|
1261
|
+
cwd: resolvedWorkTree,
|
|
1262
|
+
encoding: 'utf8',
|
|
1263
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1264
|
+
}).trim();
|
|
1265
|
+
if (root) return root;
|
|
1266
|
+
} catch {
|
|
1267
|
+
// Fall through to standard candidate probing
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
const minusC = extractGitMinusCPaths(command).map((entry) => path.resolve(baseCwd, entry));
|
|
1272
|
+
const commandCwd = effectiveCommandCwd(command, toolInput);
|
|
1273
|
+
const candidates = [];
|
|
1274
|
+
const seen = new Set();
|
|
1275
|
+
for (const value of [
|
|
1276
|
+
...minusC,
|
|
1277
|
+
commandCwd,
|
|
910
1278
|
toolInput.repoPath,
|
|
911
1279
|
toolInput.cwd,
|
|
912
1280
|
process.cwd(),
|
|
913
|
-
]
|
|
914
|
-
|
|
915
|
-
|
|
1281
|
+
]) {
|
|
1282
|
+
if (!value) continue;
|
|
1283
|
+
const resolved = path.resolve(String(value));
|
|
1284
|
+
if (seen.has(resolved)) continue;
|
|
1285
|
+
seen.add(resolved);
|
|
1286
|
+
candidates.push(resolved);
|
|
1287
|
+
}
|
|
916
1288
|
|
|
917
1289
|
for (const cwd of candidates) {
|
|
918
1290
|
try {
|
|
@@ -1135,7 +1507,17 @@ function parseGitPathspec(command, subcommand, options = {}) {
|
|
|
1135
1507
|
// null as "unknown" and fall back to broad.
|
|
1136
1508
|
function effectiveCommandCwd(command, toolInput) {
|
|
1137
1509
|
let cwd = String(toolInput?.cwd || toolInput?.repoPath || process.cwd());
|
|
1138
|
-
const
|
|
1510
|
+
const commandStr = String(command || '');
|
|
1511
|
+
const gitCMatch = commandStr.match(/(?:^|\s)git(?:\s+-[^\s]+)*\s+-C(?:\s+|=)(?:'([^']+)'|"([^"]+)"|([^\s'"]+))/i);
|
|
1512
|
+
const gitCDir = gitCMatch ? (gitCMatch[1] || gitCMatch[2] || gitCMatch[3] || '').trim() : '';
|
|
1513
|
+
if (gitCDir) {
|
|
1514
|
+
let target = gitCDir;
|
|
1515
|
+
if (target.startsWith('~/')) {
|
|
1516
|
+
target = path.join(os.homedir(), target.slice(2));
|
|
1517
|
+
}
|
|
1518
|
+
return path.resolve(cwd, target);
|
|
1519
|
+
}
|
|
1520
|
+
const segments = commandStr.split(/\r?\n|&&|\|\||[;|&]/);
|
|
1139
1521
|
for (const segment of segments) {
|
|
1140
1522
|
// Parsed without a regex: /^cd\s+(?:--\s+)?(.+)$/ has adjacent \s+ groups that backtrack
|
|
1141
1523
|
// polynomially on input like `cd\t\t\t…` (js/polynomial-redos). The command comes
|
|
@@ -1149,8 +1531,12 @@ function effectiveCommandCwd(command, toolInput) {
|
|
|
1149
1531
|
else if (argText.startsWith('--') && /^[ \t]/.test(argText.slice(2))) argText = argText.slice(2).trim();
|
|
1150
1532
|
const target = tokenizeShellWords(argText)[0];
|
|
1151
1533
|
if (!target) break; // bare `cd` -> home; leave scope resolution alone
|
|
1152
|
-
if (/[*?$`]
|
|
1153
|
-
|
|
1534
|
+
if (/[*?$`]/.test(target)) return null;
|
|
1535
|
+
let targetResolved = target;
|
|
1536
|
+
if (targetResolved.startsWith('~/')) {
|
|
1537
|
+
targetResolved = path.join(os.homedir(), targetResolved.slice(2));
|
|
1538
|
+
}
|
|
1539
|
+
cwd = path.resolve(cwd, targetResolved);
|
|
1154
1540
|
}
|
|
1155
1541
|
return cwd;
|
|
1156
1542
|
}
|
|
@@ -1376,8 +1762,10 @@ function extractAffectedFiles(toolName, toolInput = {}) {
|
|
|
1376
1762
|
}
|
|
1377
1763
|
|
|
1378
1764
|
if (/\bgit\s+push\b/i.test(command) || /\bgh\s+pr\s+(?:create|merge)\b/i.test(command) || isGhApiPrCreateCommand(command)) {
|
|
1379
|
-
|
|
1380
|
-
|
|
1765
|
+
if (files.size === 0) {
|
|
1766
|
+
for (const filePath of getBranchDiffFiles(repoRoot)) {
|
|
1767
|
+
files.add(normalizePosix(filePath));
|
|
1768
|
+
}
|
|
1381
1769
|
}
|
|
1382
1770
|
}
|
|
1383
1771
|
}
|
|
@@ -1691,9 +2079,51 @@ function isThreadResolutionSatisfied() {
|
|
|
1691
2079
|
));
|
|
1692
2080
|
}
|
|
1693
2081
|
|
|
2082
|
+
// Hook payloads name MCP tools `mcp__<server>__<tool>` while exemption lists
|
|
2083
|
+
// hold bare tool names. Exemptions must compare on the stripped name or they
|
|
2084
|
+
// never fire for real hook traffic — 2026-08-05: the pending-thread gate
|
|
2085
|
+
// blocked `mcp__thumbgate__satisfy_gate` itself, deadlocking its own
|
|
2086
|
+
// documented escape hatch until the session-actions TTL expired.
|
|
2087
|
+
function bareToolName(toolName) {
|
|
2088
|
+
return String(toolName || '').replace(/^mcp__.+?__/, '');
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
function isRemedyToolName(toolName) {
|
|
2092
|
+
return REMEDY_TOOL_NAMES.has(bareToolName(toolName));
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
// permission-change-approval used to regex the entire command string, so
|
|
2096
|
+
// quoting `chmod 755` inside `gh issue create --body` was itself a deny (#3523).
|
|
2097
|
+
function isCommandPositionPermissionChange(toolName, toolInput = {}) {
|
|
2098
|
+
if (toolName !== 'Bash') return false;
|
|
2099
|
+
const command = String(toolInput.command || '');
|
|
2100
|
+
if (!command) return false;
|
|
2101
|
+
const segments = canonicalizeCommandForGates(command).split('; ');
|
|
2102
|
+
for (const segment of segments) {
|
|
2103
|
+
const tokens = tokenizeShellWords(segment);
|
|
2104
|
+
if (tokens.length === 0) continue;
|
|
2105
|
+
const bin = tokens[0].toLowerCase();
|
|
2106
|
+
if (bin === 'chmod' || bin === 'chown' || bin === 'setfacl') return tokens.length >= 2;
|
|
2107
|
+
if (bin === 'busybox' && tokens[1] && ['chmod', 'chown', 'setfacl'].includes(tokens[1].toLowerCase())) {
|
|
2108
|
+
return tokens.length >= 3;
|
|
2109
|
+
}
|
|
2110
|
+
if (bin === 'grant' || bin === 'revoke') return tokens.length >= 2;
|
|
2111
|
+
const nextFew = tokens.slice(1, 6).map((token) => token.toLowerCase());
|
|
2112
|
+
if ((bin === 'pm' || bin === 'adb') && nextFew.includes('grant')) return true;
|
|
2113
|
+
if (
|
|
2114
|
+
(bin === 'aws' || bin === 'gcloud' || bin === 'az')
|
|
2115
|
+
&& nextFew.some((token) => token === 'iam' || token === 'policy' || token === 'role'
|
|
2116
|
+
|| token === 'grant' || token === 'revoke')
|
|
2117
|
+
) {
|
|
2118
|
+
return true;
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
return false;
|
|
2122
|
+
}
|
|
2123
|
+
|
|
1694
2124
|
function isThreadResolutionEvidenceAction(toolName, toolInput = {}) {
|
|
1695
2125
|
if (isGitCommitCommand(toolName, toolInput)) return true;
|
|
1696
|
-
if (['recall', 'search_lessons', 'verify_claim', 'satisfy_gate', 'track_action'].includes(toolName)) return true;
|
|
2126
|
+
if (['recall', 'search_lessons', 'verify_claim', 'satisfy_gate', 'track_action'].includes(bareToolName(toolName))) return true;
|
|
1697
2127
|
if (toolName !== 'Bash') return false;
|
|
1698
2128
|
const command = String(toolInput.command || '');
|
|
1699
2129
|
return /\b(?:gate-satisfy|satisfy_gate|track_action|gh\s+pr\s+(?:view|checks|status)|gh\s+api\b.*(?:reviewThreads|reviews|comments|threads)|git\s+(?:status|diff|show))\b/i.test(command);
|
|
@@ -1733,7 +2163,9 @@ function getReadOnlyToolNames() {
|
|
|
1733
2163
|
return names;
|
|
1734
2164
|
}
|
|
1735
2165
|
function isReadOnlyObservabilityTool(toolName) {
|
|
1736
|
-
|
|
2166
|
+
if (!toolName) return false;
|
|
2167
|
+
const names = getReadOnlyToolNames();
|
|
2168
|
+
return names.has(toolName) || names.has(bareToolName(toolName));
|
|
1737
2169
|
}
|
|
1738
2170
|
|
|
1739
2171
|
function evaluatePendingPrThreadResolutionGate(toolName, toolInput = {}) {
|
|
@@ -1777,7 +2209,7 @@ function evaluatePendingPrThreadResolutionGate(toolName, toolInput = {}) {
|
|
|
1777
2209
|
severity: 'critical',
|
|
1778
2210
|
reasoning: [
|
|
1779
2211
|
`Tracked action ${PR_THREAD_RESOLUTION_ACTION} is pending`,
|
|
1780
|
-
'Check review threads (e.g. gh pr view --json reviewThreads), then call the satisfy_gate tool with
|
|
2212
|
+
'Check review threads (e.g. gh pr view --json reviewThreads), then call the satisfy_gate tool with gate="pr_threads_checked" (param name is gate, not gateId) and evidence — running a check command alone does not clear this gate',
|
|
1781
2213
|
],
|
|
1782
2214
|
};
|
|
1783
2215
|
}
|
|
@@ -1872,10 +2304,15 @@ function recordHelperScriptWrite(toolName, toolInput = {}) {
|
|
|
1872
2304
|
packageScriptTouched,
|
|
1873
2305
|
reasons,
|
|
1874
2306
|
};
|
|
1875
|
-
trackAction(
|
|
2307
|
+
trackAction(helperBypassActionKey(), metadata);
|
|
1876
2308
|
return metadata;
|
|
1877
2309
|
}
|
|
1878
2310
|
|
|
2311
|
+
function helperBypassActionKey() {
|
|
2312
|
+
const sessionId = currentScopeSessionId();
|
|
2313
|
+
return sessionId ? `${HELPER_BYPASS_ACTION}:${sessionId}` : HELPER_BYPASS_ACTION;
|
|
2314
|
+
}
|
|
2315
|
+
|
|
1879
2316
|
function evaluateStatefulHelperBypassGate(toolName, toolInput = {}) {
|
|
1880
2317
|
if (process.env.THUMBGATE_HELPER_BYPASS_GUARD === '0') return null;
|
|
1881
2318
|
if (toolName !== 'Bash') {
|
|
@@ -1901,7 +2338,7 @@ function evaluateStatefulHelperBypassGate(toolName, toolInput = {}) {
|
|
|
1901
2338
|
|
|
1902
2339
|
const writeMetadata = recordHelperScriptWrite(toolName, toolInput);
|
|
1903
2340
|
const actions = listSessionActions();
|
|
1904
|
-
const recentWrite = actions[
|
|
2341
|
+
const recentWrite = actions[helperBypassActionKey()];
|
|
1905
2342
|
const recentMetadata = recentWrite && recentWrite.metadata && typeof recentWrite.metadata === 'object'
|
|
1906
2343
|
? recentWrite.metadata
|
|
1907
2344
|
: null;
|
|
@@ -1974,6 +2411,10 @@ function evaluateCatastrophicDeclarativeGate(config, constraints, toolName, tool
|
|
|
1974
2411
|
for (const gate of config.gates) {
|
|
1975
2412
|
if (!CATASTROPHIC_DECLARATIVE_GATE_IDS.has(gate.id)) continue;
|
|
1976
2413
|
if (gate.action !== 'block' || gate.metrics) continue;
|
|
2414
|
+
// Override-capable hard gates must reach the ordinary loop so it can
|
|
2415
|
+
// authenticate and consume the exact-action admin authorization. They are
|
|
2416
|
+
// still unconditional hard floors and cannot be posture/cap downgraded.
|
|
2417
|
+
if (gate.adminOverride && gate.adminOverride.required === true) continue;
|
|
1977
2418
|
|
|
1978
2419
|
const matchDetails = matchGate(gate, toolName, toolInput);
|
|
1979
2420
|
if (!matchDetails.matched) continue;
|
|
@@ -2399,17 +2840,27 @@ function matchGate(gate, toolName, toolInput = {}) {
|
|
|
2399
2840
|
|
|
2400
2841
|
if (gate.pattern) {
|
|
2401
2842
|
try {
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2843
|
+
if (gate.id === 'permission-change-approval') {
|
|
2844
|
+
if (isRemedyToolName(toolName) || !isCommandPositionPermissionChange(toolName, toolInput)) {
|
|
2845
|
+
return { matched: false, matchText, affectedFiles };
|
|
2846
|
+
}
|
|
2847
|
+
if (isSafeLocalCredentialHardeningCommand(toolName, toolInput)) {
|
|
2848
|
+
return { matched: false, matchText, affectedFiles };
|
|
2849
|
+
}
|
|
2850
|
+
} else if (gate.id === 'gh-api-pr-create-restricted') {
|
|
2851
|
+
if (!isGhApiPrCreateCommand(String(toolInput.command || ''))) {
|
|
2852
|
+
return { matched: false, matchText, affectedFiles };
|
|
2853
|
+
}
|
|
2854
|
+
} else {
|
|
2855
|
+
const regex = new RegExp(gate.pattern);
|
|
2856
|
+
// Match command text, tool name, and light payload surfaces. MCP tools
|
|
2857
|
+
// (e.g. Gmail send_message) have no `command` field — without multi-surface
|
|
2858
|
+
// matching, every pattern gate against them is permanently inert.
|
|
2859
|
+
const surfaces = matchSurfaces.length > 0 ? matchSurfaces : [matchText];
|
|
2860
|
+
const anySurfaceMatch = surfaces.some((surface) => patternMatchesCommand(regex, surface));
|
|
2861
|
+
if (!anySurfaceMatch) {
|
|
2862
|
+
return { matched: false, matchText, affectedFiles };
|
|
2863
|
+
}
|
|
2413
2864
|
}
|
|
2414
2865
|
if (isBreakGlassSettingsBypass(gate, affectedFiles)) {
|
|
2415
2866
|
return { matched: false, matchText, affectedFiles };
|
|
@@ -2476,7 +2927,6 @@ function matchGate(gate, toolName, toolInput = {}) {
|
|
|
2476
2927
|
matchText,
|
|
2477
2928
|
affectedFiles,
|
|
2478
2929
|
taskScopeViolation,
|
|
2479
|
-
protectedApprovalViolation,
|
|
2480
2930
|
branchGovernanceViolation,
|
|
2481
2931
|
};
|
|
2482
2932
|
}
|
|
@@ -2502,6 +2952,7 @@ function matchSelfProtectHardFloor(gate, toolName, toolInput = {}) {
|
|
|
2502
2952
|
|
|
2503
2953
|
const command = String(toolInput.command || '');
|
|
2504
2954
|
let matchText = command;
|
|
2955
|
+
|
|
2505
2956
|
if (gate.id === 'self-protect-config' || gate.id === 'self-protect-hooks-disable') {
|
|
2506
2957
|
const targetPattern = gate.id === 'self-protect-config'
|
|
2507
2958
|
? SELF_PROTECT_CONFIG_TARGET_PATTERN
|
|
@@ -2513,7 +2964,28 @@ function matchSelfProtectHardFloor(gate, toolName, toolInput = {}) {
|
|
|
2513
2964
|
const commandTargetPattern = gate.id === 'self-protect-config'
|
|
2514
2965
|
? SELF_PROTECT_CONFIG_COMMAND_PATTERN
|
|
2515
2966
|
: SELF_PROTECT_HOOK_COMMAND_PATTERN;
|
|
2516
|
-
|
|
2967
|
+
|
|
2968
|
+
// Inspect every shell redirection destination (optional fd, optional/no
|
|
2969
|
+
// spaces, attached forms like printf x>file, multiple redirects).
|
|
2970
|
+
const redirectPattern = /(?:^|[\s;&|]|[^\s;&|<>])(?:\d*)>{1,2}\s*([^\s;&|<>]+)/g;
|
|
2971
|
+
const redirectTargets = [];
|
|
2972
|
+
let redirectMatch = redirectPattern.exec(command);
|
|
2973
|
+
while (redirectMatch) {
|
|
2974
|
+
if (redirectMatch[1]) redirectTargets.push(redirectMatch[1]);
|
|
2975
|
+
redirectMatch = redirectPattern.exec(command);
|
|
2976
|
+
}
|
|
2977
|
+
if (redirectTargets.length > 0) {
|
|
2978
|
+
// Deny when any redirect destination is protected.
|
|
2979
|
+
if (redirectTargets.some((target) => commandTargetPattern.test(target))) {
|
|
2980
|
+
// fall through to deny
|
|
2981
|
+
} else if (!SHELL_FILE_MUTATION_PATTERN.test(command) || !commandTargetPattern.test(command)) {
|
|
2982
|
+
// Benign redirects and no other protected mutation → allow.
|
|
2983
|
+
return null;
|
|
2984
|
+
}
|
|
2985
|
+
// Benign redirects but command still mutates a protected path another way → deny.
|
|
2986
|
+
} else {
|
|
2987
|
+
if (!SHELL_FILE_MUTATION_PATTERN.test(command) || !commandTargetPattern.test(command)) return null;
|
|
2988
|
+
}
|
|
2517
2989
|
} else {
|
|
2518
2990
|
return null;
|
|
2519
2991
|
}
|
|
@@ -2813,6 +3285,27 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
|
|
|
2813
3285
|
try {
|
|
2814
3286
|
const { selectHarness } = require('./harness-selector');
|
|
2815
3287
|
harnessPath = selectHarness(toolName, toolInput);
|
|
3288
|
+
try {
|
|
3289
|
+
// Opt-in RateBurst only (THUMBGATE_RADWARE_RATE=1). Never persist in tests/CI.
|
|
3290
|
+
if (process.env.THUMBGATE_RADWARE_RATE === '1') {
|
|
3291
|
+
const radware = require('./radware-threat-defense.js');
|
|
3292
|
+
const inTest = Boolean(process.env.NODE_TEST || process.env.NODE_TEST_CONTEXT || process.env.CI || process.env.GITHUB_ACTIONS || process.env.VITEST || process.argv.some((a) => a.includes('node:test') || a.endsWith('.test.js')));
|
|
3293
|
+
if (!inTest) {
|
|
3294
|
+
radware.persistCallTimestamp();
|
|
3295
|
+
const burst = radware.checkRateBurst(radware.loadCallTimestamps());
|
|
3296
|
+
if (burst.tripped) {
|
|
3297
|
+
return recordStructuralGateBlock(toolName, toolInput, {
|
|
3298
|
+
decision: 'deny',
|
|
3299
|
+
gate: 'algorithmic-token-drain-circuit-breaker',
|
|
3300
|
+
action: 'block',
|
|
3301
|
+
severity: 'high',
|
|
3302
|
+
message: burst.message,
|
|
3303
|
+
receipt: 'threat_defense_interdicted=true:type=RateBurst:action=block',
|
|
3304
|
+
});
|
|
3305
|
+
}
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3308
|
+
} catch { /* optional */ }
|
|
2816
3309
|
} catch { /* harness-selector is optional */ }
|
|
2817
3310
|
config = loadGatesConfig(configPath, harnessPath);
|
|
2818
3311
|
} catch {
|
|
@@ -2835,6 +3328,10 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
|
|
|
2835
3328
|
if (statefulHelperBypassGate) {
|
|
2836
3329
|
return recordStructuralGateBlock(toolName, toolInput, statefulHelperBypassGate);
|
|
2837
3330
|
}
|
|
3331
|
+
const stealthMemoryInjectionGate = evaluateStealthMemoryInjection(toolName, toolInput);
|
|
3332
|
+
if (stealthMemoryInjectionGate) {
|
|
3333
|
+
return recordStructuralGateBlock(toolName, toolInput, stealthMemoryInjectionGate);
|
|
3334
|
+
}
|
|
2838
3335
|
if (isBreakGlassSettingsRecoveryAction(toolName, toolInput)) {
|
|
2839
3336
|
recordAuditEvent({
|
|
2840
3337
|
toolName,
|
|
@@ -2848,6 +3345,22 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
|
|
|
2848
3345
|
return null;
|
|
2849
3346
|
}
|
|
2850
3347
|
|
|
3348
|
+
const agentIdentityLifecycleGate = evaluateAgentIdentityLifecycleGate(toolName, toolInput);
|
|
3349
|
+
if (agentIdentityLifecycleGate) {
|
|
3350
|
+
recordStat(agentIdentityLifecycleGate.gate, 'block', null, { toolName, toolInput });
|
|
3351
|
+
const identityAuditRecord = recordAuditEvent({
|
|
3352
|
+
toolName,
|
|
3353
|
+
toolInput,
|
|
3354
|
+
decision: 'deny',
|
|
3355
|
+
gateId: agentIdentityLifecycleGate.gate,
|
|
3356
|
+
message: agentIdentityLifecycleGate.message,
|
|
3357
|
+
severity: agentIdentityLifecycleGate.severity,
|
|
3358
|
+
source: 'gates-engine',
|
|
3359
|
+
});
|
|
3360
|
+
auditToFeedback(identityAuditRecord);
|
|
3361
|
+
return agentIdentityLifecycleGate;
|
|
3362
|
+
}
|
|
3363
|
+
|
|
2851
3364
|
const pendingThreadResolutionGate = evaluatePendingPrThreadResolutionGate(toolName, toolInput);
|
|
2852
3365
|
if (pendingThreadResolutionGate) {
|
|
2853
3366
|
recordStat(pendingThreadResolutionGate.gate, 'block', null, { toolName, toolInput });
|
|
@@ -2952,13 +3465,45 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
|
|
|
2952
3465
|
});
|
|
2953
3466
|
|
|
2954
3467
|
if (gate.action === 'block') {
|
|
3468
|
+
const adminOverride = evaluateAdminOverride(gate, toolName, toolInput);
|
|
3469
|
+
if (adminOverride && adminOverride.authorized) {
|
|
3470
|
+
recordStat(gate.id, 'approve', gate, { toolName, toolInput });
|
|
3471
|
+
const auditRecord = recordAuditEvent({
|
|
3472
|
+
toolName,
|
|
3473
|
+
toolInput,
|
|
3474
|
+
decision: 'allow',
|
|
3475
|
+
gateId: gate.id,
|
|
3476
|
+
message: `Single-use admin override consumed for sha256:${adminOverride.approvalContextDigest}.`,
|
|
3477
|
+
severity: gate.severity,
|
|
3478
|
+
source: 'gates-engine-admin-override',
|
|
3479
|
+
});
|
|
3480
|
+
auditToFeedback(auditRecord);
|
|
3481
|
+
return {
|
|
3482
|
+
decision: 'allow',
|
|
3483
|
+
gate: gate.id,
|
|
3484
|
+
message: `Single-use admin override consumed for sha256:${adminOverride.approvalContextDigest}.`,
|
|
3485
|
+
severity: gate.severity,
|
|
3486
|
+
reasoning,
|
|
3487
|
+
adminOverride,
|
|
3488
|
+
};
|
|
3489
|
+
}
|
|
2955
3490
|
// Expired leases report under their own gate id so neither the enforcement posture nor
|
|
2956
3491
|
// the daily block cap can quietly turn this denial into a warning.
|
|
2957
3492
|
const gateId = matchDetails && matchDetails.taskScopeViolation
|
|
2958
3493
|
&& matchDetails.taskScopeViolation.reasonCode === 'expired_task_scope'
|
|
2959
3494
|
? TASK_SCOPE_LEASE_EXPIRED_GATE_ID
|
|
2960
3495
|
: gate.id;
|
|
2961
|
-
const
|
|
3496
|
+
const overrideMessage = adminOverride
|
|
3497
|
+
? `${message} Approval request: ${adminOverride.escalationId}. Action digest: sha256:${adminOverride.approvalContextDigest}.${adminOverride.replayed ? ' The approved override was already consumed.' : ''}`
|
|
3498
|
+
: message;
|
|
3499
|
+
const denyResult = {
|
|
3500
|
+
decision: 'deny',
|
|
3501
|
+
gate: gateId,
|
|
3502
|
+
message: overrideMessage,
|
|
3503
|
+
severity: gate.severity,
|
|
3504
|
+
reasoning,
|
|
3505
|
+
...(adminOverride ? { requiresAdminOverride: true, adminOverride } : {}),
|
|
3506
|
+
};
|
|
2962
3507
|
// Free-tier daily block cap: after N blocks/day, deny → warn + upgrade CTA
|
|
2963
3508
|
const cappedResult = applyDailyBlockCap(denyResult);
|
|
2964
3509
|
if (cappedResult) {
|
|
@@ -2968,7 +3513,7 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
|
|
|
2968
3513
|
return cappedResult;
|
|
2969
3514
|
}
|
|
2970
3515
|
recordStat(gate.id, 'block', gate, { toolName, toolInput });
|
|
2971
|
-
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'deny', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
|
|
3516
|
+
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'deny', gateId: gate.id, message: overrideMessage, severity: gate.severity, source: 'gates-engine' });
|
|
2972
3517
|
auditToFeedback(auditRecord);
|
|
2973
3518
|
return denyResult;
|
|
2974
3519
|
}
|
|
@@ -3056,6 +3601,22 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
|
|
|
3056
3601
|
return sentinelResult;
|
|
3057
3602
|
}
|
|
3058
3603
|
|
|
3604
|
+
const brokerReceiptResultAsync = evaluateBrokerReceiptGate(toolName, toolInput);
|
|
3605
|
+
if (brokerReceiptResultAsync && brokerReceiptResultAsync.decision === 'deny') {
|
|
3606
|
+
recordStat(brokerReceiptResultAsync.gate, 'block', null, { toolName, toolInput });
|
|
3607
|
+
const auditRecord = recordAuditEvent({
|
|
3608
|
+
toolName,
|
|
3609
|
+
toolInput,
|
|
3610
|
+
decision: 'deny',
|
|
3611
|
+
gateId: brokerReceiptResultAsync.gate,
|
|
3612
|
+
message: brokerReceiptResultAsync.message,
|
|
3613
|
+
severity: brokerReceiptResultAsync.severity,
|
|
3614
|
+
source: 'broker-execution-receipts',
|
|
3615
|
+
});
|
|
3616
|
+
auditToFeedback(auditRecord);
|
|
3617
|
+
return brokerReceiptResultAsync;
|
|
3618
|
+
}
|
|
3619
|
+
|
|
3059
3620
|
// Audit trail: record allow (no gate matched)
|
|
3060
3621
|
recordAuditEvent({ toolName, toolInput, decision: 'allow', source: 'gates-engine' });
|
|
3061
3622
|
return null;
|
|
@@ -3068,6 +3629,27 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
|
|
|
3068
3629
|
try {
|
|
3069
3630
|
const { selectHarness } = require('./harness-selector');
|
|
3070
3631
|
harnessPath = selectHarness(toolName, toolInput);
|
|
3632
|
+
try {
|
|
3633
|
+
// Opt-in RateBurst only (THUMBGATE_RADWARE_RATE=1). Never persist in tests/CI.
|
|
3634
|
+
if (process.env.THUMBGATE_RADWARE_RATE === '1') {
|
|
3635
|
+
const radware = require('./radware-threat-defense.js');
|
|
3636
|
+
const inTest = Boolean(process.env.NODE_TEST || process.env.NODE_TEST_CONTEXT || process.env.CI || process.env.GITHUB_ACTIONS || process.env.VITEST || process.argv.some((a) => a.includes('node:test') || a.endsWith('.test.js')));
|
|
3637
|
+
if (!inTest) {
|
|
3638
|
+
radware.persistCallTimestamp();
|
|
3639
|
+
const burst = radware.checkRateBurst(radware.loadCallTimestamps());
|
|
3640
|
+
if (burst.tripped) {
|
|
3641
|
+
return recordStructuralGateBlock(toolName, toolInput, {
|
|
3642
|
+
decision: 'deny',
|
|
3643
|
+
gate: 'algorithmic-token-drain-circuit-breaker',
|
|
3644
|
+
action: 'block',
|
|
3645
|
+
severity: 'high',
|
|
3646
|
+
message: burst.message,
|
|
3647
|
+
receipt: 'threat_defense_interdicted=true:type=RateBurst:action=block',
|
|
3648
|
+
});
|
|
3649
|
+
}
|
|
3650
|
+
}
|
|
3651
|
+
}
|
|
3652
|
+
} catch { /* optional */ }
|
|
3071
3653
|
} catch { /* harness-selector is optional */ }
|
|
3072
3654
|
config = loadGatesConfig(configPath, harnessPath);
|
|
3073
3655
|
} catch {
|
|
@@ -3091,6 +3673,10 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
|
|
|
3091
3673
|
if (statefulHelperBypassGate) {
|
|
3092
3674
|
return recordStructuralGateBlock(toolName, toolInput, statefulHelperBypassGate);
|
|
3093
3675
|
}
|
|
3676
|
+
const stealthMemoryInjectionGate = evaluateStealthMemoryInjection(toolName, toolInput);
|
|
3677
|
+
if (stealthMemoryInjectionGate) {
|
|
3678
|
+
return recordStructuralGateBlock(toolName, toolInput, stealthMemoryInjectionGate);
|
|
3679
|
+
}
|
|
3094
3680
|
if (isBreakGlassSettingsRecoveryAction(toolName, toolInput)) {
|
|
3095
3681
|
recordAuditEvent({
|
|
3096
3682
|
toolName,
|
|
@@ -3104,6 +3690,22 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
|
|
|
3104
3690
|
return null;
|
|
3105
3691
|
}
|
|
3106
3692
|
|
|
3693
|
+
const agentIdentityLifecycleGate = evaluateAgentIdentityLifecycleGate(toolName, toolInput);
|
|
3694
|
+
if (agentIdentityLifecycleGate) {
|
|
3695
|
+
recordStat(agentIdentityLifecycleGate.gate, 'block', null, { toolName, toolInput });
|
|
3696
|
+
const identityAuditRecord = recordAuditEvent({
|
|
3697
|
+
toolName,
|
|
3698
|
+
toolInput,
|
|
3699
|
+
decision: 'deny',
|
|
3700
|
+
gateId: agentIdentityLifecycleGate.gate,
|
|
3701
|
+
message: agentIdentityLifecycleGate.message,
|
|
3702
|
+
severity: agentIdentityLifecycleGate.severity,
|
|
3703
|
+
source: 'gates-engine',
|
|
3704
|
+
});
|
|
3705
|
+
auditToFeedback(identityAuditRecord);
|
|
3706
|
+
return agentIdentityLifecycleGate;
|
|
3707
|
+
}
|
|
3708
|
+
|
|
3107
3709
|
const pendingThreadResolutionGate = evaluatePendingPrThreadResolutionGate(toolName, toolInput);
|
|
3108
3710
|
if (pendingThreadResolutionGate) {
|
|
3109
3711
|
recordStat(pendingThreadResolutionGate.gate, 'block', null, { toolName, toolInput });
|
|
@@ -3181,13 +3783,45 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
|
|
|
3181
3783
|
const reasoning = buildReasoning(gate, toolName, toolInput, matchDetails);
|
|
3182
3784
|
|
|
3183
3785
|
if (gate.action === 'block') {
|
|
3786
|
+
const adminOverride = evaluateAdminOverride(gate, toolName, toolInput);
|
|
3787
|
+
if (adminOverride && adminOverride.authorized) {
|
|
3788
|
+
recordStat(gate.id, 'approve', gate, { toolName, toolInput });
|
|
3789
|
+
const auditRecord = recordAuditEvent({
|
|
3790
|
+
toolName,
|
|
3791
|
+
toolInput,
|
|
3792
|
+
decision: 'allow',
|
|
3793
|
+
gateId: gate.id,
|
|
3794
|
+
message: `Single-use admin override consumed for sha256:${adminOverride.approvalContextDigest}.`,
|
|
3795
|
+
severity: gate.severity,
|
|
3796
|
+
source: 'gates-engine-admin-override',
|
|
3797
|
+
});
|
|
3798
|
+
auditToFeedback(auditRecord);
|
|
3799
|
+
return {
|
|
3800
|
+
decision: 'allow',
|
|
3801
|
+
gate: gate.id,
|
|
3802
|
+
message: `Single-use admin override consumed for sha256:${adminOverride.approvalContextDigest}.`,
|
|
3803
|
+
severity: gate.severity,
|
|
3804
|
+
reasoning,
|
|
3805
|
+
adminOverride,
|
|
3806
|
+
};
|
|
3807
|
+
}
|
|
3184
3808
|
// Expired leases report under their own gate id so neither the enforcement posture nor
|
|
3185
3809
|
// the daily block cap can quietly turn this denial into a warning.
|
|
3186
3810
|
const gateId = matchDetails && matchDetails.taskScopeViolation
|
|
3187
3811
|
&& matchDetails.taskScopeViolation.reasonCode === 'expired_task_scope'
|
|
3188
3812
|
? TASK_SCOPE_LEASE_EXPIRED_GATE_ID
|
|
3189
3813
|
: gate.id;
|
|
3190
|
-
const
|
|
3814
|
+
const overrideMessage = adminOverride
|
|
3815
|
+
? `${message} Approval request: ${adminOverride.escalationId}. Action digest: sha256:${adminOverride.approvalContextDigest}.${adminOverride.replayed ? ' The approved override was already consumed.' : ''}`
|
|
3816
|
+
: message;
|
|
3817
|
+
const denyResult = {
|
|
3818
|
+
decision: 'deny',
|
|
3819
|
+
gate: gateId,
|
|
3820
|
+
message: overrideMessage,
|
|
3821
|
+
severity: gate.severity,
|
|
3822
|
+
reasoning,
|
|
3823
|
+
...(adminOverride ? { requiresAdminOverride: true, adminOverride } : {}),
|
|
3824
|
+
};
|
|
3191
3825
|
// Free-tier daily block cap: after N blocks/day, deny → warn + upgrade CTA
|
|
3192
3826
|
const cappedResult = applyDailyBlockCap(denyResult);
|
|
3193
3827
|
if (cappedResult) {
|
|
@@ -3197,7 +3831,7 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
|
|
|
3197
3831
|
return cappedResult;
|
|
3198
3832
|
}
|
|
3199
3833
|
recordStat(gate.id, 'block', gate, { toolName, toolInput });
|
|
3200
|
-
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'deny', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
|
|
3834
|
+
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'deny', gateId: gate.id, message: overrideMessage, severity: gate.severity, source: 'gates-engine' });
|
|
3201
3835
|
auditToFeedback(auditRecord);
|
|
3202
3836
|
return denyResult;
|
|
3203
3837
|
}
|
|
@@ -3284,6 +3918,24 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
|
|
|
3284
3918
|
return sentinelResult;
|
|
3285
3919
|
}
|
|
3286
3920
|
|
|
3921
|
+
// Broker-signed execution receipts: verify attached proof; optionally require
|
|
3922
|
+
// for high-risk provider side effects (THUMBGATE_BROKER_RECEIPT_MODE=enforce).
|
|
3923
|
+
const brokerReceiptResult = evaluateBrokerReceiptGate(toolName, toolInput);
|
|
3924
|
+
if (brokerReceiptResult && brokerReceiptResult.decision === 'deny') {
|
|
3925
|
+
recordStat(brokerReceiptResult.gate, 'block', null, { toolName, toolInput });
|
|
3926
|
+
const auditRecord = recordAuditEvent({
|
|
3927
|
+
toolName,
|
|
3928
|
+
toolInput,
|
|
3929
|
+
decision: 'deny',
|
|
3930
|
+
gateId: brokerReceiptResult.gate,
|
|
3931
|
+
message: brokerReceiptResult.message,
|
|
3932
|
+
severity: brokerReceiptResult.severity,
|
|
3933
|
+
source: 'broker-execution-receipts',
|
|
3934
|
+
});
|
|
3935
|
+
auditToFeedback(auditRecord);
|
|
3936
|
+
return brokerReceiptResult;
|
|
3937
|
+
}
|
|
3938
|
+
|
|
3287
3939
|
// Audit trail: record allow
|
|
3288
3940
|
recordAuditEvent({ toolName, toolInput, decision: 'allow', source: 'gates-engine' });
|
|
3289
3941
|
return null;
|
|
@@ -3447,6 +4099,7 @@ function evaluateUnconditionalHardFloor(input = {}, options = {}) {
|
|
|
3447
4099
|
|
|
3448
4100
|
function evaluateFinancialHardFloor(input = {}, consumeReservation = false, options = {}) {
|
|
3449
4101
|
const toolName = input.tool_name || input.toolName || 'unknown';
|
|
4102
|
+
if (isRemedyToolName(toolName)) return null;
|
|
3450
4103
|
const rawToolInput = input.tool_input ?? input.toolInput;
|
|
3451
4104
|
const toolInput = rawToolInput && typeof rawToolInput === 'object'
|
|
3452
4105
|
? rawToolInput
|
|
@@ -4286,7 +4939,87 @@ function verifyClaimEvidence(claimText, options = {}) {
|
|
|
4286
4939
|
// Exports
|
|
4287
4940
|
// ---------------------------------------------------------------------------
|
|
4288
4941
|
|
|
4942
|
+
// ---------------------------------------------------------------------------
|
|
4943
|
+
// Agent identity plane (Okta AI-identity checklist: shadow AI + lifecycle)
|
|
4944
|
+
// ---------------------------------------------------------------------------
|
|
4945
|
+
|
|
4946
|
+
// Warn-dedup per process: one shadow/lifecycle warning per agent id, so the
|
|
4947
|
+
// audit log records the finding without spamming every tool call.
|
|
4948
|
+
const AGENT_IDENTITY_WARNED = new Set();
|
|
4949
|
+
|
|
4950
|
+
function resolveActingAgentId() {
|
|
4951
|
+
return process.env.THUMBGATE_SESSION_AGENT
|
|
4952
|
+
|| process.env.THUMBGATE_AGENT_ID
|
|
4953
|
+
|| null;
|
|
4954
|
+
}
|
|
4955
|
+
|
|
4956
|
+
/**
|
|
4957
|
+
* Identity gate for the enforced evaluation path. Every attributed tool call
|
|
4958
|
+
* is recorded as an observation (the producer side of shadow-AI detection).
|
|
4959
|
+
* An observed-but-unregistered agent is a shadow agent: warn-only on every
|
|
4960
|
+
* mode — a strict-mode deny here would brick sessions on repos where nothing
|
|
4961
|
+
* registers agents yet. A registry-retired or disabled agent that keeps
|
|
4962
|
+
* acting is denied under THUMBGATE_STRICT_ENFORCEMENT=1 and warned otherwise:
|
|
4963
|
+
* retirement is an explicit operator action, so enforcing it cannot surprise
|
|
4964
|
+
* a healthy session. Fails open on any registry error — identity tracking
|
|
4965
|
+
* must never break the hook path.
|
|
4966
|
+
*/
|
|
4967
|
+
function evaluateAgentIdentityLifecycleGate(toolName, toolInput) {
|
|
4968
|
+
try {
|
|
4969
|
+
const agentId = resolveActingAgentId();
|
|
4970
|
+
if (!agentId) return null;
|
|
4971
|
+
const identityStore = require('./audit-trail');
|
|
4972
|
+
try {
|
|
4973
|
+
identityStore.recordObservedAgent(agentId);
|
|
4974
|
+
} catch {
|
|
4975
|
+
// Observation is best-effort.
|
|
4976
|
+
}
|
|
4977
|
+
const registryRow = identityStore.loadAgentRegistry()
|
|
4978
|
+
.filter((agent) => agent && agent.id === agentId)
|
|
4979
|
+
.pop();
|
|
4980
|
+
const strict = process.env.THUMBGATE_STRICT_ENFORCEMENT === '1';
|
|
4981
|
+
const lifecycleStatus = registryRow?.metadata?.lifecycleStatus;
|
|
4982
|
+
if (registryRow && (lifecycleStatus === 'retired' || lifecycleStatus === 'disabled')) {
|
|
4983
|
+
const message = `Agent identity "${agentId}" is ${lifecycleStatus} in the agent registry but is still acting. `
|
|
4984
|
+
+ 'Re-activate it via registerAgent with lifecycleStatus "active", or stop the agent.';
|
|
4985
|
+
if (strict) {
|
|
4986
|
+
return { gate: 'agent-identity-lifecycle', decision: 'deny', message, severity: 'critical' };
|
|
4987
|
+
}
|
|
4988
|
+
if (!AGENT_IDENTITY_WARNED.has(`retired:${agentId}`)) {
|
|
4989
|
+
AGENT_IDENTITY_WARNED.add(`retired:${agentId}`);
|
|
4990
|
+
recordAuditEvent({
|
|
4991
|
+
toolName,
|
|
4992
|
+
toolInput,
|
|
4993
|
+
decision: 'warn',
|
|
4994
|
+
gateId: 'agent-identity-lifecycle',
|
|
4995
|
+
message,
|
|
4996
|
+
severity: 'high',
|
|
4997
|
+
source: 'gates-engine',
|
|
4998
|
+
});
|
|
4999
|
+
}
|
|
5000
|
+
return null;
|
|
5001
|
+
}
|
|
5002
|
+
if (!registryRow && !AGENT_IDENTITY_WARNED.has(`shadow:${agentId}`)) {
|
|
5003
|
+
AGENT_IDENTITY_WARNED.add(`shadow:${agentId}`);
|
|
5004
|
+
recordAuditEvent({
|
|
5005
|
+
toolName,
|
|
5006
|
+
toolInput,
|
|
5007
|
+
decision: 'warn',
|
|
5008
|
+
gateId: 'agent-identity-shadow',
|
|
5009
|
+
message: `Shadow agent: "${agentId}" is acting but has never been registered in the agent registry. `
|
|
5010
|
+
+ 'Register it via registerAgent so identity, lifecycle, and audit attribution are explicit.',
|
|
5011
|
+
severity: 'medium',
|
|
5012
|
+
source: 'gates-engine',
|
|
5013
|
+
});
|
|
5014
|
+
}
|
|
5015
|
+
return null;
|
|
5016
|
+
} catch {
|
|
5017
|
+
return null;
|
|
5018
|
+
}
|
|
5019
|
+
}
|
|
5020
|
+
|
|
4289
5021
|
module.exports = {
|
|
5022
|
+
evaluateAgentIdentityLifecycleGate,
|
|
4290
5023
|
loadGatesConfig,
|
|
4291
5024
|
loadState,
|
|
4292
5025
|
saveState,
|
|
@@ -4299,6 +5032,8 @@ module.exports = {
|
|
|
4299
5032
|
isTaskScopeExpired,
|
|
4300
5033
|
TASK_SCOPE_LEASE_EXPIRED_GATE_ID,
|
|
4301
5034
|
applyEnforcementPosture,
|
|
5035
|
+
resolveGovernanceMode,
|
|
5036
|
+
alignmentLayerForResult,
|
|
4302
5037
|
buildTaskScopeViolation,
|
|
4303
5038
|
setBranchGovernance,
|
|
4304
5039
|
approveProtectedAction,
|
|
@@ -4317,8 +5052,17 @@ module.exports = {
|
|
|
4317
5052
|
matchesGate,
|
|
4318
5053
|
evaluateGates,
|
|
4319
5054
|
evaluateGatesAsync,
|
|
5055
|
+
actionApprovalDigest,
|
|
4320
5056
|
buildMatchSurfaces,
|
|
4321
5057
|
extractAffectedFiles,
|
|
5058
|
+
extractGitMinusCPaths,
|
|
5059
|
+
resolveRepoRoot,
|
|
5060
|
+
governanceStatePath,
|
|
5061
|
+
currentScopeSessionId,
|
|
5062
|
+
isRemedyToolName,
|
|
5063
|
+
isCommandPositionPermissionChange,
|
|
5064
|
+
isGhApiPrCreateCommand,
|
|
5065
|
+
helperBypassActionKey,
|
|
4322
5066
|
parseGitPathspec,
|
|
4323
5067
|
canonicalizeGitCommand,
|
|
4324
5068
|
canonicalizeCommandForGates,
|
|
@@ -4376,6 +5120,7 @@ module.exports = {
|
|
|
4376
5120
|
evaluateLocalOnlyRemoteSideEffectGate,
|
|
4377
5121
|
recordHelperScriptWrite,
|
|
4378
5122
|
evaluateStatefulHelperBypassGate,
|
|
5123
|
+
evaluateStealthMemoryInjection,
|
|
4379
5124
|
isAgentHookSettingsFile,
|
|
4380
5125
|
isBreakGlassSettingsRecoveryAction,
|
|
4381
5126
|
PR_THREAD_RESOLUTION_ACTION,
|
|
@@ -4384,6 +5129,8 @@ module.exports = {
|
|
|
4384
5129
|
applyDailyBlockCap,
|
|
4385
5130
|
getTodayBlockCount,
|
|
4386
5131
|
incrementTodayBlockCount,
|
|
5132
|
+
effectiveCommandCwd,
|
|
5133
|
+
resolveRepoRoot,
|
|
4387
5134
|
};
|
|
4388
5135
|
|
|
4389
5136
|
// ---------------------------------------------------------------------------
|