thumbgate 1.35.0 → 1.37.2
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/cobble-hot-store-compare-not-clone/SKILL.md +80 -0
- package/.agents/skills/colab-compute-honesty-not-clone/SKILL.md +70 -0
- 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/token-shunt-honesty-not-clone/SKILL.md +67 -0
- package/.agents/skills/typesafe-typed-questions-not-clone/SKILL.md +82 -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 +465 -3
- package/bin/futureagi-bridge +9 -0
- package/config/gate-templates.json +629 -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 +408 -150
- 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 +203 -1
- package/scripts/cobble-hot-store-split.js +600 -0
- package/scripts/codex-runbook-flywheel.js +318 -0
- package/scripts/colab-compute-honesty.js +281 -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 +826 -68
- 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/token-shunt-honesty.js +502 -0
- package/scripts/tool-kpi-tracker.js +108 -5
- package/scripts/tool-registry.js +193 -5
- package/scripts/typesafe-typed-questions.js +813 -0
- 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',
|
|
@@ -128,6 +146,10 @@ const BOOSTED_RISK_MIN_EXAMPLES = 3;
|
|
|
128
146
|
const PR_THREAD_RESOLUTION_ACTION = 'pr_thread_resolution_verified_after_commit';
|
|
129
147
|
const HELPER_BYPASS_ACTION = 'helper_script_modified';
|
|
130
148
|
const KNOWLEDGE_ENTROPY_THRESHOLD = 0.7;
|
|
149
|
+
// Issue #3689: do not inject lessons that only barely matched. Retrieval already
|
|
150
|
+
// filters >0.1 for ranking; injection requires a higher bar so low-relevance
|
|
151
|
+
// memories cannot ride along with an entropy disclaimer.
|
|
152
|
+
const MIN_LESSON_INJECTION_RELEVANCE = 0.75;
|
|
131
153
|
// Generous character bound: keeps every affected file for realistic actions while still
|
|
132
154
|
// preventing an unbounded haystack. Chosen over a file-count cap, which dropped targets.
|
|
133
155
|
const MEMORY_GUARD_MAX_SERIALIZED_CHARS = 200000;
|
|
@@ -168,8 +190,9 @@ const UNCONDITIONAL_HARD_FLOOR_GATE_IDS = new Set([
|
|
|
168
190
|
// applyEnforcementPosture, never demote spend blocks to warn-by-default.
|
|
169
191
|
// Apollo $588 incident class (2026-08).
|
|
170
192
|
'financial-control',
|
|
171
|
-
// Outbound email: irreversible delivery.
|
|
172
|
-
//
|
|
193
|
+
// Outbound email: irreversible delivery. Keep the hard gate intact until a
|
|
194
|
+
// separately authenticated admin override bound to the exact action digest
|
|
195
|
+
// authorizes one retry (AGENT-259 / District Cyber 2026-08-04).
|
|
173
196
|
'outbound-email-send',
|
|
174
197
|
TASK_SCOPE_LEASE_EXPIRED_GATE_ID,
|
|
175
198
|
...SELF_PROTECT_HARD_FLOOR_GATE_IDS,
|
|
@@ -191,7 +214,7 @@ const CATASTROPHIC_DECLARATIVE_GATE_IDS = new Set([
|
|
|
191
214
|
'git-clean-force',
|
|
192
215
|
'rm-rf-home-or-root',
|
|
193
216
|
'financial-control',
|
|
194
|
-
// Never daily-cap discount
|
|
217
|
+
// Never daily-cap discount a denied autonomous email send (same class as force-push).
|
|
195
218
|
'outbound-email-send',
|
|
196
219
|
]);
|
|
197
220
|
const SELF_PROTECT_CONFIG_TARGET_PATTERN = /(?:^|\/)(?:config\/gates\/|config\/(?:budget|enforcement|mcp-allowlists)\.json$|\.thumbgate\/config\.json$|thumbgate\.json$)/i;
|
|
@@ -204,21 +227,76 @@ function isSelfProtectGate(gateId) {
|
|
|
204
227
|
return SELF_PROTECT_HARD_FLOOR_GATE_IDS.has(gateId);
|
|
205
228
|
}
|
|
206
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Name the existing warn-by-default vs strict postures in Trustwise Control Tower
|
|
232
|
+
* vocabulary (live / sidecar / simulation / batch) without cloning their product.
|
|
233
|
+
* Default remains sidecar (= warn-by-default). Hard floors never demote.
|
|
234
|
+
*/
|
|
235
|
+
function resolveGovernanceMode(env = process.env) {
|
|
236
|
+
// Strict opt-in wins over inherited simulation/batch. Hermes CLI sets
|
|
237
|
+
// THUMBGATE_STRICT_ENFORCEMENT=1 internally; a leftover shadow mode in the
|
|
238
|
+
// process env must not demote those denials.
|
|
239
|
+
if (env.THUMBGATE_STRICT_ENFORCEMENT === '1') return 'live';
|
|
240
|
+
const raw = String(env.THUMBGATE_GOVERNANCE_MODE || '').trim().toLowerCase();
|
|
241
|
+
if (raw === 'simulation' || raw === 'batch') return raw;
|
|
242
|
+
if (raw === 'live') return 'live';
|
|
243
|
+
return 'sidecar';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function alignmentLayerForResult(result) {
|
|
247
|
+
const gate = String(result && result.gate || '');
|
|
248
|
+
if (
|
|
249
|
+
UNCONDITIONAL_HARD_FLOOR_GATE_IDS.has(gate)
|
|
250
|
+
|| CATASTROPHIC_DECLARATIVE_GATE_IDS.has(gate)
|
|
251
|
+
|| /secret|self-protect|exfil/i.test(gate)
|
|
252
|
+
) {
|
|
253
|
+
return 'safety';
|
|
254
|
+
}
|
|
255
|
+
if (/task-scope|local-only|pr_thread|sla/i.test(gate)) return 'sla';
|
|
256
|
+
return 'business';
|
|
257
|
+
}
|
|
258
|
+
|
|
207
259
|
function applyEnforcementPosture(result) {
|
|
208
260
|
if (!result || (result.decision !== 'deny' && result.decision !== 'approve')) return result;
|
|
261
|
+
const mode = resolveGovernanceMode();
|
|
262
|
+
const alignmentLayer = alignmentLayerForResult(result);
|
|
209
263
|
// Defensive backstop: hard-floor results must never be posture-downgraded.
|
|
210
|
-
if (UNCONDITIONAL_HARD_FLOOR_GATE_IDS.has(result.gate))
|
|
264
|
+
if (UNCONDITIONAL_HARD_FLOOR_GATE_IDS.has(result.gate)) {
|
|
265
|
+
return { ...result, governanceMode: mode, alignmentLayer };
|
|
266
|
+
}
|
|
267
|
+
// Simulation/batch shadow live traffic: record, do not block (except floors).
|
|
268
|
+
// Only denials are shadowed — allow/approve stay as-is.
|
|
269
|
+
if (mode === 'simulation' || mode === 'batch') {
|
|
270
|
+
if (result.decision === 'deny') {
|
|
271
|
+
return {
|
|
272
|
+
...result,
|
|
273
|
+
decision: 'warn',
|
|
274
|
+
warnByDefault: true,
|
|
275
|
+
governanceMode: mode,
|
|
276
|
+
alignmentLayer,
|
|
277
|
+
simulated: true,
|
|
278
|
+
message: `${result.message}\n\n⚠️ ThumbGate governance mode=${mode} — flagged and logged, not blocked.`,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
return { ...result, governanceMode: mode, alignmentLayer, simulated: true };
|
|
282
|
+
}
|
|
211
283
|
// Full hard enforcement opt-in: keep every deny.
|
|
212
|
-
if (process.env.THUMBGATE_STRICT_ENFORCEMENT === '1')
|
|
284
|
+
if (mode === 'live' || process.env.THUMBGATE_STRICT_ENFORCEMENT === '1') {
|
|
285
|
+
return { ...result, governanceMode: 'live', alignmentLayer };
|
|
286
|
+
}
|
|
213
287
|
// 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
|
-
|
|
288
|
+
if (process.env.THUMBGATE_STRICT_KNOWLEDGE_CONFLICT === '1' && result.gate === 'knowledge-conflict-gate') {
|
|
289
|
+
return { ...result, governanceMode: mode, alignmentLayer };
|
|
290
|
+
}
|
|
291
|
+
// Sidecar / warn-by-default: the gate still fired and is recorded; the action is allowed through
|
|
216
292
|
// with the warning surfaced instead of hard-blocked, so legitimate work is never blocked.
|
|
217
293
|
return {
|
|
218
294
|
...result,
|
|
219
295
|
decision: 'warn',
|
|
220
296
|
warnByDefault: true,
|
|
221
|
-
|
|
297
|
+
governanceMode: 'sidecar',
|
|
298
|
+
alignmentLayer,
|
|
299
|
+
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
300
|
};
|
|
223
301
|
}
|
|
224
302
|
const BREAK_GLASS_CONDITION = 'thumbgate_break_glass';
|
|
@@ -261,19 +339,51 @@ function commandContainsSequence(words, sequence) {
|
|
|
261
339
|
}
|
|
262
340
|
|
|
263
341
|
function commandHasPostMethod(words) {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
342
|
+
return ghApiHttpMethod(words) === 'post';
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function ghApiHttpMethod(words) {
|
|
346
|
+
const list = Array.isArray(words) ? words : [];
|
|
347
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
348
|
+
const word = list[i];
|
|
349
|
+
if ((word === '-x' || word === '--method') && list[i + 1]) return String(list[i + 1]).toLowerCase();
|
|
350
|
+
if (word.startsWith('--method=')) return word.slice('--method='.length).toLowerCase();
|
|
351
|
+
if (word.startsWith('-x') && word.length > 2 && !word.startsWith('-x=')) {
|
|
352
|
+
return word.slice(2).toLowerCase();
|
|
353
|
+
}
|
|
268
354
|
}
|
|
269
|
-
return
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function ghApiEndpoint(words) {
|
|
359
|
+
const list = Array.isArray(words) ? words : [];
|
|
360
|
+
const apiIndex = list.findIndex((word, i) => word === 'api' && list[i - 1] === 'gh');
|
|
361
|
+
if (apiIndex < 0) return null;
|
|
362
|
+
const flagsWithValue = new Set([
|
|
363
|
+
'-x', '--method', '-f', '--field', '-F', '--raw-field',
|
|
364
|
+
'-h', '--header', '--hostname', '--jq', '--input', '--cache',
|
|
365
|
+
]);
|
|
366
|
+
for (let i = apiIndex + 1; i < list.length; i += 1) {
|
|
367
|
+
const word = list[i];
|
|
368
|
+
if (word.startsWith('-')) {
|
|
369
|
+
if (word.includes('=')) continue;
|
|
370
|
+
if (flagsWithValue.has(word)) i += 1;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
return word;
|
|
374
|
+
}
|
|
375
|
+
return null;
|
|
270
376
|
}
|
|
271
377
|
|
|
272
378
|
function isGhApiPrCreateCommand(command) {
|
|
273
379
|
const words = commandWords(command);
|
|
274
380
|
if (!commandContainsSequence(words, ['gh', 'api'])) return false;
|
|
275
|
-
const
|
|
276
|
-
if (
|
|
381
|
+
const method = ghApiHttpMethod(words);
|
|
382
|
+
if (method && method !== 'post') return false;
|
|
383
|
+
const endpoint = ghApiEndpoint(words);
|
|
384
|
+
if (!endpoint) return false;
|
|
385
|
+
if (/\/pulls\/\d+/.test(endpoint)) return false;
|
|
386
|
+
if (!(endpoint === '/pulls' || /\/pulls$/.test(endpoint))) return false;
|
|
277
387
|
const fieldFlags = new Set(['-f', '--field', '--raw-field']);
|
|
278
388
|
const hasFieldWrite = words.some((word) => (
|
|
279
389
|
fieldFlags.has(word) ||
|
|
@@ -472,8 +582,39 @@ function isTaskScopeExpired(taskScope, nowMs = Date.now()) {
|
|
|
472
582
|
return nowMs >= deadline;
|
|
473
583
|
}
|
|
474
584
|
|
|
475
|
-
function
|
|
476
|
-
|
|
585
|
+
function currentScopeSessionId(explicitSessionId = null) {
|
|
586
|
+
if (explicitSessionId) return String(explicitSessionId).trim();
|
|
587
|
+
const raw = String(
|
|
588
|
+
process.env.THUMBGATE_SESSION_AGENT
|
|
589
|
+
|| process.env.THUMBGATE_SESSION_ID
|
|
590
|
+
|| process.env.CLAUDE_SESSION_ID
|
|
591
|
+
|| ''
|
|
592
|
+
).trim();
|
|
593
|
+
return raw || null;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function sanitizeScopeSessionId(sessionId) {
|
|
597
|
+
if (!sessionId) return '';
|
|
598
|
+
const hash = crypto.createHash('sha256').update(String(sessionId)).digest('hex').slice(0, 16);
|
|
599
|
+
const prefix = String(sessionId).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 32);
|
|
600
|
+
return prefix ? `${prefix}-${hash}` : hash;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Sibling agents share ~/.thumbgate/governance-state.json today, so one
|
|
604
|
+
// set_task_scope rebinds every other live session (#3522). When a session id
|
|
605
|
+
// is present, persist a per-session file next to the legacy slot.
|
|
606
|
+
function governanceStatePath(explicitSessionId = null) {
|
|
607
|
+
const base = module.exports.GOVERNANCE_STATE_PATH;
|
|
608
|
+
const sessionId = currentScopeSessionId(explicitSessionId);
|
|
609
|
+
if (!sessionId) return base;
|
|
610
|
+
const safe = sanitizeScopeSessionId(sessionId);
|
|
611
|
+
if (!safe) return base;
|
|
612
|
+
const parsed = path.parse(base);
|
|
613
|
+
return path.join(parsed.dir, `${parsed.name}.${safe}${parsed.ext}`);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function loadGovernanceState(explicitSessionId = null) {
|
|
617
|
+
const raw = loadJSON(governanceStatePath(explicitSessionId));
|
|
477
618
|
const state = {
|
|
478
619
|
taskScope: raw && raw.taskScope && typeof raw.taskScope === 'object' ? raw.taskScope : null,
|
|
479
620
|
protectedApprovals: Array.isArray(raw && raw.protectedApprovals) ? raw.protectedApprovals : [],
|
|
@@ -494,35 +635,142 @@ function loadGovernanceState() {
|
|
|
494
635
|
const activeApprovals = state.protectedApprovals.filter((entry) => {
|
|
495
636
|
if (!entry || typeof entry !== 'object') return false;
|
|
496
637
|
if (!entry.timestamp || !entry.expiresAt) return false;
|
|
497
|
-
return
|
|
638
|
+
return entry.expiresAt > now;
|
|
498
639
|
});
|
|
499
|
-
|
|
500
|
-
state.protectedApprovals = activeApprovals;
|
|
501
|
-
saveGovernanceState(state);
|
|
502
|
-
}
|
|
640
|
+
state.protectedApprovals = activeApprovals;
|
|
503
641
|
return state;
|
|
504
642
|
}
|
|
505
643
|
|
|
506
|
-
function saveGovernanceState(state) {
|
|
644
|
+
function saveGovernanceState(state, explicitSessionId = null) {
|
|
507
645
|
const next = {
|
|
508
646
|
taskScope: state && state.taskScope ? state.taskScope : null,
|
|
509
647
|
protectedApprovals: Array.isArray(state && state.protectedApprovals) ? state.protectedApprovals : [],
|
|
510
648
|
branchGovernance: state && state.branchGovernance ? state.branchGovernance : null,
|
|
511
649
|
workflowContract: state && state.workflowContract ? state.workflowContract : null,
|
|
512
650
|
};
|
|
513
|
-
saveJSON(
|
|
651
|
+
saveJSON(governanceStatePath(explicitSessionId), next);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function stableCanonicalStringify(value) {
|
|
655
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
656
|
+
if (Array.isArray(value)) return `[${value.map(stableCanonicalStringify).join(',')}]`;
|
|
657
|
+
return `{${Object.keys(value).sort().map((key) => (
|
|
658
|
+
`${JSON.stringify(key)}:${stableCanonicalStringify(value[key])}`
|
|
659
|
+
)).join(',')}}`;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function actionApprovalDigest(toolName, toolInput) {
|
|
663
|
+
return crypto.createHash('sha256').update(stableCanonicalStringify({
|
|
664
|
+
toolInput: toolInput && typeof toolInput === 'object' ? toolInput : {},
|
|
665
|
+
toolName: String(toolName || ''),
|
|
666
|
+
})).digest('hex');
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function adminOverrideTaskId(gateId, digest) {
|
|
670
|
+
return `admin-override:${gateId}:${digest}`;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function selectAdminOverrideAttempt(baseTaskId) {
|
|
674
|
+
const attempts = listEscalations().filter((entry) => (
|
|
675
|
+
entry.taskId === baseTaskId || String(entry.taskId || '').startsWith(`${baseTaskId}:attempt:`)
|
|
676
|
+
));
|
|
677
|
+
const latest = attempts[0] || null;
|
|
678
|
+
if (!latest) return baseTaskId;
|
|
679
|
+
const terminal = ['rejected', 'cancelled', 'expired'].includes(latest.status)
|
|
680
|
+
|| latest.eventType === 'consumed'
|
|
681
|
+
|| Date.parse(latest.expiresAt) <= Date.now();
|
|
682
|
+
if (!terminal) return latest.taskId;
|
|
683
|
+
const highestAttempt = attempts.reduce((highest, entry) => {
|
|
684
|
+
const match = String(entry.taskId || '').match(/:attempt:(\d+)$/);
|
|
685
|
+
return Math.max(highest, match ? Number(match[1]) : 1);
|
|
686
|
+
}, 1);
|
|
687
|
+
return `${baseTaskId}:attempt:${highestAttempt + 1}`;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function evaluateAdminOverride(gate, toolName, toolInput) {
|
|
691
|
+
const policy = gate && gate.adminOverride;
|
|
692
|
+
if (!policy || policy.required !== true) return null;
|
|
693
|
+
|
|
694
|
+
const approvalContextDigest = actionApprovalDigest(toolName, toolInput);
|
|
695
|
+
const baseTaskId = adminOverrideTaskId(gate.id, approvalContextDigest);
|
|
696
|
+
let taskId;
|
|
697
|
+
try {
|
|
698
|
+
taskId = selectAdminOverrideAttempt(baseTaskId);
|
|
699
|
+
} catch {
|
|
700
|
+
return {
|
|
701
|
+
authorized: false,
|
|
702
|
+
approvalContextDigest,
|
|
703
|
+
escalationId: null,
|
|
704
|
+
taskId: baseTaskId,
|
|
705
|
+
approvalUnavailable: true,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
const ttlMs = Math.min(
|
|
709
|
+
60 * 60 * 1000,
|
|
710
|
+
Math.max(60 * 1000, Number(policy.ttlMs) || DEFAULT_ADMIN_OVERRIDE_TTL_MS),
|
|
711
|
+
);
|
|
712
|
+
let escalation;
|
|
713
|
+
try {
|
|
714
|
+
escalation = requestEscalation({
|
|
715
|
+
taskId,
|
|
716
|
+
reason: `Admin override required for hard gate '${gate.id}'.`,
|
|
717
|
+
severity: gate.severity || 'critical',
|
|
718
|
+
requester: { id: 'thumbgate-gates-engine', kind: 'service' },
|
|
719
|
+
evidence: [`sha256:${approvalContextDigest}`],
|
|
720
|
+
ttlMs,
|
|
721
|
+
idempotencyKey: taskId,
|
|
722
|
+
approvalContextDigest,
|
|
723
|
+
requiredReviewerRole: 'admin',
|
|
724
|
+
});
|
|
725
|
+
} catch {
|
|
726
|
+
return {
|
|
727
|
+
authorized: false,
|
|
728
|
+
approvalContextDigest,
|
|
729
|
+
escalationId: null,
|
|
730
|
+
taskId,
|
|
731
|
+
approvalUnavailable: true,
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
const escalationId = escalation.escalation.escalationId;
|
|
735
|
+
let consumption = null;
|
|
736
|
+
try {
|
|
737
|
+
consumption = consumeVerifiedApproval(escalationId, {
|
|
738
|
+
consumer: { id: 'thumbgate-gates-engine', kind: 'service' },
|
|
739
|
+
});
|
|
740
|
+
} catch {
|
|
741
|
+
consumption = null;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (!consumption || !consumption.consumed) {
|
|
745
|
+
return {
|
|
746
|
+
authorized: false,
|
|
747
|
+
approvalContextDigest,
|
|
748
|
+
escalationId,
|
|
749
|
+
taskId,
|
|
750
|
+
replayed: consumption?.replayed === true,
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
return {
|
|
754
|
+
authorized: true,
|
|
755
|
+
approvalContextDigest,
|
|
756
|
+
escalationId,
|
|
757
|
+
taskId,
|
|
758
|
+
approver: consumption.approval.actor,
|
|
759
|
+
consumptionReceipt: consumption.consumption.eventHash,
|
|
760
|
+
};
|
|
514
761
|
}
|
|
515
762
|
|
|
516
763
|
function setTaskScope(scopeInput = {}) {
|
|
764
|
+
const explicitSessionId = scopeInput && scopeInput.sessionId ? String(scopeInput.sessionId).trim() : null;
|
|
517
765
|
if (scopeInput && scopeInput.clear === true) {
|
|
518
|
-
const currentState = loadGovernanceState();
|
|
766
|
+
const currentState = loadGovernanceState(explicitSessionId);
|
|
519
767
|
const cleared = {
|
|
520
768
|
taskScope: null,
|
|
521
769
|
protectedApprovals: currentState.protectedApprovals,
|
|
522
770
|
branchGovernance: currentState.branchGovernance,
|
|
523
771
|
workflowContract: null,
|
|
524
772
|
};
|
|
525
|
-
saveGovernanceState(cleared);
|
|
773
|
+
saveGovernanceState(cleared, explicitSessionId);
|
|
526
774
|
refreshLocalOnlyConstraint(cleared);
|
|
527
775
|
return null;
|
|
528
776
|
}
|
|
@@ -545,6 +793,7 @@ function setTaskScope(scopeInput = {}) {
|
|
|
545
793
|
const leaseMs = scopeInput.ttlMs == null ? null : clampTtlMs(scopeInput.ttlMs, TASK_SCOPE_LEASE_MS);
|
|
546
794
|
const taskScope = {
|
|
547
795
|
taskId: String(scopeInput.taskId || '').trim() || null,
|
|
796
|
+
sessionId: currentScopeSessionId(explicitSessionId),
|
|
548
797
|
summary: String(scopeInput.summary || '').trim() || null,
|
|
549
798
|
allowedPaths,
|
|
550
799
|
protectedPaths,
|
|
@@ -555,12 +804,12 @@ function setTaskScope(scopeInput = {}) {
|
|
|
555
804
|
leaseMs,
|
|
556
805
|
expiresAt: leaseMs == null ? null : scopeNow + leaseMs,
|
|
557
806
|
};
|
|
558
|
-
const state = loadGovernanceState();
|
|
807
|
+
const state = loadGovernanceState(explicitSessionId);
|
|
559
808
|
state.taskScope = taskScope;
|
|
560
809
|
state.workflowContract = scopeInput.workflowContract && typeof scopeInput.workflowContract === 'object'
|
|
561
810
|
? scopeInput.workflowContract
|
|
562
811
|
: null;
|
|
563
|
-
saveGovernanceState(state);
|
|
812
|
+
saveGovernanceState(state, explicitSessionId);
|
|
564
813
|
if (taskScope.localOnly) {
|
|
565
814
|
setConstraint('local_only', true);
|
|
566
815
|
}
|
|
@@ -606,7 +855,14 @@ function breakGlassEmergency(input = {}) {
|
|
|
606
855
|
const gates = ['pr_create_allowed', 'pr_threads_checked', BREAK_GLASS_CONDITION];
|
|
607
856
|
const satisfied = {};
|
|
608
857
|
for (const gateId of gates) {
|
|
609
|
-
|
|
858
|
+
// Break glass is the highest-scrutiny unlock there is. Without an explicit
|
|
859
|
+
// source these records defaulted to 'cli', hiding emergency overrides among
|
|
860
|
+
// ordinary ones — the exact case an auditor looks for first.
|
|
861
|
+
satisfied[gateId] = satisfyCondition(gateId, evidence, null, {
|
|
862
|
+
source: 'break-glass',
|
|
863
|
+
actor: input.actor || 'operator',
|
|
864
|
+
reason,
|
|
865
|
+
});
|
|
610
866
|
}
|
|
611
867
|
|
|
612
868
|
const approval = approveProtectedAction({
|
|
@@ -665,8 +921,9 @@ function setBranchGovernance(input = {}) {
|
|
|
665
921
|
return governance;
|
|
666
922
|
}
|
|
667
923
|
|
|
668
|
-
function getScopeState() {
|
|
669
|
-
|
|
924
|
+
function getScopeState(options = {}) {
|
|
925
|
+
const sessionId = typeof options === 'string' ? options : (options?.sessionId || process.env.THUMBGATE_SESSION_AGENT || null);
|
|
926
|
+
return loadGovernanceState(sessionId);
|
|
670
927
|
}
|
|
671
928
|
|
|
672
929
|
function getBranchGovernanceState() {
|
|
@@ -709,7 +966,7 @@ function isConditionSatisfied(conditionId) {
|
|
|
709
966
|
return age < TTL_MS;
|
|
710
967
|
}
|
|
711
968
|
|
|
712
|
-
function satisfyCondition(conditionId, evidence, structuredReasoning) {
|
|
969
|
+
function satisfyCondition(conditionId, evidence, structuredReasoning, options = {}) {
|
|
713
970
|
const state = loadState();
|
|
714
971
|
const entry = {
|
|
715
972
|
timestamp: Date.now(),
|
|
@@ -725,6 +982,28 @@ function satisfyCondition(conditionId, evidence, structuredReasoning) {
|
|
|
725
982
|
}
|
|
726
983
|
state[conditionId] = entry;
|
|
727
984
|
saveState(state);
|
|
985
|
+
|
|
986
|
+
// Satisfying a gate condition IS an override: it unlocks an action the gate
|
|
987
|
+
// had blocked. Previously this wrote only to the state store, so an override
|
|
988
|
+
// performed through the CLI left no trace in the audit trail at all — the
|
|
989
|
+
// least-supervised path was also the least-recorded. Record it explicitly.
|
|
990
|
+
// Never let an audit failure break the unlock the caller is relying on.
|
|
991
|
+
try {
|
|
992
|
+
// Lazy require: override-audit depends on audit-trail, which this module
|
|
993
|
+
// already loads; requiring at call time avoids any circular-init surprise.
|
|
994
|
+
const { recordOverride } = require('./override-audit');
|
|
995
|
+
recordOverride({
|
|
996
|
+
gateId: conditionId,
|
|
997
|
+
source: options.source || 'cli',
|
|
998
|
+
actor: options.actor,
|
|
999
|
+
reason: options.reason,
|
|
1000
|
+
evidence: entry.evidence,
|
|
1001
|
+
structuredReasoning: entry.structuredReasoning,
|
|
1002
|
+
});
|
|
1003
|
+
} catch {
|
|
1004
|
+
/* audit is best-effort; the gate state is authoritative */
|
|
1005
|
+
}
|
|
1006
|
+
|
|
728
1007
|
return entry;
|
|
729
1008
|
}
|
|
730
1009
|
|
|
@@ -905,14 +1184,111 @@ function safeExecFileLines(binary, args, cwd) {
|
|
|
905
1184
|
}
|
|
906
1185
|
}
|
|
907
1186
|
|
|
1187
|
+
function extractGitMinusCPaths(command) {
|
|
1188
|
+
const found = [];
|
|
1189
|
+
const segments = String(command || '').split(/\r?\n|&&|\|\||[;|&]/);
|
|
1190
|
+
for (const segment of segments) {
|
|
1191
|
+
const tokens = tokenizeShellWords(segment);
|
|
1192
|
+
const gitIdx = tokens.findIndex((token) => token === 'git' || /(?:^|\/)git$/.test(token));
|
|
1193
|
+
if (gitIdx === -1) continue;
|
|
1194
|
+
for (let i = gitIdx + 1; i < tokens.length; i += 1) {
|
|
1195
|
+
const token = tokens[i];
|
|
1196
|
+
if (token === '-C' && tokens[i + 1]) {
|
|
1197
|
+
found.push(tokens[i + 1]);
|
|
1198
|
+
i += 1;
|
|
1199
|
+
continue;
|
|
1200
|
+
}
|
|
1201
|
+
if (token === '--work-tree' && tokens[i + 1]) {
|
|
1202
|
+
found.push(tokens[i + 1]);
|
|
1203
|
+
i += 1;
|
|
1204
|
+
continue;
|
|
1205
|
+
}
|
|
1206
|
+
if (token.startsWith('--work-tree=')) {
|
|
1207
|
+
found.push(token.slice('--work-tree='.length));
|
|
1208
|
+
continue;
|
|
1209
|
+
}
|
|
1210
|
+
if (!token.startsWith('-')) break;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
return found;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
function extractGitContextPair(command) {
|
|
1217
|
+
const segments = String(command || '').split(/\r?\n|&&|\|\||[;|&]/);
|
|
1218
|
+
for (const segment of segments) {
|
|
1219
|
+
const tokens = tokenizeShellWords(segment);
|
|
1220
|
+
const gitIdx = tokens.findIndex((token) => token === 'git' || /(?:^|\/)git$/.test(token));
|
|
1221
|
+
if (gitIdx === -1) continue;
|
|
1222
|
+
let gitDir = null;
|
|
1223
|
+
let workTree = null;
|
|
1224
|
+
for (let i = gitIdx + 1; i < tokens.length; i += 1) {
|
|
1225
|
+
const token = tokens[i];
|
|
1226
|
+
if (token === '--git-dir' && tokens[i + 1]) {
|
|
1227
|
+
gitDir = tokens[i + 1];
|
|
1228
|
+
i += 1;
|
|
1229
|
+
continue;
|
|
1230
|
+
}
|
|
1231
|
+
if (token.startsWith('--git-dir=')) {
|
|
1232
|
+
gitDir = token.slice('--git-dir='.length);
|
|
1233
|
+
continue;
|
|
1234
|
+
}
|
|
1235
|
+
if (token === '--work-tree' && tokens[i + 1]) {
|
|
1236
|
+
workTree = tokens[i + 1];
|
|
1237
|
+
i += 1;
|
|
1238
|
+
continue;
|
|
1239
|
+
}
|
|
1240
|
+
if (token.startsWith('--work-tree=')) {
|
|
1241
|
+
workTree = token.slice('--work-tree='.length);
|
|
1242
|
+
continue;
|
|
1243
|
+
}
|
|
1244
|
+
if (token === '-C' && tokens[i + 1]) {
|
|
1245
|
+
workTree = tokens[i + 1];
|
|
1246
|
+
i += 1;
|
|
1247
|
+
continue;
|
|
1248
|
+
}
|
|
1249
|
+
if (!token.startsWith('-')) break;
|
|
1250
|
+
}
|
|
1251
|
+
if (gitDir || workTree) return { gitDir, workTree };
|
|
1252
|
+
}
|
|
1253
|
+
return null;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
908
1256
|
function resolveRepoRoot(toolInput = {}) {
|
|
909
|
-
const
|
|
1257
|
+
const command = String(toolInput.command || '');
|
|
1258
|
+
const baseCwd = toolInput.cwd ? path.resolve(String(toolInput.cwd)) : process.cwd();
|
|
1259
|
+
const paired = extractGitContextPair(command);
|
|
1260
|
+
if (paired && paired.gitDir && paired.workTree) {
|
|
1261
|
+
try {
|
|
1262
|
+
const resolvedGitDir = path.resolve(baseCwd, paired.gitDir);
|
|
1263
|
+
const resolvedWorkTree = path.resolve(baseCwd, paired.workTree);
|
|
1264
|
+
const root = execFileSync('git', ['--git-dir', resolvedGitDir, '--work-tree', resolvedWorkTree, 'rev-parse', '--show-toplevel'], {
|
|
1265
|
+
cwd: resolvedWorkTree,
|
|
1266
|
+
encoding: 'utf8',
|
|
1267
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1268
|
+
}).trim();
|
|
1269
|
+
if (root) return root;
|
|
1270
|
+
} catch {
|
|
1271
|
+
// Fall through to standard candidate probing
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
const minusC = extractGitMinusCPaths(command).map((entry) => path.resolve(baseCwd, entry));
|
|
1276
|
+
const commandCwd = effectiveCommandCwd(command, toolInput);
|
|
1277
|
+
const candidates = [];
|
|
1278
|
+
const seen = new Set();
|
|
1279
|
+
for (const value of [
|
|
1280
|
+
...minusC,
|
|
1281
|
+
commandCwd,
|
|
910
1282
|
toolInput.repoPath,
|
|
911
1283
|
toolInput.cwd,
|
|
912
1284
|
process.cwd(),
|
|
913
|
-
]
|
|
914
|
-
|
|
915
|
-
|
|
1285
|
+
]) {
|
|
1286
|
+
if (!value) continue;
|
|
1287
|
+
const resolved = path.resolve(String(value));
|
|
1288
|
+
if (seen.has(resolved)) continue;
|
|
1289
|
+
seen.add(resolved);
|
|
1290
|
+
candidates.push(resolved);
|
|
1291
|
+
}
|
|
916
1292
|
|
|
917
1293
|
for (const cwd of candidates) {
|
|
918
1294
|
try {
|
|
@@ -1135,7 +1511,17 @@ function parseGitPathspec(command, subcommand, options = {}) {
|
|
|
1135
1511
|
// null as "unknown" and fall back to broad.
|
|
1136
1512
|
function effectiveCommandCwd(command, toolInput) {
|
|
1137
1513
|
let cwd = String(toolInput?.cwd || toolInput?.repoPath || process.cwd());
|
|
1138
|
-
const
|
|
1514
|
+
const commandStr = String(command || '');
|
|
1515
|
+
const gitCMatch = commandStr.match(/(?:^|\s)git(?:\s+-[^\s]+)*\s+-C(?:\s+|=)(?:'([^']+)'|"([^"]+)"|([^\s'"]+))/i);
|
|
1516
|
+
const gitCDir = gitCMatch ? (gitCMatch[1] || gitCMatch[2] || gitCMatch[3] || '').trim() : '';
|
|
1517
|
+
if (gitCDir) {
|
|
1518
|
+
let target = gitCDir;
|
|
1519
|
+
if (target.startsWith('~/')) {
|
|
1520
|
+
target = path.join(os.homedir(), target.slice(2));
|
|
1521
|
+
}
|
|
1522
|
+
return path.resolve(cwd, target);
|
|
1523
|
+
}
|
|
1524
|
+
const segments = commandStr.split(/\r?\n|&&|\|\||[;|&]/);
|
|
1139
1525
|
for (const segment of segments) {
|
|
1140
1526
|
// Parsed without a regex: /^cd\s+(?:--\s+)?(.+)$/ has adjacent \s+ groups that backtrack
|
|
1141
1527
|
// polynomially on input like `cd\t\t\t…` (js/polynomial-redos). The command comes
|
|
@@ -1149,8 +1535,12 @@ function effectiveCommandCwd(command, toolInput) {
|
|
|
1149
1535
|
else if (argText.startsWith('--') && /^[ \t]/.test(argText.slice(2))) argText = argText.slice(2).trim();
|
|
1150
1536
|
const target = tokenizeShellWords(argText)[0];
|
|
1151
1537
|
if (!target) break; // bare `cd` -> home; leave scope resolution alone
|
|
1152
|
-
if (/[*?$`]
|
|
1153
|
-
|
|
1538
|
+
if (/[*?$`]/.test(target)) return null;
|
|
1539
|
+
let targetResolved = target;
|
|
1540
|
+
if (targetResolved.startsWith('~/')) {
|
|
1541
|
+
targetResolved = path.join(os.homedir(), targetResolved.slice(2));
|
|
1542
|
+
}
|
|
1543
|
+
cwd = path.resolve(cwd, targetResolved);
|
|
1154
1544
|
}
|
|
1155
1545
|
return cwd;
|
|
1156
1546
|
}
|
|
@@ -1376,8 +1766,10 @@ function extractAffectedFiles(toolName, toolInput = {}) {
|
|
|
1376
1766
|
}
|
|
1377
1767
|
|
|
1378
1768
|
if (/\bgit\s+push\b/i.test(command) || /\bgh\s+pr\s+(?:create|merge)\b/i.test(command) || isGhApiPrCreateCommand(command)) {
|
|
1379
|
-
|
|
1380
|
-
|
|
1769
|
+
if (files.size === 0) {
|
|
1770
|
+
for (const filePath of getBranchDiffFiles(repoRoot)) {
|
|
1771
|
+
files.add(normalizePosix(filePath));
|
|
1772
|
+
}
|
|
1381
1773
|
}
|
|
1382
1774
|
}
|
|
1383
1775
|
}
|
|
@@ -1691,9 +2083,51 @@ function isThreadResolutionSatisfied() {
|
|
|
1691
2083
|
));
|
|
1692
2084
|
}
|
|
1693
2085
|
|
|
2086
|
+
// Hook payloads name MCP tools `mcp__<server>__<tool>` while exemption lists
|
|
2087
|
+
// hold bare tool names. Exemptions must compare on the stripped name or they
|
|
2088
|
+
// never fire for real hook traffic — 2026-08-05: the pending-thread gate
|
|
2089
|
+
// blocked `mcp__thumbgate__satisfy_gate` itself, deadlocking its own
|
|
2090
|
+
// documented escape hatch until the session-actions TTL expired.
|
|
2091
|
+
function bareToolName(toolName) {
|
|
2092
|
+
return String(toolName || '').replace(/^mcp__.+?__/, '');
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
function isRemedyToolName(toolName) {
|
|
2096
|
+
return REMEDY_TOOL_NAMES.has(bareToolName(toolName));
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
// permission-change-approval used to regex the entire command string, so
|
|
2100
|
+
// quoting `chmod 755` inside `gh issue create --body` was itself a deny (#3523).
|
|
2101
|
+
function isCommandPositionPermissionChange(toolName, toolInput = {}) {
|
|
2102
|
+
if (toolName !== 'Bash') return false;
|
|
2103
|
+
const command = String(toolInput.command || '');
|
|
2104
|
+
if (!command) return false;
|
|
2105
|
+
const segments = canonicalizeCommandForGates(command).split('; ');
|
|
2106
|
+
for (const segment of segments) {
|
|
2107
|
+
const tokens = tokenizeShellWords(segment);
|
|
2108
|
+
if (tokens.length === 0) continue;
|
|
2109
|
+
const bin = tokens[0].toLowerCase();
|
|
2110
|
+
if (bin === 'chmod' || bin === 'chown' || bin === 'setfacl') return tokens.length >= 2;
|
|
2111
|
+
if (bin === 'busybox' && tokens[1] && ['chmod', 'chown', 'setfacl'].includes(tokens[1].toLowerCase())) {
|
|
2112
|
+
return tokens.length >= 3;
|
|
2113
|
+
}
|
|
2114
|
+
if (bin === 'grant' || bin === 'revoke') return tokens.length >= 2;
|
|
2115
|
+
const nextFew = tokens.slice(1, 6).map((token) => token.toLowerCase());
|
|
2116
|
+
if ((bin === 'pm' || bin === 'adb') && nextFew.includes('grant')) return true;
|
|
2117
|
+
if (
|
|
2118
|
+
(bin === 'aws' || bin === 'gcloud' || bin === 'az')
|
|
2119
|
+
&& nextFew.some((token) => token === 'iam' || token === 'policy' || token === 'role'
|
|
2120
|
+
|| token === 'grant' || token === 'revoke')
|
|
2121
|
+
) {
|
|
2122
|
+
return true;
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
return false;
|
|
2126
|
+
}
|
|
2127
|
+
|
|
1694
2128
|
function isThreadResolutionEvidenceAction(toolName, toolInput = {}) {
|
|
1695
2129
|
if (isGitCommitCommand(toolName, toolInput)) return true;
|
|
1696
|
-
if (['recall', 'search_lessons', 'verify_claim', 'satisfy_gate', 'track_action'].includes(toolName)) return true;
|
|
2130
|
+
if (['recall', 'search_lessons', 'verify_claim', 'satisfy_gate', 'track_action'].includes(bareToolName(toolName))) return true;
|
|
1697
2131
|
if (toolName !== 'Bash') return false;
|
|
1698
2132
|
const command = String(toolInput.command || '');
|
|
1699
2133
|
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 +2167,9 @@ function getReadOnlyToolNames() {
|
|
|
1733
2167
|
return names;
|
|
1734
2168
|
}
|
|
1735
2169
|
function isReadOnlyObservabilityTool(toolName) {
|
|
1736
|
-
|
|
2170
|
+
if (!toolName) return false;
|
|
2171
|
+
const names = getReadOnlyToolNames();
|
|
2172
|
+
return names.has(toolName) || names.has(bareToolName(toolName));
|
|
1737
2173
|
}
|
|
1738
2174
|
|
|
1739
2175
|
function evaluatePendingPrThreadResolutionGate(toolName, toolInput = {}) {
|
|
@@ -1777,7 +2213,7 @@ function evaluatePendingPrThreadResolutionGate(toolName, toolInput = {}) {
|
|
|
1777
2213
|
severity: 'critical',
|
|
1778
2214
|
reasoning: [
|
|
1779
2215
|
`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
|
|
2216
|
+
'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
2217
|
],
|
|
1782
2218
|
};
|
|
1783
2219
|
}
|
|
@@ -1872,10 +2308,15 @@ function recordHelperScriptWrite(toolName, toolInput = {}) {
|
|
|
1872
2308
|
packageScriptTouched,
|
|
1873
2309
|
reasons,
|
|
1874
2310
|
};
|
|
1875
|
-
trackAction(
|
|
2311
|
+
trackAction(helperBypassActionKey(), metadata);
|
|
1876
2312
|
return metadata;
|
|
1877
2313
|
}
|
|
1878
2314
|
|
|
2315
|
+
function helperBypassActionKey() {
|
|
2316
|
+
const sessionId = currentScopeSessionId();
|
|
2317
|
+
return sessionId ? `${HELPER_BYPASS_ACTION}:${sessionId}` : HELPER_BYPASS_ACTION;
|
|
2318
|
+
}
|
|
2319
|
+
|
|
1879
2320
|
function evaluateStatefulHelperBypassGate(toolName, toolInput = {}) {
|
|
1880
2321
|
if (process.env.THUMBGATE_HELPER_BYPASS_GUARD === '0') return null;
|
|
1881
2322
|
if (toolName !== 'Bash') {
|
|
@@ -1901,7 +2342,7 @@ function evaluateStatefulHelperBypassGate(toolName, toolInput = {}) {
|
|
|
1901
2342
|
|
|
1902
2343
|
const writeMetadata = recordHelperScriptWrite(toolName, toolInput);
|
|
1903
2344
|
const actions = listSessionActions();
|
|
1904
|
-
const recentWrite = actions[
|
|
2345
|
+
const recentWrite = actions[helperBypassActionKey()];
|
|
1905
2346
|
const recentMetadata = recentWrite && recentWrite.metadata && typeof recentWrite.metadata === 'object'
|
|
1906
2347
|
? recentWrite.metadata
|
|
1907
2348
|
: null;
|
|
@@ -1974,6 +2415,10 @@ function evaluateCatastrophicDeclarativeGate(config, constraints, toolName, tool
|
|
|
1974
2415
|
for (const gate of config.gates) {
|
|
1975
2416
|
if (!CATASTROPHIC_DECLARATIVE_GATE_IDS.has(gate.id)) continue;
|
|
1976
2417
|
if (gate.action !== 'block' || gate.metrics) continue;
|
|
2418
|
+
// Override-capable hard gates must reach the ordinary loop so it can
|
|
2419
|
+
// authenticate and consume the exact-action admin authorization. They are
|
|
2420
|
+
// still unconditional hard floors and cannot be posture/cap downgraded.
|
|
2421
|
+
if (gate.adminOverride && gate.adminOverride.required === true) continue;
|
|
1977
2422
|
|
|
1978
2423
|
const matchDetails = matchGate(gate, toolName, toolInput);
|
|
1979
2424
|
if (!matchDetails.matched) continue;
|
|
@@ -2399,17 +2844,27 @@ function matchGate(gate, toolName, toolInput = {}) {
|
|
|
2399
2844
|
|
|
2400
2845
|
if (gate.pattern) {
|
|
2401
2846
|
try {
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2847
|
+
if (gate.id === 'permission-change-approval') {
|
|
2848
|
+
if (isRemedyToolName(toolName) || !isCommandPositionPermissionChange(toolName, toolInput)) {
|
|
2849
|
+
return { matched: false, matchText, affectedFiles };
|
|
2850
|
+
}
|
|
2851
|
+
if (isSafeLocalCredentialHardeningCommand(toolName, toolInput)) {
|
|
2852
|
+
return { matched: false, matchText, affectedFiles };
|
|
2853
|
+
}
|
|
2854
|
+
} else if (gate.id === 'gh-api-pr-create-restricted') {
|
|
2855
|
+
if (!isGhApiPrCreateCommand(String(toolInput.command || ''))) {
|
|
2856
|
+
return { matched: false, matchText, affectedFiles };
|
|
2857
|
+
}
|
|
2858
|
+
} else {
|
|
2859
|
+
const regex = new RegExp(gate.pattern);
|
|
2860
|
+
// Match command text, tool name, and light payload surfaces. MCP tools
|
|
2861
|
+
// (e.g. Gmail send_message) have no `command` field — without multi-surface
|
|
2862
|
+
// matching, every pattern gate against them is permanently inert.
|
|
2863
|
+
const surfaces = matchSurfaces.length > 0 ? matchSurfaces : [matchText];
|
|
2864
|
+
const anySurfaceMatch = surfaces.some((surface) => patternMatchesCommand(regex, surface));
|
|
2865
|
+
if (!anySurfaceMatch) {
|
|
2866
|
+
return { matched: false, matchText, affectedFiles };
|
|
2867
|
+
}
|
|
2413
2868
|
}
|
|
2414
2869
|
if (isBreakGlassSettingsBypass(gate, affectedFiles)) {
|
|
2415
2870
|
return { matched: false, matchText, affectedFiles };
|
|
@@ -2476,7 +2931,6 @@ function matchGate(gate, toolName, toolInput = {}) {
|
|
|
2476
2931
|
matchText,
|
|
2477
2932
|
affectedFiles,
|
|
2478
2933
|
taskScopeViolation,
|
|
2479
|
-
protectedApprovalViolation,
|
|
2480
2934
|
branchGovernanceViolation,
|
|
2481
2935
|
};
|
|
2482
2936
|
}
|
|
@@ -2502,6 +2956,7 @@ function matchSelfProtectHardFloor(gate, toolName, toolInput = {}) {
|
|
|
2502
2956
|
|
|
2503
2957
|
const command = String(toolInput.command || '');
|
|
2504
2958
|
let matchText = command;
|
|
2959
|
+
|
|
2505
2960
|
if (gate.id === 'self-protect-config' || gate.id === 'self-protect-hooks-disable') {
|
|
2506
2961
|
const targetPattern = gate.id === 'self-protect-config'
|
|
2507
2962
|
? SELF_PROTECT_CONFIG_TARGET_PATTERN
|
|
@@ -2513,7 +2968,28 @@ function matchSelfProtectHardFloor(gate, toolName, toolInput = {}) {
|
|
|
2513
2968
|
const commandTargetPattern = gate.id === 'self-protect-config'
|
|
2514
2969
|
? SELF_PROTECT_CONFIG_COMMAND_PATTERN
|
|
2515
2970
|
: SELF_PROTECT_HOOK_COMMAND_PATTERN;
|
|
2516
|
-
|
|
2971
|
+
|
|
2972
|
+
// Inspect every shell redirection destination (optional fd, optional/no
|
|
2973
|
+
// spaces, attached forms like printf x>file, multiple redirects).
|
|
2974
|
+
const redirectPattern = /(?:^|[\s;&|]|[^\s;&|<>])(?:\d*)>{1,2}\s*([^\s;&|<>]+)/g;
|
|
2975
|
+
const redirectTargets = [];
|
|
2976
|
+
let redirectMatch = redirectPattern.exec(command);
|
|
2977
|
+
while (redirectMatch) {
|
|
2978
|
+
if (redirectMatch[1]) redirectTargets.push(redirectMatch[1]);
|
|
2979
|
+
redirectMatch = redirectPattern.exec(command);
|
|
2980
|
+
}
|
|
2981
|
+
if (redirectTargets.length > 0) {
|
|
2982
|
+
// Deny when any redirect destination is protected.
|
|
2983
|
+
if (redirectTargets.some((target) => commandTargetPattern.test(target))) {
|
|
2984
|
+
// fall through to deny
|
|
2985
|
+
} else if (!SHELL_FILE_MUTATION_PATTERN.test(command) || !commandTargetPattern.test(command)) {
|
|
2986
|
+
// Benign redirects and no other protected mutation → allow.
|
|
2987
|
+
return null;
|
|
2988
|
+
}
|
|
2989
|
+
// Benign redirects but command still mutates a protected path another way → deny.
|
|
2990
|
+
} else {
|
|
2991
|
+
if (!SHELL_FILE_MUTATION_PATTERN.test(command) || !commandTargetPattern.test(command)) return null;
|
|
2992
|
+
}
|
|
2517
2993
|
} else {
|
|
2518
2994
|
return null;
|
|
2519
2995
|
}
|
|
@@ -2813,6 +3289,27 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
|
|
|
2813
3289
|
try {
|
|
2814
3290
|
const { selectHarness } = require('./harness-selector');
|
|
2815
3291
|
harnessPath = selectHarness(toolName, toolInput);
|
|
3292
|
+
try {
|
|
3293
|
+
// Opt-in RateBurst only (THUMBGATE_RADWARE_RATE=1). Never persist in tests/CI.
|
|
3294
|
+
if (process.env.THUMBGATE_RADWARE_RATE === '1') {
|
|
3295
|
+
const radware = require('./radware-threat-defense.js');
|
|
3296
|
+
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')));
|
|
3297
|
+
if (!inTest) {
|
|
3298
|
+
radware.persistCallTimestamp();
|
|
3299
|
+
const burst = radware.checkRateBurst(radware.loadCallTimestamps());
|
|
3300
|
+
if (burst.tripped) {
|
|
3301
|
+
return recordStructuralGateBlock(toolName, toolInput, {
|
|
3302
|
+
decision: 'deny',
|
|
3303
|
+
gate: 'algorithmic-token-drain-circuit-breaker',
|
|
3304
|
+
action: 'block',
|
|
3305
|
+
severity: 'high',
|
|
3306
|
+
message: burst.message,
|
|
3307
|
+
receipt: 'threat_defense_interdicted=true:type=RateBurst:action=block',
|
|
3308
|
+
});
|
|
3309
|
+
}
|
|
3310
|
+
}
|
|
3311
|
+
}
|
|
3312
|
+
} catch { /* optional */ }
|
|
2816
3313
|
} catch { /* harness-selector is optional */ }
|
|
2817
3314
|
config = loadGatesConfig(configPath, harnessPath);
|
|
2818
3315
|
} catch {
|
|
@@ -2835,6 +3332,10 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
|
|
|
2835
3332
|
if (statefulHelperBypassGate) {
|
|
2836
3333
|
return recordStructuralGateBlock(toolName, toolInput, statefulHelperBypassGate);
|
|
2837
3334
|
}
|
|
3335
|
+
const stealthMemoryInjectionGate = evaluateStealthMemoryInjection(toolName, toolInput);
|
|
3336
|
+
if (stealthMemoryInjectionGate) {
|
|
3337
|
+
return recordStructuralGateBlock(toolName, toolInput, stealthMemoryInjectionGate);
|
|
3338
|
+
}
|
|
2838
3339
|
if (isBreakGlassSettingsRecoveryAction(toolName, toolInput)) {
|
|
2839
3340
|
recordAuditEvent({
|
|
2840
3341
|
toolName,
|
|
@@ -2848,6 +3349,22 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
|
|
|
2848
3349
|
return null;
|
|
2849
3350
|
}
|
|
2850
3351
|
|
|
3352
|
+
const agentIdentityLifecycleGate = evaluateAgentIdentityLifecycleGate(toolName, toolInput);
|
|
3353
|
+
if (agentIdentityLifecycleGate) {
|
|
3354
|
+
recordStat(agentIdentityLifecycleGate.gate, 'block', null, { toolName, toolInput });
|
|
3355
|
+
const identityAuditRecord = recordAuditEvent({
|
|
3356
|
+
toolName,
|
|
3357
|
+
toolInput,
|
|
3358
|
+
decision: 'deny',
|
|
3359
|
+
gateId: agentIdentityLifecycleGate.gate,
|
|
3360
|
+
message: agentIdentityLifecycleGate.message,
|
|
3361
|
+
severity: agentIdentityLifecycleGate.severity,
|
|
3362
|
+
source: 'gates-engine',
|
|
3363
|
+
});
|
|
3364
|
+
auditToFeedback(identityAuditRecord);
|
|
3365
|
+
return agentIdentityLifecycleGate;
|
|
3366
|
+
}
|
|
3367
|
+
|
|
2851
3368
|
const pendingThreadResolutionGate = evaluatePendingPrThreadResolutionGate(toolName, toolInput);
|
|
2852
3369
|
if (pendingThreadResolutionGate) {
|
|
2853
3370
|
recordStat(pendingThreadResolutionGate.gate, 'block', null, { toolName, toolInput });
|
|
@@ -2952,13 +3469,45 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
|
|
|
2952
3469
|
});
|
|
2953
3470
|
|
|
2954
3471
|
if (gate.action === 'block') {
|
|
3472
|
+
const adminOverride = evaluateAdminOverride(gate, toolName, toolInput);
|
|
3473
|
+
if (adminOverride && adminOverride.authorized) {
|
|
3474
|
+
recordStat(gate.id, 'approve', gate, { toolName, toolInput });
|
|
3475
|
+
const auditRecord = recordAuditEvent({
|
|
3476
|
+
toolName,
|
|
3477
|
+
toolInput,
|
|
3478
|
+
decision: 'allow',
|
|
3479
|
+
gateId: gate.id,
|
|
3480
|
+
message: `Single-use admin override consumed for sha256:${adminOverride.approvalContextDigest}.`,
|
|
3481
|
+
severity: gate.severity,
|
|
3482
|
+
source: 'gates-engine-admin-override',
|
|
3483
|
+
});
|
|
3484
|
+
auditToFeedback(auditRecord);
|
|
3485
|
+
return {
|
|
3486
|
+
decision: 'allow',
|
|
3487
|
+
gate: gate.id,
|
|
3488
|
+
message: `Single-use admin override consumed for sha256:${adminOverride.approvalContextDigest}.`,
|
|
3489
|
+
severity: gate.severity,
|
|
3490
|
+
reasoning,
|
|
3491
|
+
adminOverride,
|
|
3492
|
+
};
|
|
3493
|
+
}
|
|
2955
3494
|
// Expired leases report under their own gate id so neither the enforcement posture nor
|
|
2956
3495
|
// the daily block cap can quietly turn this denial into a warning.
|
|
2957
3496
|
const gateId = matchDetails && matchDetails.taskScopeViolation
|
|
2958
3497
|
&& matchDetails.taskScopeViolation.reasonCode === 'expired_task_scope'
|
|
2959
3498
|
? TASK_SCOPE_LEASE_EXPIRED_GATE_ID
|
|
2960
3499
|
: gate.id;
|
|
2961
|
-
const
|
|
3500
|
+
const overrideMessage = adminOverride
|
|
3501
|
+
? `${message} Approval request: ${adminOverride.escalationId}. Action digest: sha256:${adminOverride.approvalContextDigest}.${adminOverride.replayed ? ' The approved override was already consumed.' : ''}`
|
|
3502
|
+
: message;
|
|
3503
|
+
const denyResult = {
|
|
3504
|
+
decision: 'deny',
|
|
3505
|
+
gate: gateId,
|
|
3506
|
+
message: overrideMessage,
|
|
3507
|
+
severity: gate.severity,
|
|
3508
|
+
reasoning,
|
|
3509
|
+
...(adminOverride ? { requiresAdminOverride: true, adminOverride } : {}),
|
|
3510
|
+
};
|
|
2962
3511
|
// Free-tier daily block cap: after N blocks/day, deny → warn + upgrade CTA
|
|
2963
3512
|
const cappedResult = applyDailyBlockCap(denyResult);
|
|
2964
3513
|
if (cappedResult) {
|
|
@@ -2968,7 +3517,7 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
|
|
|
2968
3517
|
return cappedResult;
|
|
2969
3518
|
}
|
|
2970
3519
|
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' });
|
|
3520
|
+
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'deny', gateId: gate.id, message: overrideMessage, severity: gate.severity, source: 'gates-engine' });
|
|
2972
3521
|
auditToFeedback(auditRecord);
|
|
2973
3522
|
return denyResult;
|
|
2974
3523
|
}
|
|
@@ -3056,6 +3605,22 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
|
|
|
3056
3605
|
return sentinelResult;
|
|
3057
3606
|
}
|
|
3058
3607
|
|
|
3608
|
+
const brokerReceiptResultAsync = evaluateBrokerReceiptGate(toolName, toolInput);
|
|
3609
|
+
if (brokerReceiptResultAsync && brokerReceiptResultAsync.decision === 'deny') {
|
|
3610
|
+
recordStat(brokerReceiptResultAsync.gate, 'block', null, { toolName, toolInput });
|
|
3611
|
+
const auditRecord = recordAuditEvent({
|
|
3612
|
+
toolName,
|
|
3613
|
+
toolInput,
|
|
3614
|
+
decision: 'deny',
|
|
3615
|
+
gateId: brokerReceiptResultAsync.gate,
|
|
3616
|
+
message: brokerReceiptResultAsync.message,
|
|
3617
|
+
severity: brokerReceiptResultAsync.severity,
|
|
3618
|
+
source: 'broker-execution-receipts',
|
|
3619
|
+
});
|
|
3620
|
+
auditToFeedback(auditRecord);
|
|
3621
|
+
return brokerReceiptResultAsync;
|
|
3622
|
+
}
|
|
3623
|
+
|
|
3059
3624
|
// Audit trail: record allow (no gate matched)
|
|
3060
3625
|
recordAuditEvent({ toolName, toolInput, decision: 'allow', source: 'gates-engine' });
|
|
3061
3626
|
return null;
|
|
@@ -3068,6 +3633,27 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
|
|
|
3068
3633
|
try {
|
|
3069
3634
|
const { selectHarness } = require('./harness-selector');
|
|
3070
3635
|
harnessPath = selectHarness(toolName, toolInput);
|
|
3636
|
+
try {
|
|
3637
|
+
// Opt-in RateBurst only (THUMBGATE_RADWARE_RATE=1). Never persist in tests/CI.
|
|
3638
|
+
if (process.env.THUMBGATE_RADWARE_RATE === '1') {
|
|
3639
|
+
const radware = require('./radware-threat-defense.js');
|
|
3640
|
+
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')));
|
|
3641
|
+
if (!inTest) {
|
|
3642
|
+
radware.persistCallTimestamp();
|
|
3643
|
+
const burst = radware.checkRateBurst(radware.loadCallTimestamps());
|
|
3644
|
+
if (burst.tripped) {
|
|
3645
|
+
return recordStructuralGateBlock(toolName, toolInput, {
|
|
3646
|
+
decision: 'deny',
|
|
3647
|
+
gate: 'algorithmic-token-drain-circuit-breaker',
|
|
3648
|
+
action: 'block',
|
|
3649
|
+
severity: 'high',
|
|
3650
|
+
message: burst.message,
|
|
3651
|
+
receipt: 'threat_defense_interdicted=true:type=RateBurst:action=block',
|
|
3652
|
+
});
|
|
3653
|
+
}
|
|
3654
|
+
}
|
|
3655
|
+
}
|
|
3656
|
+
} catch { /* optional */ }
|
|
3071
3657
|
} catch { /* harness-selector is optional */ }
|
|
3072
3658
|
config = loadGatesConfig(configPath, harnessPath);
|
|
3073
3659
|
} catch {
|
|
@@ -3091,6 +3677,10 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
|
|
|
3091
3677
|
if (statefulHelperBypassGate) {
|
|
3092
3678
|
return recordStructuralGateBlock(toolName, toolInput, statefulHelperBypassGate);
|
|
3093
3679
|
}
|
|
3680
|
+
const stealthMemoryInjectionGate = evaluateStealthMemoryInjection(toolName, toolInput);
|
|
3681
|
+
if (stealthMemoryInjectionGate) {
|
|
3682
|
+
return recordStructuralGateBlock(toolName, toolInput, stealthMemoryInjectionGate);
|
|
3683
|
+
}
|
|
3094
3684
|
if (isBreakGlassSettingsRecoveryAction(toolName, toolInput)) {
|
|
3095
3685
|
recordAuditEvent({
|
|
3096
3686
|
toolName,
|
|
@@ -3104,6 +3694,22 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
|
|
|
3104
3694
|
return null;
|
|
3105
3695
|
}
|
|
3106
3696
|
|
|
3697
|
+
const agentIdentityLifecycleGate = evaluateAgentIdentityLifecycleGate(toolName, toolInput);
|
|
3698
|
+
if (agentIdentityLifecycleGate) {
|
|
3699
|
+
recordStat(agentIdentityLifecycleGate.gate, 'block', null, { toolName, toolInput });
|
|
3700
|
+
const identityAuditRecord = recordAuditEvent({
|
|
3701
|
+
toolName,
|
|
3702
|
+
toolInput,
|
|
3703
|
+
decision: 'deny',
|
|
3704
|
+
gateId: agentIdentityLifecycleGate.gate,
|
|
3705
|
+
message: agentIdentityLifecycleGate.message,
|
|
3706
|
+
severity: agentIdentityLifecycleGate.severity,
|
|
3707
|
+
source: 'gates-engine',
|
|
3708
|
+
});
|
|
3709
|
+
auditToFeedback(identityAuditRecord);
|
|
3710
|
+
return agentIdentityLifecycleGate;
|
|
3711
|
+
}
|
|
3712
|
+
|
|
3107
3713
|
const pendingThreadResolutionGate = evaluatePendingPrThreadResolutionGate(toolName, toolInput);
|
|
3108
3714
|
if (pendingThreadResolutionGate) {
|
|
3109
3715
|
recordStat(pendingThreadResolutionGate.gate, 'block', null, { toolName, toolInput });
|
|
@@ -3181,13 +3787,45 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
|
|
|
3181
3787
|
const reasoning = buildReasoning(gate, toolName, toolInput, matchDetails);
|
|
3182
3788
|
|
|
3183
3789
|
if (gate.action === 'block') {
|
|
3790
|
+
const adminOverride = evaluateAdminOverride(gate, toolName, toolInput);
|
|
3791
|
+
if (adminOverride && adminOverride.authorized) {
|
|
3792
|
+
recordStat(gate.id, 'approve', gate, { toolName, toolInput });
|
|
3793
|
+
const auditRecord = recordAuditEvent({
|
|
3794
|
+
toolName,
|
|
3795
|
+
toolInput,
|
|
3796
|
+
decision: 'allow',
|
|
3797
|
+
gateId: gate.id,
|
|
3798
|
+
message: `Single-use admin override consumed for sha256:${adminOverride.approvalContextDigest}.`,
|
|
3799
|
+
severity: gate.severity,
|
|
3800
|
+
source: 'gates-engine-admin-override',
|
|
3801
|
+
});
|
|
3802
|
+
auditToFeedback(auditRecord);
|
|
3803
|
+
return {
|
|
3804
|
+
decision: 'allow',
|
|
3805
|
+
gate: gate.id,
|
|
3806
|
+
message: `Single-use admin override consumed for sha256:${adminOverride.approvalContextDigest}.`,
|
|
3807
|
+
severity: gate.severity,
|
|
3808
|
+
reasoning,
|
|
3809
|
+
adminOverride,
|
|
3810
|
+
};
|
|
3811
|
+
}
|
|
3184
3812
|
// Expired leases report under their own gate id so neither the enforcement posture nor
|
|
3185
3813
|
// the daily block cap can quietly turn this denial into a warning.
|
|
3186
3814
|
const gateId = matchDetails && matchDetails.taskScopeViolation
|
|
3187
3815
|
&& matchDetails.taskScopeViolation.reasonCode === 'expired_task_scope'
|
|
3188
3816
|
? TASK_SCOPE_LEASE_EXPIRED_GATE_ID
|
|
3189
3817
|
: gate.id;
|
|
3190
|
-
const
|
|
3818
|
+
const overrideMessage = adminOverride
|
|
3819
|
+
? `${message} Approval request: ${adminOverride.escalationId}. Action digest: sha256:${adminOverride.approvalContextDigest}.${adminOverride.replayed ? ' The approved override was already consumed.' : ''}`
|
|
3820
|
+
: message;
|
|
3821
|
+
const denyResult = {
|
|
3822
|
+
decision: 'deny',
|
|
3823
|
+
gate: gateId,
|
|
3824
|
+
message: overrideMessage,
|
|
3825
|
+
severity: gate.severity,
|
|
3826
|
+
reasoning,
|
|
3827
|
+
...(adminOverride ? { requiresAdminOverride: true, adminOverride } : {}),
|
|
3828
|
+
};
|
|
3191
3829
|
// Free-tier daily block cap: after N blocks/day, deny → warn + upgrade CTA
|
|
3192
3830
|
const cappedResult = applyDailyBlockCap(denyResult);
|
|
3193
3831
|
if (cappedResult) {
|
|
@@ -3197,7 +3835,7 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
|
|
|
3197
3835
|
return cappedResult;
|
|
3198
3836
|
}
|
|
3199
3837
|
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' });
|
|
3838
|
+
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'deny', gateId: gate.id, message: overrideMessage, severity: gate.severity, source: 'gates-engine' });
|
|
3201
3839
|
auditToFeedback(auditRecord);
|
|
3202
3840
|
return denyResult;
|
|
3203
3841
|
}
|
|
@@ -3284,6 +3922,24 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
|
|
|
3284
3922
|
return sentinelResult;
|
|
3285
3923
|
}
|
|
3286
3924
|
|
|
3925
|
+
// Broker-signed execution receipts: verify attached proof; optionally require
|
|
3926
|
+
// for high-risk provider side effects (THUMBGATE_BROKER_RECEIPT_MODE=enforce).
|
|
3927
|
+
const brokerReceiptResult = evaluateBrokerReceiptGate(toolName, toolInput);
|
|
3928
|
+
if (brokerReceiptResult && brokerReceiptResult.decision === 'deny') {
|
|
3929
|
+
recordStat(brokerReceiptResult.gate, 'block', null, { toolName, toolInput });
|
|
3930
|
+
const auditRecord = recordAuditEvent({
|
|
3931
|
+
toolName,
|
|
3932
|
+
toolInput,
|
|
3933
|
+
decision: 'deny',
|
|
3934
|
+
gateId: brokerReceiptResult.gate,
|
|
3935
|
+
message: brokerReceiptResult.message,
|
|
3936
|
+
severity: brokerReceiptResult.severity,
|
|
3937
|
+
source: 'broker-execution-receipts',
|
|
3938
|
+
});
|
|
3939
|
+
auditToFeedback(auditRecord);
|
|
3940
|
+
return brokerReceiptResult;
|
|
3941
|
+
}
|
|
3942
|
+
|
|
3287
3943
|
// Audit trail: record allow
|
|
3288
3944
|
recordAuditEvent({ toolName, toolInput, decision: 'allow', source: 'gates-engine' });
|
|
3289
3945
|
return null;
|
|
@@ -3447,6 +4103,7 @@ function evaluateUnconditionalHardFloor(input = {}, options = {}) {
|
|
|
3447
4103
|
|
|
3448
4104
|
function evaluateFinancialHardFloor(input = {}, consumeReservation = false, options = {}) {
|
|
3449
4105
|
const toolName = input.tool_name || input.toolName || 'unknown';
|
|
4106
|
+
if (isRemedyToolName(toolName)) return null;
|
|
3450
4107
|
const rawToolInput = input.tool_input ?? input.toolInput;
|
|
3451
4108
|
const toolInput = rawToolInput && typeof rawToolInput === 'object'
|
|
3452
4109
|
? rawToolInput
|
|
@@ -3799,7 +4456,11 @@ async function buildRelevantLessonContextAsync(toolName, toolInput) {
|
|
|
3799
4456
|
* negative lesson present is relevant enough to surface.
|
|
3800
4457
|
*/
|
|
3801
4458
|
function formatNegativeLessonContext(lessons) {
|
|
3802
|
-
const negative = (lessons || []).filter((l) =>
|
|
4459
|
+
const negative = (lessons || []).filter((l) => {
|
|
4460
|
+
if (l.signal !== 'negative') return false;
|
|
4461
|
+
const score = Number(l.rerankedScore ?? l.relevanceScore ?? 0);
|
|
4462
|
+
return score >= MIN_LESSON_INJECTION_RELEVANCE;
|
|
4463
|
+
});
|
|
3803
4464
|
if (negative.length === 0) return null;
|
|
3804
4465
|
|
|
3805
4466
|
const formatted = negative.map((l) => {
|
|
@@ -3824,8 +4485,10 @@ function isKnowledgeConflictHardBlockAction(toolName, toolInput = {}) {
|
|
|
3824
4485
|
}
|
|
3825
4486
|
|
|
3826
4487
|
function buildKnowledgeConflictContext(toolName, toolInput, lessons, entropy) {
|
|
3827
|
-
|
|
3828
|
-
|
|
4488
|
+
// Issue #3689: high entropy means the scorer disagrees with itself. Do NOT
|
|
4489
|
+
// inject a disclaimer + noisy lessons into unrelated tool calls — suppress.
|
|
4490
|
+
// Strict mode may still hard-block destructive/external side effects.
|
|
4491
|
+
const message = `Knowledge conflict: retrieved lessons disagree for this action (entropy ${entropy}).`;
|
|
3829
4492
|
|
|
3830
4493
|
if (isKnowledgeConflictHardBlockAction(toolName, toolInput)) {
|
|
3831
4494
|
recordStat('retrieval_entropy_high', 'block', null, { toolName, toolInput });
|
|
@@ -3837,8 +4500,9 @@ function buildKnowledgeConflictContext(toolName, toolInput, lessons, entropy) {
|
|
|
3837
4500
|
};
|
|
3838
4501
|
}
|
|
3839
4502
|
|
|
3840
|
-
|
|
3841
|
-
|
|
4503
|
+
// Count as log (not warn): we intentionally did not inject context.
|
|
4504
|
+
recordStat('retrieval_entropy_high', 'log', null, { toolName, toolInput });
|
|
4505
|
+
return null;
|
|
3842
4506
|
}
|
|
3843
4507
|
|
|
3844
4508
|
function extractActionContext(toolName, toolInput) {
|
|
@@ -4286,7 +4950,87 @@ function verifyClaimEvidence(claimText, options = {}) {
|
|
|
4286
4950
|
// Exports
|
|
4287
4951
|
// ---------------------------------------------------------------------------
|
|
4288
4952
|
|
|
4953
|
+
// ---------------------------------------------------------------------------
|
|
4954
|
+
// Agent identity plane (Okta AI-identity checklist: shadow AI + lifecycle)
|
|
4955
|
+
// ---------------------------------------------------------------------------
|
|
4956
|
+
|
|
4957
|
+
// Warn-dedup per process: one shadow/lifecycle warning per agent id, so the
|
|
4958
|
+
// audit log records the finding without spamming every tool call.
|
|
4959
|
+
const AGENT_IDENTITY_WARNED = new Set();
|
|
4960
|
+
|
|
4961
|
+
function resolveActingAgentId() {
|
|
4962
|
+
return process.env.THUMBGATE_SESSION_AGENT
|
|
4963
|
+
|| process.env.THUMBGATE_AGENT_ID
|
|
4964
|
+
|| null;
|
|
4965
|
+
}
|
|
4966
|
+
|
|
4967
|
+
/**
|
|
4968
|
+
* Identity gate for the enforced evaluation path. Every attributed tool call
|
|
4969
|
+
* is recorded as an observation (the producer side of shadow-AI detection).
|
|
4970
|
+
* An observed-but-unregistered agent is a shadow agent: warn-only on every
|
|
4971
|
+
* mode — a strict-mode deny here would brick sessions on repos where nothing
|
|
4972
|
+
* registers agents yet. A registry-retired or disabled agent that keeps
|
|
4973
|
+
* acting is denied under THUMBGATE_STRICT_ENFORCEMENT=1 and warned otherwise:
|
|
4974
|
+
* retirement is an explicit operator action, so enforcing it cannot surprise
|
|
4975
|
+
* a healthy session. Fails open on any registry error — identity tracking
|
|
4976
|
+
* must never break the hook path.
|
|
4977
|
+
*/
|
|
4978
|
+
function evaluateAgentIdentityLifecycleGate(toolName, toolInput) {
|
|
4979
|
+
try {
|
|
4980
|
+
const agentId = resolveActingAgentId();
|
|
4981
|
+
if (!agentId) return null;
|
|
4982
|
+
const identityStore = require('./audit-trail');
|
|
4983
|
+
try {
|
|
4984
|
+
identityStore.recordObservedAgent(agentId);
|
|
4985
|
+
} catch {
|
|
4986
|
+
// Observation is best-effort.
|
|
4987
|
+
}
|
|
4988
|
+
const registryRow = identityStore.loadAgentRegistry()
|
|
4989
|
+
.filter((agent) => agent && agent.id === agentId)
|
|
4990
|
+
.pop();
|
|
4991
|
+
const strict = process.env.THUMBGATE_STRICT_ENFORCEMENT === '1';
|
|
4992
|
+
const lifecycleStatus = registryRow?.metadata?.lifecycleStatus;
|
|
4993
|
+
if (registryRow && (lifecycleStatus === 'retired' || lifecycleStatus === 'disabled')) {
|
|
4994
|
+
const message = `Agent identity "${agentId}" is ${lifecycleStatus} in the agent registry but is still acting. `
|
|
4995
|
+
+ 'Re-activate it via registerAgent with lifecycleStatus "active", or stop the agent.';
|
|
4996
|
+
if (strict) {
|
|
4997
|
+
return { gate: 'agent-identity-lifecycle', decision: 'deny', message, severity: 'critical' };
|
|
4998
|
+
}
|
|
4999
|
+
if (!AGENT_IDENTITY_WARNED.has(`retired:${agentId}`)) {
|
|
5000
|
+
AGENT_IDENTITY_WARNED.add(`retired:${agentId}`);
|
|
5001
|
+
recordAuditEvent({
|
|
5002
|
+
toolName,
|
|
5003
|
+
toolInput,
|
|
5004
|
+
decision: 'warn',
|
|
5005
|
+
gateId: 'agent-identity-lifecycle',
|
|
5006
|
+
message,
|
|
5007
|
+
severity: 'high',
|
|
5008
|
+
source: 'gates-engine',
|
|
5009
|
+
});
|
|
5010
|
+
}
|
|
5011
|
+
return null;
|
|
5012
|
+
}
|
|
5013
|
+
if (!registryRow && !AGENT_IDENTITY_WARNED.has(`shadow:${agentId}`)) {
|
|
5014
|
+
AGENT_IDENTITY_WARNED.add(`shadow:${agentId}`);
|
|
5015
|
+
recordAuditEvent({
|
|
5016
|
+
toolName,
|
|
5017
|
+
toolInput,
|
|
5018
|
+
decision: 'warn',
|
|
5019
|
+
gateId: 'agent-identity-shadow',
|
|
5020
|
+
message: `Shadow agent: "${agentId}" is acting but has never been registered in the agent registry. `
|
|
5021
|
+
+ 'Register it via registerAgent so identity, lifecycle, and audit attribution are explicit.',
|
|
5022
|
+
severity: 'medium',
|
|
5023
|
+
source: 'gates-engine',
|
|
5024
|
+
});
|
|
5025
|
+
}
|
|
5026
|
+
return null;
|
|
5027
|
+
} catch {
|
|
5028
|
+
return null;
|
|
5029
|
+
}
|
|
5030
|
+
}
|
|
5031
|
+
|
|
4289
5032
|
module.exports = {
|
|
5033
|
+
evaluateAgentIdentityLifecycleGate,
|
|
4290
5034
|
loadGatesConfig,
|
|
4291
5035
|
loadState,
|
|
4292
5036
|
saveState,
|
|
@@ -4299,6 +5043,8 @@ module.exports = {
|
|
|
4299
5043
|
isTaskScopeExpired,
|
|
4300
5044
|
TASK_SCOPE_LEASE_EXPIRED_GATE_ID,
|
|
4301
5045
|
applyEnforcementPosture,
|
|
5046
|
+
resolveGovernanceMode,
|
|
5047
|
+
alignmentLayerForResult,
|
|
4302
5048
|
buildTaskScopeViolation,
|
|
4303
5049
|
setBranchGovernance,
|
|
4304
5050
|
approveProtectedAction,
|
|
@@ -4317,8 +5063,17 @@ module.exports = {
|
|
|
4317
5063
|
matchesGate,
|
|
4318
5064
|
evaluateGates,
|
|
4319
5065
|
evaluateGatesAsync,
|
|
5066
|
+
actionApprovalDigest,
|
|
4320
5067
|
buildMatchSurfaces,
|
|
4321
5068
|
extractAffectedFiles,
|
|
5069
|
+
extractGitMinusCPaths,
|
|
5070
|
+
resolveRepoRoot,
|
|
5071
|
+
governanceStatePath,
|
|
5072
|
+
currentScopeSessionId,
|
|
5073
|
+
isRemedyToolName,
|
|
5074
|
+
isCommandPositionPermissionChange,
|
|
5075
|
+
isGhApiPrCreateCommand,
|
|
5076
|
+
helperBypassActionKey,
|
|
4322
5077
|
parseGitPathspec,
|
|
4323
5078
|
canonicalizeGitCommand,
|
|
4324
5079
|
canonicalizeCommandForGates,
|
|
@@ -4376,6 +5131,7 @@ module.exports = {
|
|
|
4376
5131
|
evaluateLocalOnlyRemoteSideEffectGate,
|
|
4377
5132
|
recordHelperScriptWrite,
|
|
4378
5133
|
evaluateStatefulHelperBypassGate,
|
|
5134
|
+
evaluateStealthMemoryInjection,
|
|
4379
5135
|
isAgentHookSettingsFile,
|
|
4380
5136
|
isBreakGlassSettingsRecoveryAction,
|
|
4381
5137
|
PR_THREAD_RESOLUTION_ACTION,
|
|
@@ -4384,6 +5140,8 @@ module.exports = {
|
|
|
4384
5140
|
applyDailyBlockCap,
|
|
4385
5141
|
getTodayBlockCount,
|
|
4386
5142
|
incrementTodayBlockCount,
|
|
5143
|
+
effectiveCommandCwd,
|
|
5144
|
+
resolveRepoRoot,
|
|
4387
5145
|
};
|
|
4388
5146
|
|
|
4389
5147
|
// ---------------------------------------------------------------------------
|