thumbgate 1.34.3 → 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 +373 -5
- package/bin/futureagi-bridge +9 -0
- package/config/gate-templates.json +653 -4
- package/config/gates/actor-critic-audit.json +34 -0
- package/config/gates/default.json +21 -2
- 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 +10 -1
- package/config/model-candidates.json +382 -24
- 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 +401 -147
- 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/auto-promote-gates.js +178 -27
- 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-progress.js +111 -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-limits.js +27 -0
- package/scripts/dashboard.js +279 -9
- 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/gate-stats.js +2 -2
- package/scripts/gates-engine.js +859 -58
- 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 +802 -185
- 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,6 +186,10 @@ 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',
|
|
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).
|
|
192
|
+
'outbound-email-send',
|
|
171
193
|
TASK_SCOPE_LEASE_EXPIRED_GATE_ID,
|
|
172
194
|
...SELF_PROTECT_HARD_FLOOR_GATE_IDS,
|
|
173
195
|
]);
|
|
@@ -188,6 +210,8 @@ const CATASTROPHIC_DECLARATIVE_GATE_IDS = new Set([
|
|
|
188
210
|
'git-clean-force',
|
|
189
211
|
'rm-rf-home-or-root',
|
|
190
212
|
'financial-control',
|
|
213
|
+
// Never daily-cap discount a denied autonomous email send (same class as force-push).
|
|
214
|
+
'outbound-email-send',
|
|
191
215
|
]);
|
|
192
216
|
const SELF_PROTECT_CONFIG_TARGET_PATTERN = /(?:^|\/)(?:config\/gates\/|config\/(?:budget|enforcement|mcp-allowlists)\.json$|\.thumbgate\/config\.json$|thumbgate\.json$)/i;
|
|
193
217
|
const SELF_PROTECT_HOOK_TARGET_PATTERN = /(?:^|\/)(?:\.claude\/settings(?:\.local)?\.json|\.codex\/config\.toml|scripts\/hook-[^/]+\.(?:js|sh))$/i;
|
|
@@ -199,21 +223,76 @@ function isSelfProtectGate(gateId) {
|
|
|
199
223
|
return SELF_PROTECT_HARD_FLOOR_GATE_IDS.has(gateId);
|
|
200
224
|
}
|
|
201
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
|
+
|
|
202
255
|
function applyEnforcementPosture(result) {
|
|
203
256
|
if (!result || (result.decision !== 'deny' && result.decision !== 'approve')) return result;
|
|
257
|
+
const mode = resolveGovernanceMode();
|
|
258
|
+
const alignmentLayer = alignmentLayerForResult(result);
|
|
204
259
|
// Defensive backstop: hard-floor results must never be posture-downgraded.
|
|
205
|
-
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
|
+
}
|
|
206
279
|
// Full hard enforcement opt-in: keep every deny.
|
|
207
|
-
if (process.env.THUMBGATE_STRICT_ENFORCEMENT === '1')
|
|
280
|
+
if (mode === 'live' || process.env.THUMBGATE_STRICT_ENFORCEMENT === '1') {
|
|
281
|
+
return { ...result, governanceMode: 'live', alignmentLayer };
|
|
282
|
+
}
|
|
208
283
|
// Honor the explicit strict-knowledge-conflict opt-in for that gate.
|
|
209
|
-
if (process.env.THUMBGATE_STRICT_KNOWLEDGE_CONFLICT === '1' && result.gate === 'knowledge-conflict-gate')
|
|
210
|
-
|
|
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
|
|
211
288
|
// with the warning surfaced instead of hard-blocked, so legitimate work is never blocked.
|
|
212
289
|
return {
|
|
213
290
|
...result,
|
|
214
291
|
decision: 'warn',
|
|
215
292
|
warnByDefault: true,
|
|
216
|
-
|
|
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.`,
|
|
217
296
|
};
|
|
218
297
|
}
|
|
219
298
|
const BREAK_GLASS_CONDITION = 'thumbgate_break_glass';
|
|
@@ -256,19 +335,51 @@ function commandContainsSequence(words, sequence) {
|
|
|
256
335
|
}
|
|
257
336
|
|
|
258
337
|
function commandHasPostMethod(words) {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
+
}
|
|
263
350
|
}
|
|
264
|
-
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;
|
|
265
372
|
}
|
|
266
373
|
|
|
267
374
|
function isGhApiPrCreateCommand(command) {
|
|
268
375
|
const words = commandWords(command);
|
|
269
376
|
if (!commandContainsSequence(words, ['gh', 'api'])) return false;
|
|
270
|
-
const
|
|
271
|
-
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;
|
|
272
383
|
const fieldFlags = new Set(['-f', '--field', '--raw-field']);
|
|
273
384
|
const hasFieldWrite = words.some((word) => (
|
|
274
385
|
fieldFlags.has(word) ||
|
|
@@ -467,8 +578,39 @@ function isTaskScopeExpired(taskScope, nowMs = Date.now()) {
|
|
|
467
578
|
return nowMs >= deadline;
|
|
468
579
|
}
|
|
469
580
|
|
|
470
|
-
function
|
|
471
|
-
|
|
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));
|
|
472
614
|
const state = {
|
|
473
615
|
taskScope: raw && raw.taskScope && typeof raw.taskScope === 'object' ? raw.taskScope : null,
|
|
474
616
|
protectedApprovals: Array.isArray(raw && raw.protectedApprovals) ? raw.protectedApprovals : [],
|
|
@@ -489,35 +631,142 @@ function loadGovernanceState() {
|
|
|
489
631
|
const activeApprovals = state.protectedApprovals.filter((entry) => {
|
|
490
632
|
if (!entry || typeof entry !== 'object') return false;
|
|
491
633
|
if (!entry.timestamp || !entry.expiresAt) return false;
|
|
492
|
-
return
|
|
634
|
+
return entry.expiresAt > now;
|
|
493
635
|
});
|
|
494
|
-
|
|
495
|
-
state.protectedApprovals = activeApprovals;
|
|
496
|
-
saveGovernanceState(state);
|
|
497
|
-
}
|
|
636
|
+
state.protectedApprovals = activeApprovals;
|
|
498
637
|
return state;
|
|
499
638
|
}
|
|
500
639
|
|
|
501
|
-
function saveGovernanceState(state) {
|
|
640
|
+
function saveGovernanceState(state, explicitSessionId = null) {
|
|
502
641
|
const next = {
|
|
503
642
|
taskScope: state && state.taskScope ? state.taskScope : null,
|
|
504
643
|
protectedApprovals: Array.isArray(state && state.protectedApprovals) ? state.protectedApprovals : [],
|
|
505
644
|
branchGovernance: state && state.branchGovernance ? state.branchGovernance : null,
|
|
506
645
|
workflowContract: state && state.workflowContract ? state.workflowContract : null,
|
|
507
646
|
};
|
|
508
|
-
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
|
+
};
|
|
509
757
|
}
|
|
510
758
|
|
|
511
759
|
function setTaskScope(scopeInput = {}) {
|
|
760
|
+
const explicitSessionId = scopeInput && scopeInput.sessionId ? String(scopeInput.sessionId).trim() : null;
|
|
512
761
|
if (scopeInput && scopeInput.clear === true) {
|
|
513
|
-
const currentState = loadGovernanceState();
|
|
762
|
+
const currentState = loadGovernanceState(explicitSessionId);
|
|
514
763
|
const cleared = {
|
|
515
764
|
taskScope: null,
|
|
516
765
|
protectedApprovals: currentState.protectedApprovals,
|
|
517
766
|
branchGovernance: currentState.branchGovernance,
|
|
518
767
|
workflowContract: null,
|
|
519
768
|
};
|
|
520
|
-
saveGovernanceState(cleared);
|
|
769
|
+
saveGovernanceState(cleared, explicitSessionId);
|
|
521
770
|
refreshLocalOnlyConstraint(cleared);
|
|
522
771
|
return null;
|
|
523
772
|
}
|
|
@@ -540,6 +789,7 @@ function setTaskScope(scopeInput = {}) {
|
|
|
540
789
|
const leaseMs = scopeInput.ttlMs == null ? null : clampTtlMs(scopeInput.ttlMs, TASK_SCOPE_LEASE_MS);
|
|
541
790
|
const taskScope = {
|
|
542
791
|
taskId: String(scopeInput.taskId || '').trim() || null,
|
|
792
|
+
sessionId: currentScopeSessionId(explicitSessionId),
|
|
543
793
|
summary: String(scopeInput.summary || '').trim() || null,
|
|
544
794
|
allowedPaths,
|
|
545
795
|
protectedPaths,
|
|
@@ -550,12 +800,12 @@ function setTaskScope(scopeInput = {}) {
|
|
|
550
800
|
leaseMs,
|
|
551
801
|
expiresAt: leaseMs == null ? null : scopeNow + leaseMs,
|
|
552
802
|
};
|
|
553
|
-
const state = loadGovernanceState();
|
|
803
|
+
const state = loadGovernanceState(explicitSessionId);
|
|
554
804
|
state.taskScope = taskScope;
|
|
555
805
|
state.workflowContract = scopeInput.workflowContract && typeof scopeInput.workflowContract === 'object'
|
|
556
806
|
? scopeInput.workflowContract
|
|
557
807
|
: null;
|
|
558
|
-
saveGovernanceState(state);
|
|
808
|
+
saveGovernanceState(state, explicitSessionId);
|
|
559
809
|
if (taskScope.localOnly) {
|
|
560
810
|
setConstraint('local_only', true);
|
|
561
811
|
}
|
|
@@ -601,7 +851,14 @@ function breakGlassEmergency(input = {}) {
|
|
|
601
851
|
const gates = ['pr_create_allowed', 'pr_threads_checked', BREAK_GLASS_CONDITION];
|
|
602
852
|
const satisfied = {};
|
|
603
853
|
for (const gateId of gates) {
|
|
604
|
-
|
|
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
|
+
});
|
|
605
862
|
}
|
|
606
863
|
|
|
607
864
|
const approval = approveProtectedAction({
|
|
@@ -660,8 +917,9 @@ function setBranchGovernance(input = {}) {
|
|
|
660
917
|
return governance;
|
|
661
918
|
}
|
|
662
919
|
|
|
663
|
-
function getScopeState() {
|
|
664
|
-
|
|
920
|
+
function getScopeState(options = {}) {
|
|
921
|
+
const sessionId = typeof options === 'string' ? options : (options?.sessionId || process.env.THUMBGATE_SESSION_AGENT || null);
|
|
922
|
+
return loadGovernanceState(sessionId);
|
|
665
923
|
}
|
|
666
924
|
|
|
667
925
|
function getBranchGovernanceState() {
|
|
@@ -704,7 +962,7 @@ function isConditionSatisfied(conditionId) {
|
|
|
704
962
|
return age < TTL_MS;
|
|
705
963
|
}
|
|
706
964
|
|
|
707
|
-
function satisfyCondition(conditionId, evidence, structuredReasoning) {
|
|
965
|
+
function satisfyCondition(conditionId, evidence, structuredReasoning, options = {}) {
|
|
708
966
|
const state = loadState();
|
|
709
967
|
const entry = {
|
|
710
968
|
timestamp: Date.now(),
|
|
@@ -720,6 +978,28 @@ function satisfyCondition(conditionId, evidence, structuredReasoning) {
|
|
|
720
978
|
}
|
|
721
979
|
state[conditionId] = entry;
|
|
722
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
|
+
|
|
723
1003
|
return entry;
|
|
724
1004
|
}
|
|
725
1005
|
|
|
@@ -900,14 +1180,111 @@ function safeExecFileLines(binary, args, cwd) {
|
|
|
900
1180
|
}
|
|
901
1181
|
}
|
|
902
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
|
+
|
|
903
1252
|
function resolveRepoRoot(toolInput = {}) {
|
|
904
|
-
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,
|
|
905
1278
|
toolInput.repoPath,
|
|
906
1279
|
toolInput.cwd,
|
|
907
1280
|
process.cwd(),
|
|
908
|
-
]
|
|
909
|
-
|
|
910
|
-
|
|
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
|
+
}
|
|
911
1288
|
|
|
912
1289
|
for (const cwd of candidates) {
|
|
913
1290
|
try {
|
|
@@ -1130,7 +1507,17 @@ function parseGitPathspec(command, subcommand, options = {}) {
|
|
|
1130
1507
|
// null as "unknown" and fall back to broad.
|
|
1131
1508
|
function effectiveCommandCwd(command, toolInput) {
|
|
1132
1509
|
let cwd = String(toolInput?.cwd || toolInput?.repoPath || process.cwd());
|
|
1133
|
-
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|&&|\|\||[;|&]/);
|
|
1134
1521
|
for (const segment of segments) {
|
|
1135
1522
|
// Parsed without a regex: /^cd\s+(?:--\s+)?(.+)$/ has adjacent \s+ groups that backtrack
|
|
1136
1523
|
// polynomially on input like `cd\t\t\t…` (js/polynomial-redos). The command comes
|
|
@@ -1144,8 +1531,12 @@ function effectiveCommandCwd(command, toolInput) {
|
|
|
1144
1531
|
else if (argText.startsWith('--') && /^[ \t]/.test(argText.slice(2))) argText = argText.slice(2).trim();
|
|
1145
1532
|
const target = tokenizeShellWords(argText)[0];
|
|
1146
1533
|
if (!target) break; // bare `cd` -> home; leave scope resolution alone
|
|
1147
|
-
if (/[*?$`]
|
|
1148
|
-
|
|
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);
|
|
1149
1540
|
}
|
|
1150
1541
|
return cwd;
|
|
1151
1542
|
}
|
|
@@ -1371,8 +1762,10 @@ function extractAffectedFiles(toolName, toolInput = {}) {
|
|
|
1371
1762
|
}
|
|
1372
1763
|
|
|
1373
1764
|
if (/\bgit\s+push\b/i.test(command) || /\bgh\s+pr\s+(?:create|merge)\b/i.test(command) || isGhApiPrCreateCommand(command)) {
|
|
1374
|
-
|
|
1375
|
-
|
|
1765
|
+
if (files.size === 0) {
|
|
1766
|
+
for (const filePath of getBranchDiffFiles(repoRoot)) {
|
|
1767
|
+
files.add(normalizePosix(filePath));
|
|
1768
|
+
}
|
|
1376
1769
|
}
|
|
1377
1770
|
}
|
|
1378
1771
|
}
|
|
@@ -1686,9 +2079,51 @@ function isThreadResolutionSatisfied() {
|
|
|
1686
2079
|
));
|
|
1687
2080
|
}
|
|
1688
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
|
+
|
|
1689
2124
|
function isThreadResolutionEvidenceAction(toolName, toolInput = {}) {
|
|
1690
2125
|
if (isGitCommitCommand(toolName, toolInput)) return true;
|
|
1691
|
-
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;
|
|
1692
2127
|
if (toolName !== 'Bash') return false;
|
|
1693
2128
|
const command = String(toolInput.command || '');
|
|
1694
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);
|
|
@@ -1728,7 +2163,9 @@ function getReadOnlyToolNames() {
|
|
|
1728
2163
|
return names;
|
|
1729
2164
|
}
|
|
1730
2165
|
function isReadOnlyObservabilityTool(toolName) {
|
|
1731
|
-
|
|
2166
|
+
if (!toolName) return false;
|
|
2167
|
+
const names = getReadOnlyToolNames();
|
|
2168
|
+
return names.has(toolName) || names.has(bareToolName(toolName));
|
|
1732
2169
|
}
|
|
1733
2170
|
|
|
1734
2171
|
function evaluatePendingPrThreadResolutionGate(toolName, toolInput = {}) {
|
|
@@ -1772,7 +2209,7 @@ function evaluatePendingPrThreadResolutionGate(toolName, toolInput = {}) {
|
|
|
1772
2209
|
severity: 'critical',
|
|
1773
2210
|
reasoning: [
|
|
1774
2211
|
`Tracked action ${PR_THREAD_RESOLUTION_ACTION} is pending`,
|
|
1775
|
-
'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',
|
|
1776
2213
|
],
|
|
1777
2214
|
};
|
|
1778
2215
|
}
|
|
@@ -1867,10 +2304,15 @@ function recordHelperScriptWrite(toolName, toolInput = {}) {
|
|
|
1867
2304
|
packageScriptTouched,
|
|
1868
2305
|
reasons,
|
|
1869
2306
|
};
|
|
1870
|
-
trackAction(
|
|
2307
|
+
trackAction(helperBypassActionKey(), metadata);
|
|
1871
2308
|
return metadata;
|
|
1872
2309
|
}
|
|
1873
2310
|
|
|
2311
|
+
function helperBypassActionKey() {
|
|
2312
|
+
const sessionId = currentScopeSessionId();
|
|
2313
|
+
return sessionId ? `${HELPER_BYPASS_ACTION}:${sessionId}` : HELPER_BYPASS_ACTION;
|
|
2314
|
+
}
|
|
2315
|
+
|
|
1874
2316
|
function evaluateStatefulHelperBypassGate(toolName, toolInput = {}) {
|
|
1875
2317
|
if (process.env.THUMBGATE_HELPER_BYPASS_GUARD === '0') return null;
|
|
1876
2318
|
if (toolName !== 'Bash') {
|
|
@@ -1896,7 +2338,7 @@ function evaluateStatefulHelperBypassGate(toolName, toolInput = {}) {
|
|
|
1896
2338
|
|
|
1897
2339
|
const writeMetadata = recordHelperScriptWrite(toolName, toolInput);
|
|
1898
2340
|
const actions = listSessionActions();
|
|
1899
|
-
const recentWrite = actions[
|
|
2341
|
+
const recentWrite = actions[helperBypassActionKey()];
|
|
1900
2342
|
const recentMetadata = recentWrite && recentWrite.metadata && typeof recentWrite.metadata === 'object'
|
|
1901
2343
|
? recentWrite.metadata
|
|
1902
2344
|
: null;
|
|
@@ -1969,6 +2411,10 @@ function evaluateCatastrophicDeclarativeGate(config, constraints, toolName, tool
|
|
|
1969
2411
|
for (const gate of config.gates) {
|
|
1970
2412
|
if (!CATASTROPHIC_DECLARATIVE_GATE_IDS.has(gate.id)) continue;
|
|
1971
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;
|
|
1972
2418
|
|
|
1973
2419
|
const matchDetails = matchGate(gate, toolName, toolInput);
|
|
1974
2420
|
if (!matchDetails.matched) continue;
|
|
@@ -2274,8 +2720,52 @@ function checkWhenClause(when, constraints) {
|
|
|
2274
2720
|
return true;
|
|
2275
2721
|
}
|
|
2276
2722
|
|
|
2723
|
+
|
|
2724
|
+
/**
|
|
2725
|
+
* Surfaces a gate pattern can match against.
|
|
2726
|
+
*
|
|
2727
|
+
* Historically only `toolInput.command|file_path|path` was considered. That
|
|
2728
|
+
* made every MCP / non-Bash tool call invisible to pattern gates — including
|
|
2729
|
+
* Gmail `send_message`, Apollo emailer send, and any auto-promoted rule that
|
|
2730
|
+
* mentioned a tool by name. Those gates rendered as "active" with
|
|
2731
|
+
* lastFiredAt:null forever (AGENT-259).
|
|
2732
|
+
*
|
|
2733
|
+
* Surfaces, in order:
|
|
2734
|
+
* 1. Bash/command text (preserves existing `^` anchor behavior)
|
|
2735
|
+
* 2. bare tool name (MCP tools)
|
|
2736
|
+
* 3. tool name + command
|
|
2737
|
+
* 4. common URL/endpoint/action fields (HTTP-shaped tool inputs)
|
|
2738
|
+
*
|
|
2739
|
+
* Body/content is intentionally excluded to avoid false blocks when an agent
|
|
2740
|
+
* *edits code that mentions* a send endpoint.
|
|
2741
|
+
*/
|
|
2742
|
+
function buildMatchSurfaces(toolName, toolInput = {}) {
|
|
2743
|
+
const name = String(toolName || '').trim();
|
|
2744
|
+
const command = String(toolInput.command || '').trim();
|
|
2745
|
+
const filePath = String(toolInput.file_path || toolInput.path || '').trim();
|
|
2746
|
+
const surfaces = [];
|
|
2747
|
+
const push = (s) => {
|
|
2748
|
+
const v = String(s || '').trim();
|
|
2749
|
+
if (v && !surfaces.includes(v)) surfaces.push(v);
|
|
2750
|
+
};
|
|
2751
|
+
push(command);
|
|
2752
|
+
push(filePath);
|
|
2753
|
+
push(name);
|
|
2754
|
+
if (name && command) push(`${name} ${command}`);
|
|
2755
|
+
if (name && filePath) push(`${name} ${filePath}`);
|
|
2756
|
+
for (const key of ['url', 'endpoint', 'method', 'action', 'path']) {
|
|
2757
|
+
if (toolInput[key] == null) continue;
|
|
2758
|
+
const v = String(toolInput[key]).slice(0, 400);
|
|
2759
|
+
push(v);
|
|
2760
|
+
if (name) push(`${name} ${v}`);
|
|
2761
|
+
}
|
|
2762
|
+
return surfaces;
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2277
2765
|
function matchGate(gate, toolName, toolInput = {}) {
|
|
2278
|
-
|
|
2766
|
+
// Primary text for audit/reasoning: prefer command, then tool name (MCP).
|
|
2767
|
+
const matchSurfaces = buildMatchSurfaces(toolName, toolInput);
|
|
2768
|
+
let matchText = matchSurfaces[0] || String(toolName || '');
|
|
2279
2769
|
|
|
2280
2770
|
// Claw/hybrid support: enrich matchText with claw metadata (for EnterpriseClaw/OpenShell/Perplexity hybrid agents)
|
|
2281
2771
|
const clawCtx = toolInput.clawContext || toolInput._claw || (toolInput.agentId ? {
|
|
@@ -2309,6 +2799,7 @@ function matchGate(gate, toolName, toolInput = {}) {
|
|
|
2309
2799
|
}
|
|
2310
2800
|
|
|
2311
2801
|
matchText = parts.filter(Boolean).join(' | ');
|
|
2802
|
+
if (!matchSurfaces.includes(matchText)) matchSurfaces.push(matchText);
|
|
2312
2803
|
}
|
|
2313
2804
|
|
|
2314
2805
|
const affected = extractAffectedFiles(toolName, toolInput);
|
|
@@ -2349,14 +2840,27 @@ function matchGate(gate, toolName, toolInput = {}) {
|
|
|
2349
2840
|
|
|
2350
2841
|
if (gate.pattern) {
|
|
2351
2842
|
try {
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
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
|
+
}
|
|
2360
2864
|
}
|
|
2361
2865
|
if (isBreakGlassSettingsBypass(gate, affectedFiles)) {
|
|
2362
2866
|
return { matched: false, matchText, affectedFiles };
|
|
@@ -2423,7 +2927,6 @@ function matchGate(gate, toolName, toolInput = {}) {
|
|
|
2423
2927
|
matchText,
|
|
2424
2928
|
affectedFiles,
|
|
2425
2929
|
taskScopeViolation,
|
|
2426
|
-
protectedApprovalViolation,
|
|
2427
2930
|
branchGovernanceViolation,
|
|
2428
2931
|
};
|
|
2429
2932
|
}
|
|
@@ -2449,6 +2952,7 @@ function matchSelfProtectHardFloor(gate, toolName, toolInput = {}) {
|
|
|
2449
2952
|
|
|
2450
2953
|
const command = String(toolInput.command || '');
|
|
2451
2954
|
let matchText = command;
|
|
2955
|
+
|
|
2452
2956
|
if (gate.id === 'self-protect-config' || gate.id === 'self-protect-hooks-disable') {
|
|
2453
2957
|
const targetPattern = gate.id === 'self-protect-config'
|
|
2454
2958
|
? SELF_PROTECT_CONFIG_TARGET_PATTERN
|
|
@@ -2460,7 +2964,28 @@ function matchSelfProtectHardFloor(gate, toolName, toolInput = {}) {
|
|
|
2460
2964
|
const commandTargetPattern = gate.id === 'self-protect-config'
|
|
2461
2965
|
? SELF_PROTECT_CONFIG_COMMAND_PATTERN
|
|
2462
2966
|
: SELF_PROTECT_HOOK_COMMAND_PATTERN;
|
|
2463
|
-
|
|
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
|
+
}
|
|
2464
2989
|
} else {
|
|
2465
2990
|
return null;
|
|
2466
2991
|
}
|
|
@@ -2760,6 +3285,27 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
|
|
|
2760
3285
|
try {
|
|
2761
3286
|
const { selectHarness } = require('./harness-selector');
|
|
2762
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 */ }
|
|
2763
3309
|
} catch { /* harness-selector is optional */ }
|
|
2764
3310
|
config = loadGatesConfig(configPath, harnessPath);
|
|
2765
3311
|
} catch {
|
|
@@ -2782,6 +3328,10 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
|
|
|
2782
3328
|
if (statefulHelperBypassGate) {
|
|
2783
3329
|
return recordStructuralGateBlock(toolName, toolInput, statefulHelperBypassGate);
|
|
2784
3330
|
}
|
|
3331
|
+
const stealthMemoryInjectionGate = evaluateStealthMemoryInjection(toolName, toolInput);
|
|
3332
|
+
if (stealthMemoryInjectionGate) {
|
|
3333
|
+
return recordStructuralGateBlock(toolName, toolInput, stealthMemoryInjectionGate);
|
|
3334
|
+
}
|
|
2785
3335
|
if (isBreakGlassSettingsRecoveryAction(toolName, toolInput)) {
|
|
2786
3336
|
recordAuditEvent({
|
|
2787
3337
|
toolName,
|
|
@@ -2795,6 +3345,22 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
|
|
|
2795
3345
|
return null;
|
|
2796
3346
|
}
|
|
2797
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
|
+
|
|
2798
3364
|
const pendingThreadResolutionGate = evaluatePendingPrThreadResolutionGate(toolName, toolInput);
|
|
2799
3365
|
if (pendingThreadResolutionGate) {
|
|
2800
3366
|
recordStat(pendingThreadResolutionGate.gate, 'block', null, { toolName, toolInput });
|
|
@@ -2899,13 +3465,45 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
|
|
|
2899
3465
|
});
|
|
2900
3466
|
|
|
2901
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
|
+
}
|
|
2902
3490
|
// Expired leases report under their own gate id so neither the enforcement posture nor
|
|
2903
3491
|
// the daily block cap can quietly turn this denial into a warning.
|
|
2904
3492
|
const gateId = matchDetails && matchDetails.taskScopeViolation
|
|
2905
3493
|
&& matchDetails.taskScopeViolation.reasonCode === 'expired_task_scope'
|
|
2906
3494
|
? TASK_SCOPE_LEASE_EXPIRED_GATE_ID
|
|
2907
3495
|
: gate.id;
|
|
2908
|
-
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
|
+
};
|
|
2909
3507
|
// Free-tier daily block cap: after N blocks/day, deny → warn + upgrade CTA
|
|
2910
3508
|
const cappedResult = applyDailyBlockCap(denyResult);
|
|
2911
3509
|
if (cappedResult) {
|
|
@@ -2915,7 +3513,7 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
|
|
|
2915
3513
|
return cappedResult;
|
|
2916
3514
|
}
|
|
2917
3515
|
recordStat(gate.id, 'block', gate, { toolName, toolInput });
|
|
2918
|
-
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' });
|
|
2919
3517
|
auditToFeedback(auditRecord);
|
|
2920
3518
|
return denyResult;
|
|
2921
3519
|
}
|
|
@@ -3003,6 +3601,22 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
|
|
|
3003
3601
|
return sentinelResult;
|
|
3004
3602
|
}
|
|
3005
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
|
+
|
|
3006
3620
|
// Audit trail: record allow (no gate matched)
|
|
3007
3621
|
recordAuditEvent({ toolName, toolInput, decision: 'allow', source: 'gates-engine' });
|
|
3008
3622
|
return null;
|
|
@@ -3015,6 +3629,27 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
|
|
|
3015
3629
|
try {
|
|
3016
3630
|
const { selectHarness } = require('./harness-selector');
|
|
3017
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 */ }
|
|
3018
3653
|
} catch { /* harness-selector is optional */ }
|
|
3019
3654
|
config = loadGatesConfig(configPath, harnessPath);
|
|
3020
3655
|
} catch {
|
|
@@ -3038,6 +3673,10 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
|
|
|
3038
3673
|
if (statefulHelperBypassGate) {
|
|
3039
3674
|
return recordStructuralGateBlock(toolName, toolInput, statefulHelperBypassGate);
|
|
3040
3675
|
}
|
|
3676
|
+
const stealthMemoryInjectionGate = evaluateStealthMemoryInjection(toolName, toolInput);
|
|
3677
|
+
if (stealthMemoryInjectionGate) {
|
|
3678
|
+
return recordStructuralGateBlock(toolName, toolInput, stealthMemoryInjectionGate);
|
|
3679
|
+
}
|
|
3041
3680
|
if (isBreakGlassSettingsRecoveryAction(toolName, toolInput)) {
|
|
3042
3681
|
recordAuditEvent({
|
|
3043
3682
|
toolName,
|
|
@@ -3051,6 +3690,22 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
|
|
|
3051
3690
|
return null;
|
|
3052
3691
|
}
|
|
3053
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
|
+
|
|
3054
3709
|
const pendingThreadResolutionGate = evaluatePendingPrThreadResolutionGate(toolName, toolInput);
|
|
3055
3710
|
if (pendingThreadResolutionGate) {
|
|
3056
3711
|
recordStat(pendingThreadResolutionGate.gate, 'block', null, { toolName, toolInput });
|
|
@@ -3128,13 +3783,45 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
|
|
|
3128
3783
|
const reasoning = buildReasoning(gate, toolName, toolInput, matchDetails);
|
|
3129
3784
|
|
|
3130
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
|
+
}
|
|
3131
3808
|
// Expired leases report under their own gate id so neither the enforcement posture nor
|
|
3132
3809
|
// the daily block cap can quietly turn this denial into a warning.
|
|
3133
3810
|
const gateId = matchDetails && matchDetails.taskScopeViolation
|
|
3134
3811
|
&& matchDetails.taskScopeViolation.reasonCode === 'expired_task_scope'
|
|
3135
3812
|
? TASK_SCOPE_LEASE_EXPIRED_GATE_ID
|
|
3136
3813
|
: gate.id;
|
|
3137
|
-
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
|
+
};
|
|
3138
3825
|
// Free-tier daily block cap: after N blocks/day, deny → warn + upgrade CTA
|
|
3139
3826
|
const cappedResult = applyDailyBlockCap(denyResult);
|
|
3140
3827
|
if (cappedResult) {
|
|
@@ -3144,7 +3831,7 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
|
|
|
3144
3831
|
return cappedResult;
|
|
3145
3832
|
}
|
|
3146
3833
|
recordStat(gate.id, 'block', gate, { toolName, toolInput });
|
|
3147
|
-
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' });
|
|
3148
3835
|
auditToFeedback(auditRecord);
|
|
3149
3836
|
return denyResult;
|
|
3150
3837
|
}
|
|
@@ -3231,6 +3918,24 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
|
|
|
3231
3918
|
return sentinelResult;
|
|
3232
3919
|
}
|
|
3233
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
|
+
|
|
3234
3939
|
// Audit trail: record allow
|
|
3235
3940
|
recordAuditEvent({ toolName, toolInput, decision: 'allow', source: 'gates-engine' });
|
|
3236
3941
|
return null;
|
|
@@ -3394,6 +4099,7 @@ function evaluateUnconditionalHardFloor(input = {}, options = {}) {
|
|
|
3394
4099
|
|
|
3395
4100
|
function evaluateFinancialHardFloor(input = {}, consumeReservation = false, options = {}) {
|
|
3396
4101
|
const toolName = input.tool_name || input.toolName || 'unknown';
|
|
4102
|
+
if (isRemedyToolName(toolName)) return null;
|
|
3397
4103
|
const rawToolInput = input.tool_input ?? input.toolInput;
|
|
3398
4104
|
const toolInput = rawToolInput && typeof rawToolInput === 'object'
|
|
3399
4105
|
? rawToolInput
|
|
@@ -4233,7 +4939,87 @@ function verifyClaimEvidence(claimText, options = {}) {
|
|
|
4233
4939
|
// Exports
|
|
4234
4940
|
// ---------------------------------------------------------------------------
|
|
4235
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
|
+
|
|
4236
5021
|
module.exports = {
|
|
5022
|
+
evaluateAgentIdentityLifecycleGate,
|
|
4237
5023
|
loadGatesConfig,
|
|
4238
5024
|
loadState,
|
|
4239
5025
|
saveState,
|
|
@@ -4246,6 +5032,8 @@ module.exports = {
|
|
|
4246
5032
|
isTaskScopeExpired,
|
|
4247
5033
|
TASK_SCOPE_LEASE_EXPIRED_GATE_ID,
|
|
4248
5034
|
applyEnforcementPosture,
|
|
5035
|
+
resolveGovernanceMode,
|
|
5036
|
+
alignmentLayerForResult,
|
|
4249
5037
|
buildTaskScopeViolation,
|
|
4250
5038
|
setBranchGovernance,
|
|
4251
5039
|
approveProtectedAction,
|
|
@@ -4264,7 +5052,17 @@ module.exports = {
|
|
|
4264
5052
|
matchesGate,
|
|
4265
5053
|
evaluateGates,
|
|
4266
5054
|
evaluateGatesAsync,
|
|
5055
|
+
actionApprovalDigest,
|
|
5056
|
+
buildMatchSurfaces,
|
|
4267
5057
|
extractAffectedFiles,
|
|
5058
|
+
extractGitMinusCPaths,
|
|
5059
|
+
resolveRepoRoot,
|
|
5060
|
+
governanceStatePath,
|
|
5061
|
+
currentScopeSessionId,
|
|
5062
|
+
isRemedyToolName,
|
|
5063
|
+
isCommandPositionPermissionChange,
|
|
5064
|
+
isGhApiPrCreateCommand,
|
|
5065
|
+
helperBypassActionKey,
|
|
4268
5066
|
parseGitPathspec,
|
|
4269
5067
|
canonicalizeGitCommand,
|
|
4270
5068
|
canonicalizeCommandForGates,
|
|
@@ -4322,6 +5120,7 @@ module.exports = {
|
|
|
4322
5120
|
evaluateLocalOnlyRemoteSideEffectGate,
|
|
4323
5121
|
recordHelperScriptWrite,
|
|
4324
5122
|
evaluateStatefulHelperBypassGate,
|
|
5123
|
+
evaluateStealthMemoryInjection,
|
|
4325
5124
|
isAgentHookSettingsFile,
|
|
4326
5125
|
isBreakGlassSettingsRecoveryAction,
|
|
4327
5126
|
PR_THREAD_RESOLUTION_ACTION,
|
|
@@ -4330,6 +5129,8 @@ module.exports = {
|
|
|
4330
5129
|
applyDailyBlockCap,
|
|
4331
5130
|
getTodayBlockCount,
|
|
4332
5131
|
incrementTodayBlockCount,
|
|
5132
|
+
effectiveCommandCwd,
|
|
5133
|
+
resolveRepoRoot,
|
|
4333
5134
|
};
|
|
4334
5135
|
|
|
4335
5136
|
// ---------------------------------------------------------------------------
|