thumbgate 1.27.18 → 1.27.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/dashboard.md +15 -0
- package/.claude/commands/thumbgate-blocked.md +27 -0
- package/.claude/commands/thumbgate-dashboard.md +15 -0
- package/.claude/commands/thumbgate-doctor.md +30 -0
- package/.claude/commands/thumbgate-guard.md +36 -0
- package/.claude/commands/thumbgate-protect.md +30 -0
- package/.claude/commands/thumbgate-rules.md +30 -0
- package/.claude-plugin/plugin.json +4 -3
- package/.well-known/agentic-verify.txt +1 -0
- package/.well-known/llms.txt +33 -12
- package/.well-known/mcp/server-card.json +8 -8
- package/README.md +246 -30
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/codex/config.toml +2 -2
- package/adapters/gcp/dfcx-webhook-gate.js +295 -0
- package/adapters/gemini/function-declarations.json +1 -0
- package/adapters/letta/README.md +41 -0
- package/adapters/letta/thumbgate-letta-adapter.js +133 -0
- package/adapters/mcp/server-stdio.js +263 -11
- package/adapters/opencode/opencode.json +1 -1
- package/adapters/policy-engine/ethicore-guardian-client.js +68 -0
- package/adapters/policy-engine/thumbgate-policy-engine-adapter.js +260 -0
- package/bench/observability-eval-suite.json +26 -0
- package/bench/thumbgate-bench.json +2 -2
- package/bin/cli.js +1456 -122
- package/bin/dashboard-cli.js +7 -0
- package/bin/postinstall.js +11 -27
- package/commands/dashboard.md +15 -0
- package/commands/thumbgate-dashboard.md +15 -0
- package/config/gate-classifier-routing.json +98 -0
- package/config/gate-templates.json +216 -0
- package/config/gates/claim-verification.json +12 -0
- package/config/gates/default.json +31 -2
- package/config/github-about.json +2 -2
- package/config/mcp-allowlists.json +23 -13
- package/config/merge-quality-checks.json +0 -1
- package/config/model-candidates.json +121 -6
- package/config/post-deploy-marketing-pages.json +80 -0
- package/config/tessl-tiles.json +1 -3
- package/openapi/openapi.yaml +12 -0
- package/package.json +225 -100
- package/public/about.html +162 -0
- package/public/agent-manager.html +179 -0
- package/public/agents-cost-savings.html +153 -0
- package/public/ai-malpractice-prevention.html +818 -0
- package/public/assets/brand/github-social-preview.png +0 -0
- package/public/assets/brand/thumbgate-icon-512.png +0 -0
- package/public/assets/brand/thumbgate-icon-pro-512.png +0 -0
- package/public/assets/brand/thumbgate-icon-team-512.png +0 -0
- package/public/assets/brand/thumbgate-logo-1200x360.png +0 -0
- package/public/assets/brand/thumbgate-logo-transparent.svg +28 -0
- package/public/assets/brand/thumbgate-mark-inline-v3.svg +18 -0
- package/public/assets/brand/thumbgate-mark-pro.svg +23 -0
- package/public/assets/brand/thumbgate-mark-team.svg +26 -0
- package/public/assets/brand/thumbgate-mark.svg +21 -0
- package/public/assets/brand/thumbgate-wordmark.svg +20 -0
- package/public/assets/claude-thumbgate-statusbar.svg +8 -0
- package/public/assets/codex-thumbgate-statusbar-test.svg +9 -0
- package/public/assets/legal-intake-control-flow.svg +66 -0
- package/public/blog.html +4 -4
- package/public/brand/thumbgate-mark.svg +19 -0
- package/public/brand/thumbgate-og.svg +16 -0
- package/public/chatgpt-app.html +330 -0
- package/public/codex-enterprise.html +123 -0
- package/public/codex-plugin.html +72 -20
- package/public/compare.html +31 -8
- package/public/dashboard.html +930 -166
- package/public/diagnostic.html +345 -0
- package/public/federal.html +2 -2
- package/public/guide.html +33 -13
- package/public/index.html +469 -111
- package/public/install.html +193 -0
- package/public/js/buyer-intent.js +672 -0
- package/public/learn.html +183 -18
- package/public/lessons.html +168 -10
- package/public/numbers.html +7 -7
- package/public/pricing.html +399 -0
- package/public/pro.html +34 -11
- package/scripts/action-receipts.js +324 -0
- package/scripts/activation-quickstart.js +187 -0
- package/scripts/agent-memory-lifecycle.js +211 -0
- package/scripts/agent-operations-planner.js +621 -0
- package/scripts/agent-readiness.js +20 -3
- package/scripts/agent-reward-model.js +53 -1
- package/scripts/ai-component-inventory.js +367 -0
- package/scripts/async-eval-observability.js +236 -0
- package/scripts/audit.js +65 -0
- package/scripts/auto-promote-gates.js +82 -10
- package/scripts/auto-wire-hooks.js +14 -0
- package/scripts/aws-blocks-guardrails.js +272 -0
- package/scripts/billing.js +93 -1
- package/scripts/bot-detection.js +61 -3
- package/scripts/build-metadata.js +50 -10
- package/scripts/classifier-routing.js +130 -0
- package/scripts/cli-feedback.js +4 -2
- package/scripts/cli-schema.js +97 -0
- package/scripts/cli-telemetry.js +6 -1
- package/scripts/commercial-offer.js +82 -2
- package/scripts/context-manager.js +74 -6
- package/scripts/dashboard-chat.js +332 -0
- package/scripts/dashboard.js +68 -2
- package/scripts/export-databricks-bundle.js +5 -1
- package/scripts/export-dpo-pairs.js +7 -2
- package/scripts/feedback-aggregate.js +281 -0
- package/scripts/feedback-loop.js +123 -1
- package/scripts/feedback-quality.js +87 -0
- package/scripts/feedback-sanitizer.js +105 -0
- package/scripts/filesystem-search.js +35 -10
- package/scripts/gate-stats.js +89 -0
- package/scripts/gates-engine.js +1176 -85
- package/scripts/gemini-embedding-policy.js +2 -1
- package/scripts/hook-runtime.js +20 -14
- package/scripts/hook-stop-anti-claim.js +301 -0
- package/scripts/hook-thumbgate-cache-updater.js +18 -2
- package/scripts/hybrid-feedback-context.js +142 -7
- package/scripts/install-shim.js +87 -0
- package/scripts/lesson-inference.js +8 -3
- package/scripts/lesson-search.js +17 -1
- package/scripts/license.js +10 -10
- package/scripts/llm-client.js +169 -4
- package/scripts/local-model-profile.js +15 -8
- package/scripts/mcp-config.js +7 -1
- package/scripts/mcp-oauth.js +293 -0
- package/scripts/memory-scope-readiness.js +159 -0
- package/scripts/meta-agent-loop.js +36 -0
- package/scripts/noop-detect.js +285 -0
- package/scripts/operational-integrity.js +39 -5
- package/scripts/oss-pr-opportunity-scout.js +35 -5
- package/scripts/parallel-workflow-orchestrator.js +293 -0
- package/scripts/plan-gate.js +243 -0
- package/scripts/plausible-domain-config.js +99 -0
- package/scripts/plausible-server-events.js +9 -6
- package/scripts/pro-local-dashboard.js +4 -4
- package/scripts/proxy-pointer-rag-guardrails.js +42 -1
- package/scripts/published-cli.js +0 -8
- package/scripts/qa-scenario-planner.js +136 -0
- package/scripts/rate-limiter.js +64 -13
- package/scripts/repeat-metric.js +137 -0
- package/scripts/secret-fixture-tokens.js +61 -0
- package/scripts/secret-redaction.js +166 -0
- package/scripts/secret-scanner.js +44 -5
- package/scripts/security-scanner.js +260 -10
- package/scripts/self-distill-agent.js +3 -1
- package/scripts/self-harness-optimizer.js +141 -0
- package/scripts/self-healing-check.js +193 -0
- package/scripts/self-protection.js +90 -0
- package/scripts/seo-gsd.js +916 -7
- package/scripts/silent-failure-cluster.js +531 -0
- package/scripts/statusline-cache-path.js +17 -2
- package/scripts/statusline-cache-read.js +57 -0
- package/scripts/statusline-local-stats.js +9 -1
- package/scripts/statusline-meta.js +28 -2
- package/scripts/statusline.sh +20 -4
- package/scripts/sync-telemetry-from-prod.js +374 -0
- package/scripts/telemetry-analytics.js +357 -0
- package/scripts/thompson-sampling.js +31 -10
- package/scripts/thumbgate-bench.js +16 -1
- package/scripts/thumbgate-search.js +85 -19
- package/scripts/tool-contract-validator.js +76 -0
- package/scripts/tool-registry.js +169 -1
- package/scripts/trajectory-scorer.js +63 -0
- package/scripts/vector-store.js +45 -0
- package/scripts/verify-marketing-pages-deployed.js +212 -0
- package/scripts/visitor-journey.js +172 -0
- package/scripts/workflow-sentinel.js +286 -53
- package/scripts/workspace-evolver.js +62 -2
- package/src/api/server.js +2683 -319
- package/.claude-plugin/marketplace.json +0 -85
- package/adapters/chatgpt/openapi.yaml +0 -1695
- package/scripts/bot-detector.js +0 -50
package/scripts/gates-engine.js
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
6
7
|
const crypto = require('crypto');
|
|
7
|
-
const {
|
|
8
|
+
const { execFileSync } = require('child_process');
|
|
8
9
|
const { loadOptionalModule } = require('./private-core-boundary');
|
|
9
10
|
|
|
10
|
-
const { isProTier, FREE_TIER_MAX_GATES } = require('./rate-limiter');
|
|
11
|
+
const { isProTier, isInTrialPeriod, FREE_TIER_MAX_GATES, FREE_TIER_DAILY_BLOCKS, todayKey } = require('./rate-limiter');
|
|
11
12
|
const {
|
|
12
13
|
DEFAULT_BASE_BRANCH,
|
|
13
14
|
evaluateOperationalIntegrity,
|
|
@@ -19,6 +20,10 @@ const {
|
|
|
19
20
|
recordDecisionEvaluation,
|
|
20
21
|
recordDecisionOutcome,
|
|
21
22
|
} = require('./decision-journal');
|
|
23
|
+
const {
|
|
24
|
+
actionFingerprint,
|
|
25
|
+
sanitizeFeedbackText,
|
|
26
|
+
} = require('./feedback-sanitizer');
|
|
22
27
|
|
|
23
28
|
/**
|
|
24
29
|
* Computes the SHA-256 hash of an executable binary to prevent path-based bypasses.
|
|
@@ -30,10 +35,12 @@ function computeExecutableHash(command) {
|
|
|
30
35
|
const firstWord = command.trim().split(/\s+/)[0];
|
|
31
36
|
if (!firstWord) return null;
|
|
32
37
|
|
|
33
|
-
// Resolve absolute path using 'which'
|
|
38
|
+
// Resolve absolute path using 'which'. Use execFileSync (no shell) and pass
|
|
39
|
+
// firstWord as an argv element, never interpolated into a command string, so
|
|
40
|
+
// a hostile `command` value cannot inject shell metacharacters here.
|
|
34
41
|
let fullPath;
|
|
35
42
|
try {
|
|
36
|
-
fullPath =
|
|
43
|
+
fullPath = execFileSync('which', [firstWord], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
37
44
|
} catch (e) {
|
|
38
45
|
// If 'which' fails, it might be an absolute path or a non-existent command
|
|
39
46
|
fullPath = path.isAbsolute(firstWord) ? firstWord : null;
|
|
@@ -51,21 +58,44 @@ const {
|
|
|
51
58
|
scanHookInput,
|
|
52
59
|
buildSafeSummary,
|
|
53
60
|
redactText,
|
|
61
|
+
isSafeSecretStorageWrite,
|
|
62
|
+
SAFE_SECRET_STORAGE_DIRS,
|
|
54
63
|
} = require('./secret-scanner');
|
|
55
64
|
const {
|
|
56
65
|
evaluateSecurityScan,
|
|
57
66
|
} = require('./security-scanner');
|
|
67
|
+
const { evaluatePlanGate } = require('./plan-gate');
|
|
68
|
+
const { getTrajectoryScore } = require('./trajectory-scorer');
|
|
69
|
+
const { evaluateSequenceState } = loadOptionalModule('./sequence-guard', () => ({
|
|
70
|
+
evaluateSequenceState: () => null,
|
|
71
|
+
}));
|
|
58
72
|
const { getAutoGatesPath } = require('./auto-promote-gates');
|
|
59
73
|
const { recordAuditEvent, auditToFeedback } = require('./audit-trail');
|
|
60
74
|
|
|
61
75
|
const DEFAULT_CONFIG_PATH = path.join(__dirname, '..', 'config', 'gates', 'default.json');
|
|
62
76
|
const DEFAULT_CLAIM_GATES_PATH = path.join(__dirname, '..', 'config', 'gates', 'claim-verification.json');
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
77
|
+
|
|
78
|
+
function resolveThumbgateStateDir() {
|
|
79
|
+
if (process.env.THUMBGATE_STATE_DIR) return process.env.THUMBGATE_STATE_DIR;
|
|
80
|
+
|
|
81
|
+
if (process.env.XDG_STATE_HOME) {
|
|
82
|
+
return path.join(process.env.XDG_STATE_HOME, 'thumbgate');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (process.env.CODEX_SANDBOX) {
|
|
86
|
+
return path.join(os.tmpdir(), 'thumbgate');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return path.join(process.env.HOME || os.tmpdir(), '.thumbgate');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const STATE_DIR = resolveThumbgateStateDir();
|
|
93
|
+
const STATE_PATH = path.join(STATE_DIR, 'gate-state.json');
|
|
94
|
+
const CONSTRAINTS_PATH = path.join(STATE_DIR, 'session-constraints.json');
|
|
95
|
+
const STATS_PATH = path.join(STATE_DIR, 'gate-stats.json');
|
|
96
|
+
const SESSION_ACTIONS_PATH = path.join(STATE_DIR, 'session-actions.json');
|
|
97
|
+
const CUSTOM_CLAIM_GATES_PATH = path.join(STATE_DIR, 'claim-verification.json');
|
|
98
|
+
const GOVERNANCE_STATE_PATH = path.join(STATE_DIR, 'governance-state.json');
|
|
69
99
|
const TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
70
100
|
const SESSION_ACTION_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
71
101
|
const PROTECTED_APPROVAL_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
@@ -85,12 +115,127 @@ const DEFAULT_PROTECTED_FILE_GLOBS = [
|
|
|
85
115
|
const EDIT_LIKE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit']);
|
|
86
116
|
const HIGH_RISK_BASH_PATTERN = /\b(?:git\s+(?:add|commit|push)|gh\s+pr\s+(?:create|merge)|npm\s+publish|yarn\s+publish|pnpm\s+publish|rm\s+-rf)\b/i;
|
|
87
117
|
const REMOTE_SIDE_EFFECT_BASH_PATTERN = /\b(?:git\s+push\b|gh\s+pr\s+(?:create|merge|close|reopen|ready|edit)\b|gh\s+release\s+(?:create|delete|edit|upload)\b|npm\s+publish\b|yarn\s+publish\b|pnpm\s+publish\b)\b/i;
|
|
118
|
+
const MAX_COMMAND_SCAN_CHARS = 20000;
|
|
88
119
|
const BOOSTED_RISK_BLOCK_SCORE = 0.8;
|
|
89
120
|
const BOOSTED_RISK_MIN_EXAMPLES = 3;
|
|
90
121
|
const PR_THREAD_RESOLUTION_ACTION = 'pr_thread_resolution_verified_after_commit';
|
|
122
|
+
const HELPER_BYPASS_ACTION = 'helper_script_modified';
|
|
123
|
+
const KNOWLEDGE_ENTROPY_THRESHOLD = 0.7;
|
|
124
|
+
const KNOWLEDGE_CONFLICT_STRICT_BASH_PATTERN = /\b(?:git\s+push\b|gh\s+pr\s+merge\b|gh\s+release\s+(?:create|delete|edit|upload)\b|(?:npm|yarn|pnpm)\s+publish\b|rm\s+-rf\b|git\s+reset\s+--hard\b|git\s+clean\s+-f[a-z]*|railway\s+(?:deploy|up)\b|gcloud\s+(?:run\s+deploy|app\s+deploy)\b|firebase\s+deploy\b|vercel\s+--prod\b|kubectl\s+(?:apply|delete)\b|terraform\s+(?:apply|destroy)\b)\b/i;
|
|
125
|
+
const HELPER_SCRIPT_FILE_PATTERN = /(?:^|\/)(?:scripts|bin|tools|tasks|\.githooks|\.github\/workflows)\/|(?:^|\/)(?:package\.json|Makefile|justfile|Taskfile\.ya?ml)$|\.(?:sh|bash|zsh|fish|js|mjs|cjs|ts|tsx|py|rb|pl|ps1|yml|yaml)$/i;
|
|
126
|
+
const PACKAGE_RUN_PATTERN = /\b(?:npm|yarn|pnpm)\s+run\s+([:@./\w-]+)\b/i;
|
|
127
|
+
const HELPER_EXEC_PATTERN = /(?:^|[;&|]\s*|\b(?:bash|sh|zsh|node|python3?|ruby|perl|tsx|ts-node)\s+)(?:(?:\.?\.?\/)?(?:scripts|bin|tools|tasks|tmp|build|dist|\.tmp|\.cache)\/[^\s;&|]+|\.\/[^\s;&|]+\.(?:sh|bash|zsh|js|mjs|cjs|ts|py|rb|pl|ps1))\b/i;
|
|
128
|
+
const HELPER_WRITE_PATTERN = /\b(?:cat|printf|echo|tee|npm\s+pkg\s+set|jq|node\s+-e|python3?\s+-c)\b[\s\S]{0,300}(?:>|--field|scripts\.|package\.json|(?:scripts|bin|tools|tasks|tmp|build|dist|\.tmp|\.cache)\/[^\s;&|]+|\.(?:sh|js|mjs|cjs|ts|py|rb|pl|ps1))\b/i;
|
|
129
|
+
const NETWORK_OR_PROCESS_BOUNDARY_PATTERN = /\b(?:curl|wget|nc|ncat|socat|ssh|scp|rsync|ftp|python3?\s+-m\s+http\.server|node\s+-e|python3?\s+-c|perl\s+-e|ruby\s+-e|bash\s+-c|sh\s+-c|osascript|open)\b/i;
|
|
130
|
+
const DOWNLOAD_EXEC_CHAIN_PATTERN = /\b(?:curl|wget)\b[\s\S]{0,400}(?:\|\s*(?:bash|sh|zsh)|&&[\s\S]{0,200}\bchmod\s+\+x\b[\s\S]{0,200}&&[\s\S]{0,120}(?:\.\/|bash|sh|node|python3?))/i;
|
|
131
|
+
const DESTRUCTIVE_OR_PRIVILEGE_BOUNDARY_PATTERN = /\b(?:rm\s+-rf|chmod\s+(?:\+x|777)|chown\b|sudo\b|dd\s+if=|mkfs|git\s+reset\s+--hard|git\s+clean\s+-f[a-z]*|kubectl\s+(?:apply|delete)|terraform\s+(?:apply|destroy)|railway\s+(?:deploy|up)|gcloud\s+(?:run\s+deploy|app\s+deploy)|vercel\s+--prod|firebase\s+deploy)\b/i;
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Enforcement posture (CEO decision 2026-06-04): warn-by-default.
|
|
135
|
+
// The firewall ALWAYS fires and logs every decision, but most gates WARN rather
|
|
136
|
+
// than hard-block — only TRULY CATASTROPHIC, irreversible actions hard-block:
|
|
137
|
+
// - secret exfiltration (handled on its own deny path; never downgraded)
|
|
138
|
+
// - security-vulnerability / supply-chain denies (own deny path; not downgraded)
|
|
139
|
+
// - irreversibly destructive filesystem commands (rm -rf class, mkfs, dd to disk,
|
|
140
|
+
// fork bomb) — kept as hard deny via DESTRUCTIVE_FS_PATTERN below.
|
|
141
|
+
// Everything else (memory-high-risk, workflow-sequence, off-scope, git push, deploy,
|
|
142
|
+
// approval gates) downgrades deny/approve -> warn so legitimate work is never blocked.
|
|
143
|
+
// Opt back into full hard enforcement with THUMBGATE_STRICT_ENFORCEMENT=1.
|
|
144
|
+
// Enforcement posture (CEO decision 2026-06-04): WARN + AUDIT by default.
|
|
145
|
+
// The firewall fires and LOGS every decision, but downgrades deny/approve -> warn so
|
|
146
|
+
// legitimate work is never hard-blocked. We deliberately do NOT try to hard-block
|
|
147
|
+
// arbitrary destructive commands here: a regex "catastrophic floor" is unwinnable
|
|
148
|
+
// (sudo / bash -c / find -exec / eval / base64|sh all evade it) and gives false confidence.
|
|
149
|
+
// HARD enforcement is an explicit opt-in via THUMBGATE_STRICT_ENFORCEMENT=1, which keeps
|
|
150
|
+
// the engine's FULL gate set (its high-risk-command gates catch prefixed/obfuscated forms
|
|
151
|
+
// far better than any single regex). Secret exfiltration and the security-vulnerability
|
|
152
|
+
// scan hard-deny on their OWN paths before this runs, so irreversible data-leak / supply
|
|
153
|
+
// chain risks stay blocked regardless of posture.
|
|
154
|
+
function applyEnforcementPosture(result) {
|
|
155
|
+
if (!result || (result.decision !== 'deny' && result.decision !== 'approve')) return result;
|
|
156
|
+
// Full hard enforcement opt-in: keep every deny.
|
|
157
|
+
if (process.env.THUMBGATE_STRICT_ENFORCEMENT === '1') return result;
|
|
158
|
+
// Honor the explicit strict-knowledge-conflict opt-in for that gate.
|
|
159
|
+
if (process.env.THUMBGATE_STRICT_KNOWLEDGE_CONFLICT === '1' && result.gate === 'knowledge-conflict-gate') return result;
|
|
160
|
+
// Warn-by-default: the gate still fired and is recorded; the action is allowed through
|
|
161
|
+
// with the warning surfaced instead of hard-blocked, so legitimate work is never blocked.
|
|
162
|
+
return {
|
|
163
|
+
...result,
|
|
164
|
+
decision: 'warn',
|
|
165
|
+
warnByDefault: true,
|
|
166
|
+
message: `${result.message}\n\n⚠️ ThumbGate is in warn-by-default mode — this was flagged and logged, not blocked. Set THUMBGATE_STRICT_ENFORCEMENT=1 to hard-block, or THUMBGATE_HOTFIX_BYPASS=1 to disable checks entirely.`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const BREAK_GLASS_CONDITION = 'thumbgate_break_glass';
|
|
170
|
+
const BREAK_GLASS_SETTINGS_GLOBS = [
|
|
171
|
+
'.claude/settings.local.json',
|
|
172
|
+
'.claude/settings.json',
|
|
173
|
+
'**/.claude/settings.local.json',
|
|
174
|
+
'**/.claude/settings.json',
|
|
175
|
+
'.codex/config.toml',
|
|
176
|
+
'**/.codex/config.toml',
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
function isRuntimePlanGateEnabled() {
|
|
180
|
+
return process.env.THUMBGATE_PLAN_GATE === '1' || process.env.THUMBGATE_PLAN_GATE === 'true';
|
|
181
|
+
}
|
|
91
182
|
const PR_THREAD_RESOLUTION_CLAIM_PATTERN = '(?:thread|review|comment).*?(?:resolved|verified|checked|addressed|fixed)|(?:resolved|verified|checked|addressed|fixed).*?(?:thread|review|comment)';
|
|
92
183
|
const PR_THREAD_RESOLUTION_REQUIRED_ACTIONS = ['pr_threads_checked', 'thread_resolution_verified'];
|
|
93
184
|
|
|
185
|
+
function commandScanText(command) {
|
|
186
|
+
return String(command || '').slice(0, MAX_COMMAND_SCAN_CHARS);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function commandWords(command) {
|
|
190
|
+
return commandScanText(command).toLowerCase().split(/\s+/).filter(Boolean);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function commandContainsSequence(words, sequence) {
|
|
194
|
+
if (!Array.isArray(words) || !Array.isArray(sequence) || sequence.length === 0) return false;
|
|
195
|
+
for (let i = 0; i <= words.length - sequence.length; i += 1) {
|
|
196
|
+
let matched = true;
|
|
197
|
+
for (let j = 0; j < sequence.length; j += 1) {
|
|
198
|
+
if (words[i + j] !== sequence[j]) {
|
|
199
|
+
matched = false;
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (matched) return true;
|
|
204
|
+
}
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function commandHasPostMethod(words) {
|
|
209
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
210
|
+
const word = words[i];
|
|
211
|
+
if ((word === '-x' || word === '--method') && words[i + 1] === 'post') return true;
|
|
212
|
+
if (word === '--method=post' || word === '-xpost') return true;
|
|
213
|
+
}
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function isGhApiPrCreateCommand(command) {
|
|
218
|
+
const words = commandWords(command);
|
|
219
|
+
if (!commandContainsSequence(words, ['gh', 'api'])) return false;
|
|
220
|
+
const hasPullsEndpoint = words.some((word) => word === '/pulls' || word.endsWith('/pulls'));
|
|
221
|
+
if (!hasPullsEndpoint) return false;
|
|
222
|
+
const fieldFlags = new Set(['-f', '--field', '--raw-field']);
|
|
223
|
+
const hasFieldWrite = words.some((word) => (
|
|
224
|
+
fieldFlags.has(word) ||
|
|
225
|
+
word.startsWith('-f=') ||
|
|
226
|
+
word.startsWith('--field=') ||
|
|
227
|
+
word.startsWith('--raw-field=')
|
|
228
|
+
));
|
|
229
|
+
return hasFieldWrite || commandHasPostMethod(words);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function isRecursiveChmodCommand(command) {
|
|
233
|
+
const words = commandWords(command);
|
|
234
|
+
const chmodIndex = words.indexOf('chmod');
|
|
235
|
+
if (chmodIndex === -1) return false;
|
|
236
|
+
return words.slice(chmodIndex + 1).includes('-r') || words.slice(chmodIndex + 1).some((word) => word.includes('r') && word.startsWith('-'));
|
|
237
|
+
}
|
|
238
|
+
|
|
94
239
|
// ---------------------------------------------------------------------------
|
|
95
240
|
// Config loading
|
|
96
241
|
// ---------------------------------------------------------------------------
|
|
@@ -187,6 +332,27 @@ function sanitizeGlobList(globs) {
|
|
|
187
332
|
return [...new Set(globs.map((glob) => normalizeGlob(glob)).filter(Boolean))];
|
|
188
333
|
}
|
|
189
334
|
|
|
335
|
+
// Affected files are compared as repo-relative paths (git --name-only output and
|
|
336
|
+
// inline paths are relative to the repo root). A caller who passes an ABSOLUTE
|
|
337
|
+
// allowedPath (e.g. "/Users/me/proj/src/**") therefore declares a glob that can never
|
|
338
|
+
// match — a silent no-op scope. When repoPath is known, rebase absolute globs that
|
|
339
|
+
// live under it to the repo-relative form so the scope actually applies. Globs already
|
|
340
|
+
// relative, or absolute but outside repoPath, are returned unchanged.
|
|
341
|
+
function rebaseGlobsToRepoRoot(globs, repoPath) {
|
|
342
|
+
// normalizeGlob strips both leading AND trailing slashes, so a repoPath with a
|
|
343
|
+
// trailing slash ("/Users/me/proj/") still matches the repo-relative globs.
|
|
344
|
+
const repoRel = normalizeGlob(repoPath);
|
|
345
|
+
if (!repoRel) return globs;
|
|
346
|
+
return globs.map((glob) => {
|
|
347
|
+
if (glob === repoRel) return '**';
|
|
348
|
+
if (glob.startsWith(`${repoRel}/`)) {
|
|
349
|
+
const rebased = glob.slice(repoRel.length + 1);
|
|
350
|
+
return rebased || '**';
|
|
351
|
+
}
|
|
352
|
+
return glob;
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
190
356
|
function globToRegExp(glob) {
|
|
191
357
|
const normalized = normalizeGlob(glob);
|
|
192
358
|
let pattern = '^';
|
|
@@ -240,6 +406,9 @@ function loadGovernanceState() {
|
|
|
240
406
|
branchGovernance: raw && raw.branchGovernance && typeof raw.branchGovernance === 'object'
|
|
241
407
|
? raw.branchGovernance
|
|
242
408
|
: null,
|
|
409
|
+
workflowContract: raw && raw.workflowContract && typeof raw.workflowContract === 'object'
|
|
410
|
+
? raw.workflowContract
|
|
411
|
+
: null,
|
|
243
412
|
};
|
|
244
413
|
const now = Date.now();
|
|
245
414
|
const activeApprovals = state.protectedApprovals.filter((entry) => {
|
|
@@ -259,6 +428,7 @@ function saveGovernanceState(state) {
|
|
|
259
428
|
taskScope: state && state.taskScope ? state.taskScope : null,
|
|
260
429
|
protectedApprovals: Array.isArray(state && state.protectedApprovals) ? state.protectedApprovals : [],
|
|
261
430
|
branchGovernance: state && state.branchGovernance ? state.branchGovernance : null,
|
|
431
|
+
workflowContract: state && state.workflowContract ? state.workflowContract : null,
|
|
262
432
|
};
|
|
263
433
|
saveJSON(module.exports.GOVERNANCE_STATE_PATH, next);
|
|
264
434
|
}
|
|
@@ -270,33 +440,39 @@ function setTaskScope(scopeInput = {}) {
|
|
|
270
440
|
taskScope: null,
|
|
271
441
|
protectedApprovals: currentState.protectedApprovals,
|
|
272
442
|
branchGovernance: currentState.branchGovernance,
|
|
443
|
+
workflowContract: null,
|
|
273
444
|
};
|
|
274
445
|
saveGovernanceState(cleared);
|
|
446
|
+
refreshLocalOnlyConstraint(cleared);
|
|
275
447
|
return null;
|
|
276
448
|
}
|
|
277
449
|
|
|
278
|
-
const
|
|
450
|
+
const repoPath = String(scopeInput.repoPath || '').trim() || null;
|
|
451
|
+
const allowedPaths = rebaseGlobsToRepoRoot(sanitizeGlobList(scopeInput.allowedPaths), repoPath);
|
|
279
452
|
if (allowedPaths.length === 0) {
|
|
280
453
|
throw new Error('allowedPaths must be a non-empty array');
|
|
281
454
|
}
|
|
282
455
|
|
|
283
|
-
const protectedPaths = sanitizeGlobList(
|
|
456
|
+
const protectedPaths = rebaseGlobsToRepoRoot(sanitizeGlobList(
|
|
284
457
|
Array.isArray(scopeInput.protectedPaths) && scopeInput.protectedPaths.length > 0
|
|
285
458
|
? scopeInput.protectedPaths
|
|
286
459
|
: DEFAULT_PROTECTED_FILE_GLOBS
|
|
287
|
-
);
|
|
460
|
+
), repoPath);
|
|
288
461
|
const taskScope = {
|
|
289
462
|
taskId: String(scopeInput.taskId || '').trim() || null,
|
|
290
463
|
summary: String(scopeInput.summary || '').trim() || null,
|
|
291
464
|
allowedPaths,
|
|
292
465
|
protectedPaths,
|
|
293
466
|
localOnly: scopeInput.localOnly === true,
|
|
294
|
-
repoPath
|
|
467
|
+
repoPath,
|
|
295
468
|
createdAt: new Date().toISOString(),
|
|
296
469
|
timestamp: Date.now(),
|
|
297
470
|
};
|
|
298
471
|
const state = loadGovernanceState();
|
|
299
472
|
state.taskScope = taskScope;
|
|
473
|
+
state.workflowContract = scopeInput.workflowContract && typeof scopeInput.workflowContract === 'object'
|
|
474
|
+
? scopeInput.workflowContract
|
|
475
|
+
: null;
|
|
300
476
|
saveGovernanceState(state);
|
|
301
477
|
if (taskScope.localOnly) {
|
|
302
478
|
setConstraint('local_only', true);
|
|
@@ -332,11 +508,44 @@ function approveProtectedAction(input = {}) {
|
|
|
332
508
|
return entry;
|
|
333
509
|
}
|
|
334
510
|
|
|
511
|
+
function breakGlassEmergency(input = {}) {
|
|
512
|
+
const reason = String(input.reason || '').trim();
|
|
513
|
+
if (!reason) {
|
|
514
|
+
throw new Error('reason is required');
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const ttlMs = Math.min(clampTtlMs(input.ttlMs, TTL_MS), TTL_MS);
|
|
518
|
+
const evidence = `BREAK GLASS: ${reason}`;
|
|
519
|
+
const gates = ['pr_create_allowed', 'pr_threads_checked', BREAK_GLASS_CONDITION];
|
|
520
|
+
const satisfied = {};
|
|
521
|
+
for (const gateId of gates) {
|
|
522
|
+
satisfied[gateId] = satisfyCondition(gateId, evidence);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const approval = approveProtectedAction({
|
|
526
|
+
pathGlobs: BREAK_GLASS_SETTINGS_GLOBS,
|
|
527
|
+
reason: evidence,
|
|
528
|
+
evidence,
|
|
529
|
+
ttlMs,
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
return {
|
|
533
|
+
ok: true,
|
|
534
|
+
reason,
|
|
535
|
+
ttlMs,
|
|
536
|
+
expiresAt: new Date(Date.now() + ttlMs).toISOString(),
|
|
537
|
+
satisfied,
|
|
538
|
+
approval,
|
|
539
|
+
settingsGlobs: BREAK_GLASS_SETTINGS_GLOBS.slice(),
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
335
543
|
function setBranchGovernance(input = {}) {
|
|
336
544
|
if (input && input.clear === true) {
|
|
337
545
|
const state = loadGovernanceState();
|
|
338
546
|
state.branchGovernance = null;
|
|
339
547
|
saveGovernanceState(state);
|
|
548
|
+
refreshLocalOnlyConstraint(state);
|
|
340
549
|
return null;
|
|
341
550
|
}
|
|
342
551
|
|
|
@@ -387,6 +596,24 @@ function setConstraint(key, value) {
|
|
|
387
596
|
return constraints[key];
|
|
388
597
|
}
|
|
389
598
|
|
|
599
|
+
function clearConstraint(key) {
|
|
600
|
+
const constraints = loadConstraints();
|
|
601
|
+
delete constraints[key];
|
|
602
|
+
saveConstraints(constraints);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function refreshLocalOnlyConstraint(governanceState = loadGovernanceState()) {
|
|
606
|
+
const localOnlyActive = Boolean(
|
|
607
|
+
(governanceState.taskScope && governanceState.taskScope.localOnly) ||
|
|
608
|
+
(governanceState.branchGovernance && governanceState.branchGovernance.localOnly)
|
|
609
|
+
);
|
|
610
|
+
if (localOnlyActive) {
|
|
611
|
+
setConstraint('local_only', true);
|
|
612
|
+
} else {
|
|
613
|
+
clearConstraint('local_only');
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
390
617
|
function isConditionSatisfied(conditionId) {
|
|
391
618
|
const state = loadState();
|
|
392
619
|
const entry = state[conditionId];
|
|
@@ -426,7 +653,29 @@ function loadStats() {
|
|
|
426
653
|
|
|
427
654
|
function saveStats(stats) { saveJSON(module.exports.STATS_PATH, stats); }
|
|
428
655
|
|
|
429
|
-
function
|
|
656
|
+
function buildGateActionFingerprint(gateId, options = {}) {
|
|
657
|
+
if (options.actionFingerprint) return String(options.actionFingerprint);
|
|
658
|
+
const toolName = options.toolName || options.tool_name || '';
|
|
659
|
+
const toolInput = options.toolInput || options.tool_input || {};
|
|
660
|
+
const parts = [toolName];
|
|
661
|
+
if (typeof toolInput === 'string') {
|
|
662
|
+
parts.push(toolInput);
|
|
663
|
+
} else if (toolInput && typeof toolInput === 'object') {
|
|
664
|
+
parts.push(
|
|
665
|
+
toolInput.command || '',
|
|
666
|
+
toolInput.cmd || '',
|
|
667
|
+
toolInput.file_path || '',
|
|
668
|
+
toolInput.path || '',
|
|
669
|
+
toolInput.description || '',
|
|
670
|
+
toolInput.prompt || '',
|
|
671
|
+
toolInput.pattern || '',
|
|
672
|
+
);
|
|
673
|
+
if (Array.isArray(toolInput.affectedFiles)) parts.push(...toolInput.affectedFiles);
|
|
674
|
+
}
|
|
675
|
+
return actionFingerprint(parts);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function recordStat(gateId, action, gate, options = {}) {
|
|
430
679
|
const stats = loadStats();
|
|
431
680
|
if (action === 'block') stats.blocked = (stats.blocked || 0) + 1;
|
|
432
681
|
else if (action === 'warn') stats.warned = (stats.warned || 0) + 1;
|
|
@@ -439,6 +688,29 @@ function recordStat(gateId, action, gate) {
|
|
|
439
688
|
else if (action === 'warn') stats.byGate[gateId].warned += 1;
|
|
440
689
|
else if (action === 'approve') stats.byGate[gateId].pendingApproval = (stats.byGate[gateId].pendingApproval || 0) + 1;
|
|
441
690
|
else if (action === 'log') stats.byGate[gateId].logged = (stats.byGate[gateId].logged || 0) + 1;
|
|
691
|
+
|
|
692
|
+
// Track same-action recurrence within a session for first-time fix rate.
|
|
693
|
+
// Gate-only recurrence over-counts noisy gates; repeats require a stable,
|
|
694
|
+
// sanitized action fingerprint.
|
|
695
|
+
if (action === 'block' || action === 'warn') {
|
|
696
|
+
if (!stats.sessionFiredGates) stats.sessionFiredGates = {};
|
|
697
|
+
if (!stats.sessionFiredActions) stats.sessionFiredActions = {};
|
|
698
|
+
const sessionKey = `session_${Math.floor(Date.now() / SESSION_ACTION_TTL_MS)}`;
|
|
699
|
+
if (!stats.sessionFiredGates[sessionKey]) stats.sessionFiredGates[sessionKey] = {};
|
|
700
|
+
stats.sessionFiredGates[sessionKey][gateId] = true;
|
|
701
|
+
|
|
702
|
+
const fingerprint = buildGateActionFingerprint(gateId, options);
|
|
703
|
+
if (fingerprint) {
|
|
704
|
+
if (!stats.sessionFiredActions[sessionKey]) stats.sessionFiredActions[sessionKey] = {};
|
|
705
|
+
if (!stats.sessionFiredActions[sessionKey][gateId]) stats.sessionFiredActions[sessionKey][gateId] = {};
|
|
706
|
+
if (stats.sessionFiredActions[sessionKey][gateId][fingerprint]) {
|
|
707
|
+
stats.recurringBlocks = (stats.recurringBlocks || 0) + 1;
|
|
708
|
+
} else {
|
|
709
|
+
stats.sessionFiredActions[sessionKey][gateId][fingerprint] = true;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
442
714
|
saveStats(stats);
|
|
443
715
|
// Track lesson freshness when an auto-promoted gate fires
|
|
444
716
|
if (gate && gate.sourceLessonId) {
|
|
@@ -452,6 +724,69 @@ function recordStat(gateId, action, gate) {
|
|
|
452
724
|
}
|
|
453
725
|
}
|
|
454
726
|
|
|
727
|
+
// ---------------------------------------------------------------------------
|
|
728
|
+
// Free-tier daily block cap
|
|
729
|
+
// ---------------------------------------------------------------------------
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Count today's gate blocks from stats. Free tier gets FREE_TIER_DAILY_BLOCKS
|
|
733
|
+
* blocks/day. After the limit, deny → warn + upgrade CTA so the action proceeds
|
|
734
|
+
* but the user sees they lost protection.
|
|
735
|
+
*/
|
|
736
|
+
function getTodayBlockCount() {
|
|
737
|
+
const stats = loadStats();
|
|
738
|
+
const today = todayKey();
|
|
739
|
+
if (!stats.dailyBlocks || !stats.dailyBlocks[today]) return 0;
|
|
740
|
+
return stats.dailyBlocks[today];
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function incrementTodayBlockCount() {
|
|
744
|
+
const stats = loadStats();
|
|
745
|
+
const today = todayKey();
|
|
746
|
+
if (!stats.dailyBlocks) stats.dailyBlocks = {};
|
|
747
|
+
// Clean old dates (keep only last 7 days to prevent unbounded growth)
|
|
748
|
+
const keys = Object.keys(stats.dailyBlocks);
|
|
749
|
+
if (keys.length > 7) {
|
|
750
|
+
keys.sort();
|
|
751
|
+
for (const k of keys.slice(0, keys.length - 7)) {
|
|
752
|
+
delete stats.dailyBlocks[k];
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
stats.dailyBlocks[today] = (stats.dailyBlocks[today] || 0) + 1;
|
|
756
|
+
saveStats(stats);
|
|
757
|
+
return stats.dailyBlocks[today];
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* If the user is free-tier and has exceeded daily block limit, downgrade
|
|
762
|
+
* a deny result to a warn with an upgrade CTA. Returns null if no cap applies.
|
|
763
|
+
*/
|
|
764
|
+
function applyDailyBlockCap(denyResult) {
|
|
765
|
+
// Pro, trial, CI, and THUMBGATE_NO_RATE_LIMIT users are uncapped
|
|
766
|
+
if (isProTier()) return null;
|
|
767
|
+
if (process.env.CI || process.env.GITHUB_ACTIONS) return null;
|
|
768
|
+
|
|
769
|
+
const todayCount = getTodayBlockCount();
|
|
770
|
+
if (todayCount < FREE_TIER_DAILY_BLOCKS) {
|
|
771
|
+
// Under limit: allow the block, increment counter
|
|
772
|
+
incrementTodayBlockCount();
|
|
773
|
+
return null;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Over limit: downgrade deny → warn with upgrade CTA
|
|
777
|
+
const remaining = 0;
|
|
778
|
+
return {
|
|
779
|
+
decision: 'warn',
|
|
780
|
+
gate: denyResult.gate,
|
|
781
|
+
message: `⚠️ ${denyResult.message}\n\n🔓 Daily protection limit reached (${FREE_TIER_DAILY_BLOCKS}/${FREE_TIER_DAILY_BLOCKS} blocks used). This action was allowed through. Upgrade for unlimited protection: https://thumbgate.ai/go/pro`,
|
|
782
|
+
severity: denyResult.severity,
|
|
783
|
+
reasoning: (denyResult.reasoning || []).concat([
|
|
784
|
+
`Free-tier daily block limit (${FREE_TIER_DAILY_BLOCKS}) exceeded — deny downgraded to warn`,
|
|
785
|
+
]),
|
|
786
|
+
dailyBlockCapApplied: true,
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
|
|
455
790
|
// ---------------------------------------------------------------------------
|
|
456
791
|
// Reasoning chain builder
|
|
457
792
|
// ---------------------------------------------------------------------------
|
|
@@ -507,9 +842,26 @@ function toRepoRelativePath(filePath, repoRoot) {
|
|
|
507
842
|
const value = String(filePath || '').trim();
|
|
508
843
|
if (!value) return '';
|
|
509
844
|
if (repoRoot && path.isAbsolute(value)) {
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
|
|
845
|
+
const candidates = [[path.resolve(repoRoot), path.resolve(value)]];
|
|
846
|
+
try {
|
|
847
|
+
const rootReal = fs.realpathSync.native(repoRoot);
|
|
848
|
+
let valueReal = null;
|
|
849
|
+
try {
|
|
850
|
+
valueReal = fs.realpathSync.native(value);
|
|
851
|
+
} catch {
|
|
852
|
+
const parentReal = fs.realpathSync.native(path.dirname(value));
|
|
853
|
+
valueReal = path.join(parentReal, path.basename(value));
|
|
854
|
+
}
|
|
855
|
+
candidates.push([rootReal, valueReal]);
|
|
856
|
+
} catch {
|
|
857
|
+
// Fall back to lexical path comparison below.
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
for (const [rootCandidate, valueCandidate] of candidates) {
|
|
861
|
+
const relative = path.relative(rootCandidate, valueCandidate);
|
|
862
|
+
if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
|
|
863
|
+
return normalizePosix(relative);
|
|
864
|
+
}
|
|
513
865
|
}
|
|
514
866
|
}
|
|
515
867
|
return normalizePosix(value);
|
|
@@ -588,7 +940,7 @@ function extractAffectedFiles(toolName, toolInput = {}) {
|
|
|
588
940
|
}
|
|
589
941
|
}
|
|
590
942
|
|
|
591
|
-
if (/\bgit\s+push\b/i.test(command) || /\bgh\s+pr\s+(?:create|merge)\b/i.test(command)) {
|
|
943
|
+
if (/\bgit\s+push\b/i.test(command) || /\bgh\s+pr\s+(?:create|merge)\b/i.test(command) || isGhApiPrCreateCommand(command)) {
|
|
592
944
|
for (const filePath of getBranchDiffFiles(repoRoot)) {
|
|
593
945
|
files.add(normalizePosix(filePath));
|
|
594
946
|
}
|
|
@@ -607,6 +959,7 @@ function isHighRiskAction(toolName, toolInput = {}, affectedFiles = []) {
|
|
|
607
959
|
const command = String(toolInput.command || '');
|
|
608
960
|
// Original high-risk pattern (git writes, publishes, destructive ops)
|
|
609
961
|
if (HIGH_RISK_BASH_PATTERN.test(command)) return true;
|
|
962
|
+
if (isGhApiPrCreateCommand(command)) return true;
|
|
610
963
|
// Broadened: any Bash command that modifies files or has side effects.
|
|
611
964
|
// Excludes pure read/analysis commands (node --test, cat, ls, echo, etc.)
|
|
612
965
|
// to avoid false positives on benign operations.
|
|
@@ -810,9 +1163,47 @@ function isThreadResolutionEvidenceAction(toolName, toolInput = {}) {
|
|
|
810
1163
|
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);
|
|
811
1164
|
}
|
|
812
1165
|
|
|
1166
|
+
// Read-only observability/metrics MCP tools must NEVER be blocked by the pending
|
|
1167
|
+
// PR-thread-resolution gate. Reading revenue, the dashboard, gate stats, or a
|
|
1168
|
+
// semantic entity cannot advance a "done" claim or mutate any state, so gating it
|
|
1169
|
+
// only blinds the operator to their own numbers. This is the exact failure the CEO
|
|
1170
|
+
// hit on 2026-06-30: `get_business_metrics` denied with "a git commit was made on a
|
|
1171
|
+
// PR branch" — a governance gate eating the observability path. The exempt set is
|
|
1172
|
+
// sourced from the canonical `readonly` MCP profile (config/mcp-allowlists.json) so
|
|
1173
|
+
// it cannot drift from the product's own definition of "safe to read"; the hard-coded
|
|
1174
|
+
// fallback guarantees the core observability tools stay readable even if that policy
|
|
1175
|
+
// file is unreadable at runtime.
|
|
1176
|
+
const READ_ONLY_TOOL_FALLBACK = new Set([
|
|
1177
|
+
'get_business_metrics', 'describe_semantic_entity', 'describe_reliability_entity',
|
|
1178
|
+
'get_reliability_rules', 'dashboard', 'org_dashboard', 'gate_stats', 'feedback_stats',
|
|
1179
|
+
'feedback_summary', 'session_report', 'generate_operator_artifact', 'settings_status',
|
|
1180
|
+
'get_scope_state', 'get_branch_governance', 'context_provenance', 'native_messaging_audit',
|
|
1181
|
+
'list_harnesses', 'list_intents', 'list_imported_documents', 'get_imported_document',
|
|
1182
|
+
'check_operational_integrity', 'workflow_sentinel', 'recall', 'search_lessons',
|
|
1183
|
+
'retrieve_lessons', 'search_thumbgate', 'unified_context', 'verify_claim',
|
|
1184
|
+
]);
|
|
1185
|
+
let readOnlyToolCache = null;
|
|
1186
|
+
function getReadOnlyToolNames() {
|
|
1187
|
+
if (readOnlyToolCache) return readOnlyToolCache;
|
|
1188
|
+
const names = new Set(READ_ONLY_TOOL_FALLBACK);
|
|
1189
|
+
try {
|
|
1190
|
+
const { getAllowedTools } = require('./mcp-policy');
|
|
1191
|
+
for (const tool of getAllowedTools('readonly')) names.add(tool);
|
|
1192
|
+
} catch (_) {
|
|
1193
|
+
// Policy file missing/malformed — the fallback set still covers the core
|
|
1194
|
+
// observability tools, so the operator can always read state.
|
|
1195
|
+
}
|
|
1196
|
+
readOnlyToolCache = names;
|
|
1197
|
+
return names;
|
|
1198
|
+
}
|
|
1199
|
+
function isReadOnlyObservabilityTool(toolName) {
|
|
1200
|
+
return Boolean(toolName) && getReadOnlyToolNames().has(toolName);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
813
1203
|
function evaluatePendingPrThreadResolutionGate(toolName, toolInput = {}) {
|
|
814
1204
|
if (!hasAction(PR_THREAD_RESOLUTION_ACTION)) return null;
|
|
815
1205
|
if (isThreadResolutionSatisfied()) return null;
|
|
1206
|
+
if (isReadOnlyObservabilityTool(toolName)) return null;
|
|
816
1207
|
if (isThreadResolutionEvidenceAction(toolName, toolInput)) return null;
|
|
817
1208
|
|
|
818
1209
|
const message = 'A git commit was made on a PR branch. Verify review threads are resolved before the next tool call.';
|
|
@@ -844,7 +1235,8 @@ function getLocalOnlyScopeSources(governanceState = {}, constraints = {}) {
|
|
|
844
1235
|
|
|
845
1236
|
function isRemoteSideEffectCommand(toolName, toolInput = {}) {
|
|
846
1237
|
if (toolName !== 'Bash') return false;
|
|
847
|
-
|
|
1238
|
+
const command = String(toolInput.command || '');
|
|
1239
|
+
return REMOTE_SIDE_EFFECT_BASH_PATTERN.test(command) || isGhApiPrCreateCommand(command);
|
|
848
1240
|
}
|
|
849
1241
|
|
|
850
1242
|
function evaluateLocalOnlyRemoteSideEffectGate(toolName, toolInput = {}, governanceState = {}, constraints = {}) {
|
|
@@ -866,8 +1258,127 @@ function evaluateLocalOnlyRemoteSideEffectGate(toolName, toolInput = {}, governa
|
|
|
866
1258
|
};
|
|
867
1259
|
}
|
|
868
1260
|
|
|
1261
|
+
function helperRiskReasons(text) {
|
|
1262
|
+
const value = String(text || '');
|
|
1263
|
+
const reasons = [];
|
|
1264
|
+
if (DOWNLOAD_EXEC_CHAIN_PATTERN.test(value)) reasons.push('download-then-execute chain');
|
|
1265
|
+
if (NETWORK_OR_PROCESS_BOUNDARY_PATTERN.test(value)) reasons.push('network/process boundary');
|
|
1266
|
+
if (DESTRUCTIVE_OR_PRIVILEGE_BOUNDARY_PATTERN.test(value)) reasons.push('destructive/privileged side effect');
|
|
1267
|
+
if (/\b(?:child_process|spawn\(|exec\(|execFile\(|subprocess|ProcessBuilder)\b/i.test(value)) {
|
|
1268
|
+
reasons.push('process spawn');
|
|
1269
|
+
}
|
|
1270
|
+
return [...new Set(reasons)];
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function readPackageScript(repoRoot, scriptName) {
|
|
1274
|
+
if (!repoRoot || !scriptName) return '';
|
|
1275
|
+
try {
|
|
1276
|
+
const packagePath = path.join(repoRoot, 'package.json');
|
|
1277
|
+
if (!fs.existsSync(packagePath)) return '';
|
|
1278
|
+
const { scripts = {} } = JSON.parse(fs.readFileSync(packagePath, 'utf8')) || {};
|
|
1279
|
+
return typeof scripts[scriptName] === 'string' ? scripts[scriptName] : '';
|
|
1280
|
+
} catch {
|
|
1281
|
+
return '';
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function recordHelperScriptWrite(toolName, toolInput = {}) {
|
|
1286
|
+
if (process.env.THUMBGATE_HELPER_BYPASS_GUARD === '0') return null;
|
|
1287
|
+
|
|
1288
|
+
const affected = extractAffectedFiles(toolName, toolInput);
|
|
1289
|
+
const affectedFiles = affected.files || [];
|
|
1290
|
+
const command = String(toolInput.command || '');
|
|
1291
|
+
const actionContext = extractActionContext(toolName, toolInput);
|
|
1292
|
+
const helperFiles = affectedFiles.filter((filePath) => HELPER_SCRIPT_FILE_PATTERN.test(normalizePosix(filePath)));
|
|
1293
|
+
const packageScriptTouched = affectedFiles.some((filePath) => normalizePosix(filePath) === 'package.json') || /\bpackage\.json\b|\bscripts\./i.test(actionContext);
|
|
1294
|
+
const commandWrite = toolName === 'Bash' && HELPER_WRITE_PATTERN.test(command);
|
|
1295
|
+
|
|
1296
|
+
if (helperFiles.length === 0 && !packageScriptTouched && !commandWrite) return null;
|
|
1297
|
+
|
|
1298
|
+
const riskText = [
|
|
1299
|
+
actionContext,
|
|
1300
|
+
command,
|
|
1301
|
+
helperFiles.join(' '),
|
|
1302
|
+
packageScriptTouched ? 'package.json scripts' : '',
|
|
1303
|
+
].filter(Boolean).join('\n');
|
|
1304
|
+
const reasons = helperRiskReasons(riskText);
|
|
1305
|
+
|
|
1306
|
+
const metadata = {
|
|
1307
|
+
repoRoot: affected.repoRoot || resolveRepoRoot(toolInput) || null,
|
|
1308
|
+
helperFiles,
|
|
1309
|
+
packageScriptTouched,
|
|
1310
|
+
reasons,
|
|
1311
|
+
};
|
|
1312
|
+
trackAction(HELPER_BYPASS_ACTION, metadata);
|
|
1313
|
+
return metadata;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function evaluateStatefulHelperBypassGate(toolName, toolInput = {}) {
|
|
1317
|
+
if (process.env.THUMBGATE_HELPER_BYPASS_GUARD === '0') return null;
|
|
1318
|
+
if (toolName !== 'Bash') {
|
|
1319
|
+
recordHelperScriptWrite(toolName, toolInput);
|
|
1320
|
+
return null;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
const command = String(toolInput.command || '').trim();
|
|
1324
|
+
if (!command) return null;
|
|
1325
|
+
|
|
1326
|
+
if (DOWNLOAD_EXEC_CHAIN_PATTERN.test(command)) {
|
|
1327
|
+
return {
|
|
1328
|
+
decision: 'deny',
|
|
1329
|
+
gate: 'stateful-helper-script-bypass',
|
|
1330
|
+
message: 'Download-then-execute chains are blocked before the real action moves below the visible tool call.',
|
|
1331
|
+
severity: 'critical',
|
|
1332
|
+
reasoning: [
|
|
1333
|
+
`Command matched download/execute chain: ${command.slice(0, 180)}`,
|
|
1334
|
+
'PreToolUse must review the whole action chain, not only the first low-risk command',
|
|
1335
|
+
],
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
const writeMetadata = recordHelperScriptWrite(toolName, toolInput);
|
|
1340
|
+
const actions = listSessionActions();
|
|
1341
|
+
const recentWrite = actions[HELPER_BYPASS_ACTION];
|
|
1342
|
+
const recentMetadata = recentWrite && recentWrite.metadata && typeof recentWrite.metadata === 'object'
|
|
1343
|
+
? recentWrite.metadata
|
|
1344
|
+
: null;
|
|
1345
|
+
|
|
1346
|
+
const scriptMatch = command.match(PACKAGE_RUN_PATTERN);
|
|
1347
|
+
const scriptName = scriptMatch ? scriptMatch[1] : '';
|
|
1348
|
+
const repoRoot = resolveRepoRoot(toolInput);
|
|
1349
|
+
const packageScript = scriptName ? readPackageScript(repoRoot, scriptName) : '';
|
|
1350
|
+
const commandBoundaryReasons = helperRiskReasons(`${command}\n${packageScript}`);
|
|
1351
|
+
const executesRecentHelper = HELPER_EXEC_PATTERN.test(command);
|
|
1352
|
+
const runsRecentPackageScript = Boolean(scriptName && recentMetadata && recentMetadata.packageScriptTouched);
|
|
1353
|
+
const recentReasons = recentMetadata && Array.isArray(recentMetadata.reasons) ? recentMetadata.reasons : [];
|
|
1354
|
+
const writeRiskReasons = writeMetadata && Array.isArray(writeMetadata.reasons) ? writeMetadata.reasons : [];
|
|
1355
|
+
const correlatedReasons = [...new Set([...recentReasons, ...writeRiskReasons, ...commandBoundaryReasons])];
|
|
1356
|
+
|
|
1357
|
+
if (
|
|
1358
|
+
(executesRecentHelper || runsRecentPackageScript) &&
|
|
1359
|
+
(recentMetadata || writeMetadata) &&
|
|
1360
|
+
correlatedReasons.length > 0
|
|
1361
|
+
) {
|
|
1362
|
+
const target = scriptName ? `package script "${scriptName}"` : 'recent helper script';
|
|
1363
|
+
return {
|
|
1364
|
+
decision: 'deny',
|
|
1365
|
+
gate: 'stateful-helper-script-bypass',
|
|
1366
|
+
message: `A recently modified ${target} now crosses a risky boundary. Review it or constrain cwd, network, process, and writable paths before execution.`,
|
|
1367
|
+
severity: 'critical',
|
|
1368
|
+
reasoning: [
|
|
1369
|
+
`Recent helper/package modification: ${(recentMetadata && recentMetadata.helperFiles || []).join(', ') || (recentMetadata && recentMetadata.packageScriptTouched ? 'package.json scripts' : 'current command write')}`,
|
|
1370
|
+
`Execution command: ${command.slice(0, 180)}`,
|
|
1371
|
+
`Risk reasons: ${correlatedReasons.join(', ')}`,
|
|
1372
|
+
'This blocks the helper-script/package-script bypass class raised in external review',
|
|
1373
|
+
],
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
return null;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
869
1380
|
function recordStructuralGateBlock(toolName, toolInput, result) {
|
|
870
|
-
recordStat(result.gate, 'block');
|
|
1381
|
+
recordStat(result.gate, 'block', null, { toolName, toolInput });
|
|
871
1382
|
const auditRecord = recordAuditEvent({
|
|
872
1383
|
toolName,
|
|
873
1384
|
toolInput,
|
|
@@ -898,6 +1409,25 @@ function shouldEnforceTaskScope(gate, governanceState, toolName, toolInput = {},
|
|
|
898
1409
|
return isScopeEnforcedAction(toolName, toolInput, affectedFiles);
|
|
899
1410
|
}
|
|
900
1411
|
|
|
1412
|
+
function isAgentHookSettingsFile(filePath) {
|
|
1413
|
+
return matchesAnyGlob(filePath, BREAK_GLASS_SETTINGS_GLOBS);
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
function isBreakGlassSettingsBypass(gate, affectedFiles) {
|
|
1417
|
+
if (!gate || !['task-scope-edit-boundary', 'protected-file-approval-required'].includes(gate.id)) {
|
|
1418
|
+
return false;
|
|
1419
|
+
}
|
|
1420
|
+
if (!isConditionSatisfied(BREAK_GLASS_CONDITION)) return false;
|
|
1421
|
+
return Array.isArray(affectedFiles) && affectedFiles.length > 0 && affectedFiles.every(isAgentHookSettingsFile);
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
function isBreakGlassSettingsRecoveryAction(toolName, toolInput = {}) {
|
|
1425
|
+
if (!EDIT_LIKE_TOOLS.has(toolName)) return false;
|
|
1426
|
+
if (!isConditionSatisfied(BREAK_GLASS_CONDITION)) return false;
|
|
1427
|
+
const affectedFiles = extractAffectedFiles(toolName, toolInput).files;
|
|
1428
|
+
return affectedFiles.length > 0 && affectedFiles.every(isAgentHookSettingsFile);
|
|
1429
|
+
}
|
|
1430
|
+
|
|
901
1431
|
function formatFileList(files, limit = 5) {
|
|
902
1432
|
const items = Array.isArray(files) ? files.filter(Boolean) : [];
|
|
903
1433
|
if (items.length === 0) return 'none';
|
|
@@ -1106,8 +1636,19 @@ function checkWhenClause(when, constraints) {
|
|
|
1106
1636
|
if (!when || !when.constraints) return true;
|
|
1107
1637
|
|
|
1108
1638
|
for (const [key, expectedValue] of Object.entries(when.constraints)) {
|
|
1109
|
-
|
|
1110
|
-
if (
|
|
1639
|
+
let value;
|
|
1640
|
+
if (key === 'careful_mode') {
|
|
1641
|
+
const isEnvTrue = process.env.THUMBGATE_CAREFUL_MODE === '1' ||
|
|
1642
|
+
String(process.env.THUMBGATE_CAREFUL_MODE).toLowerCase() === 'true';
|
|
1643
|
+
value = isEnvTrue || (constraints[key] && constraints[key].value === expectedValue);
|
|
1644
|
+
} else if (key === 'freeze_mode') {
|
|
1645
|
+
value = Boolean(process.env.THUMBGATE_FREEZE_PATHS) ||
|
|
1646
|
+
(constraints[key] && constraints[key].value !== false && constraints[key].value !== null && constraints[key].value !== undefined);
|
|
1647
|
+
} else {
|
|
1648
|
+
const constraint = constraints[key];
|
|
1649
|
+
value = constraint && constraint.value === expectedValue;
|
|
1650
|
+
}
|
|
1651
|
+
if (!value) {
|
|
1111
1652
|
return false;
|
|
1112
1653
|
}
|
|
1113
1654
|
}
|
|
@@ -1115,11 +1656,69 @@ function checkWhenClause(when, constraints) {
|
|
|
1115
1656
|
}
|
|
1116
1657
|
|
|
1117
1658
|
function matchGate(gate, toolName, toolInput = {}) {
|
|
1118
|
-
|
|
1659
|
+
let matchText = toolInput.command || toolInput.file_path || toolInput.path || '';
|
|
1660
|
+
|
|
1661
|
+
// Claw/hybrid support: enrich matchText with claw metadata (for EnterpriseClaw/OpenShell/Perplexity hybrid agents)
|
|
1662
|
+
const clawCtx = toolInput.clawContext || toolInput._claw || (toolInput.agentId ? {
|
|
1663
|
+
actionType: toolInput.actionType || 'unknown',
|
|
1664
|
+
agentId: toolInput.agentId || 'unknown',
|
|
1665
|
+
hybridRoute: toolInput.hybridRoute || 'unknown',
|
|
1666
|
+
screenInteraction: !!toolInput.screenInteraction,
|
|
1667
|
+
fileAccess: !!toolInput.fileAccess,
|
|
1668
|
+
} : null);
|
|
1669
|
+
|
|
1670
|
+
if (clawCtx) {
|
|
1671
|
+
const actionType = clawCtx.actionType || clawCtx.claw_action_type || 'unknown';
|
|
1672
|
+
const parts = [
|
|
1673
|
+
matchText,
|
|
1674
|
+
`claw_style: true`,
|
|
1675
|
+
`agent_identity: ${clawCtx.agentId || 'unknown'}`,
|
|
1676
|
+
`claw_action_type: ${actionType}`,
|
|
1677
|
+
`hybrid_route: ${clawCtx.hybridRoute || 'unknown'}`,
|
|
1678
|
+
];
|
|
1679
|
+
|
|
1680
|
+
if (clawCtx.screenInteraction || actionType.includes('screen')) {
|
|
1681
|
+
parts.push('screen_interaction');
|
|
1682
|
+
parts.push('interact screen');
|
|
1683
|
+
}
|
|
1684
|
+
if (clawCtx.fileAccess || actionType.includes('file') || actionType.includes('fs')) {
|
|
1685
|
+
parts.push('file_system_access');
|
|
1686
|
+
parts.push('local device file system access');
|
|
1687
|
+
}
|
|
1688
|
+
if (actionType === 'dynamic-tool-creation' || actionType.includes('create-tool') || actionType.includes('define-tool')) {
|
|
1689
|
+
parts.push('create tool');
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
matchText = parts.filter(Boolean).join(' | ');
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1119
1695
|
const affected = extractAffectedFiles(toolName, toolInput);
|
|
1120
1696
|
const affectedFiles = affected.files;
|
|
1121
1697
|
const repoRoot = affected.repoRoot;
|
|
1122
1698
|
const governanceState = loadGovernanceState();
|
|
1699
|
+
const constraints = loadConstraints();
|
|
1700
|
+
|
|
1701
|
+
if (gate.id === 'on-demand-freeze-mode' || (gate.when && gate.when.constraints && gate.when.constraints.freeze_mode)) {
|
|
1702
|
+
let freezePaths = [];
|
|
1703
|
+
if (process.env.THUMBGATE_FREEZE_PATHS) {
|
|
1704
|
+
freezePaths = process.env.THUMBGATE_FREEZE_PATHS.split(',').map(p => p.trim()).filter(Boolean);
|
|
1705
|
+
} else if (constraints.freeze_mode && typeof constraints.freeze_mode.value === 'string') {
|
|
1706
|
+
freezePaths = constraints.freeze_mode.value.split(',').map(p => p.trim()).filter(Boolean);
|
|
1707
|
+
} else if (constraints.freeze_mode && Array.isArray(constraints.freeze_mode.value)) {
|
|
1708
|
+
freezePaths = constraints.freeze_mode.value;
|
|
1709
|
+
} else if (governanceState.taskScope && Array.isArray(governanceState.taskScope.allowedPaths)) {
|
|
1710
|
+
freezePaths = governanceState.taskScope.allowedPaths;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
if (freezePaths.length > 0) {
|
|
1714
|
+
const outsideFiles = affectedFiles.filter((filePath) => !matchesAnyGlob(filePath, freezePaths));
|
|
1715
|
+
if (outsideFiles.length > 0) {
|
|
1716
|
+
return { matched: true, matchText, affectedFiles };
|
|
1717
|
+
} else {
|
|
1718
|
+
return { matched: false, matchText, affectedFiles };
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1123
1722
|
|
|
1124
1723
|
if (Array.isArray(gate.toolNames) && gate.toolNames.length > 0 && !gate.toolNames.includes(toolName)) {
|
|
1125
1724
|
return { matched: false, matchText, affectedFiles };
|
|
@@ -1129,6 +1728,12 @@ function matchGate(gate, toolName, toolInput = {}) {
|
|
|
1129
1728
|
try {
|
|
1130
1729
|
const regex = new RegExp(gate.pattern);
|
|
1131
1730
|
if (!regex.test(matchText)) return { matched: false, matchText, affectedFiles };
|
|
1731
|
+
if (gate.id === 'permission-change-approval' && isSafeLocalCredentialHardeningCommand(toolName, toolInput)) {
|
|
1732
|
+
return { matched: false, matchText, affectedFiles };
|
|
1733
|
+
}
|
|
1734
|
+
if (isBreakGlassSettingsBypass(gate, affectedFiles)) {
|
|
1735
|
+
return { matched: false, matchText, affectedFiles };
|
|
1736
|
+
}
|
|
1132
1737
|
} catch {
|
|
1133
1738
|
return { matched: false, matchText, affectedFiles };
|
|
1134
1739
|
}
|
|
@@ -1146,6 +1751,9 @@ function matchGate(gate, toolName, toolInput = {}) {
|
|
|
1146
1751
|
|
|
1147
1752
|
let taskScopeViolation = null;
|
|
1148
1753
|
if (gate.requireTaskScope) {
|
|
1754
|
+
if (isBreakGlassSettingsBypass(gate, affectedFiles)) {
|
|
1755
|
+
return { matched: false, matchText, affectedFiles };
|
|
1756
|
+
}
|
|
1149
1757
|
if (!shouldEnforceTaskScope(gate, governanceState, toolName, toolInput, affectedFiles)) {
|
|
1150
1758
|
return { matched: false, matchText, affectedFiles };
|
|
1151
1759
|
}
|
|
@@ -1155,6 +1763,9 @@ function matchGate(gate, toolName, toolInput = {}) {
|
|
|
1155
1763
|
|
|
1156
1764
|
let protectedApprovalViolation = null;
|
|
1157
1765
|
if (gate.requireProtectedApproval) {
|
|
1766
|
+
if (isBreakGlassSettingsBypass(gate, affectedFiles)) {
|
|
1767
|
+
return { matched: false, matchText, affectedFiles };
|
|
1768
|
+
}
|
|
1158
1769
|
const protectedGlobs = sanitizeGlobList(
|
|
1159
1770
|
Array.isArray(gate.protectedGlobs) && gate.protectedGlobs.length > 0
|
|
1160
1771
|
? gate.protectedGlobs
|
|
@@ -1194,9 +1805,40 @@ function matchesGate(gate, toolName, toolInput) {
|
|
|
1194
1805
|
return matchGate(gate, toolName, toolInput).matched;
|
|
1195
1806
|
}
|
|
1196
1807
|
|
|
1808
|
+
function isSafeLocalCredentialHardeningCommand(toolName, toolInput = {}) {
|
|
1809
|
+
if (toolName !== 'Bash') return false;
|
|
1810
|
+
const command = String(toolInput.command || '').trim();
|
|
1811
|
+
if (!command || isRecursiveChmodCommand(command)) return false;
|
|
1812
|
+
if (/[;&|`$()<>*?[\]{}]/.test(command)) return false;
|
|
1813
|
+
|
|
1814
|
+
const match = command.match(/(?:^|\s)chmod\s+(?:-[fv]\s+)?0?([46]00)\s+(['"]?)(\S+)\2\s*$/i);
|
|
1815
|
+
if (!match) return false;
|
|
1816
|
+
|
|
1817
|
+
const target = match[3];
|
|
1818
|
+
if (!target || target === '/' || target === '~') return false;
|
|
1819
|
+
if (target.includes('..')) return false;
|
|
1820
|
+
|
|
1821
|
+
const normalized = target.replace(/^['"]|['"]$/g, '').toLowerCase();
|
|
1822
|
+
const looksLikeCredentialPath = /(?:^|\/)(?:\.config|\.ssh|\.gnupg|\.aws|\.gcloud|\.gemini|\.resume_secrets|\.thumbgate|secrets?|credentials?)(?:\/|$)/.test(normalized)
|
|
1823
|
+
|| /(?:key|secret|token|credential|gemini|gcloud|google|operator).*\.(?:json|pem|key|env)$/i.test(normalized)
|
|
1824
|
+
|| /\.(?:pem|key)$/i.test(normalized);
|
|
1825
|
+
|
|
1826
|
+
return looksLikeCredentialPath;
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1197
1829
|
function evaluateMemoryGuard(toolName, toolInput = {}) {
|
|
1198
1830
|
const affected = extractAffectedFiles(toolName, toolInput);
|
|
1199
1831
|
const affectedFiles = affected.files;
|
|
1832
|
+
if (isSafeSecretStorageWrite(toolName, toolInput, process.cwd())) {
|
|
1833
|
+
return null;
|
|
1834
|
+
}
|
|
1835
|
+
// Hardening a credential file's permissions (chmod 600 on a key/secret path) is
|
|
1836
|
+
// a safety action, not a risk. The same exemption already guards the
|
|
1837
|
+
// permission-change-approval gate; without it here, `chmod 600 ~/.resume_secrets/key`
|
|
1838
|
+
// gets hard-denied by recurring-negative-memory matching — the opposite of intent.
|
|
1839
|
+
if (isSafeLocalCredentialHardeningCommand(toolName, toolInput)) {
|
|
1840
|
+
return null;
|
|
1841
|
+
}
|
|
1200
1842
|
if (!isHighRiskAction(toolName, toolInput, affectedFiles)) {
|
|
1201
1843
|
return null;
|
|
1202
1844
|
}
|
|
@@ -1210,7 +1852,10 @@ function evaluateMemoryGuard(toolName, toolInput = {}) {
|
|
|
1210
1852
|
}
|
|
1211
1853
|
|
|
1212
1854
|
const command = String(toolInput.command || '');
|
|
1213
|
-
|
|
1855
|
+
const isPrCreateCommand = toolName === 'Bash' && (
|
|
1856
|
+
/\bgh\s+pr\s+create\b/i.test(command) || isGhApiPrCreateCommand(command)
|
|
1857
|
+
);
|
|
1858
|
+
if (isPrCreateCommand && isConditionSatisfied('pr_create_allowed')) {
|
|
1214
1859
|
const branchGovernanceViolation = buildBranchGovernanceViolation(
|
|
1215
1860
|
governanceState,
|
|
1216
1861
|
toolInput,
|
|
@@ -1223,7 +1868,10 @@ function evaluateMemoryGuard(toolName, toolInput = {}) {
|
|
|
1223
1868
|
}
|
|
1224
1869
|
}
|
|
1225
1870
|
|
|
1226
|
-
if (toolName === 'Bash' &&
|
|
1871
|
+
if (toolName === 'Bash' && (
|
|
1872
|
+
/\b(?:gh\s+pr\s+(?:create|merge)|gh\s+release\s+create|git\s+tag\b|(?:npm|yarn|pnpm)\s+publish\b)\b/i.test(command) ||
|
|
1873
|
+
isGhApiPrCreateCommand(command)
|
|
1874
|
+
)) {
|
|
1227
1875
|
const branchGovernanceViolation = buildBranchGovernanceViolation(
|
|
1228
1876
|
governanceState,
|
|
1229
1877
|
toolInput,
|
|
@@ -1261,7 +1909,14 @@ function evaluateMemoryGuard(toolName, toolInput = {}) {
|
|
|
1261
1909
|
filePath: toolInput.file_path || toolInput.path || null,
|
|
1262
1910
|
affectedFiles,
|
|
1263
1911
|
});
|
|
1264
|
-
|
|
1912
|
+
// Claw/hybrid support: pass context if agent provides claw metadata (for EnterpriseClaw/OpenShell/Perplexity hybrid agents)
|
|
1913
|
+
let guard;
|
|
1914
|
+
if (toolInput && (toolInput.clawContext || toolInput._claw || toolInput.hybridRoute || toolInput.agentId)) {
|
|
1915
|
+
const clawCtx = toolInput.clawContext || toolInput._claw || { actionType: toolInput.actionType || 'unknown', agentId: toolInput.agentId || 'unknown', hybridRoute: toolInput.hybridRoute || 'unknown' };
|
|
1916
|
+
guard = hybrid.evaluateClawPretool ? hybrid.evaluateClawPretool(toolName, serializedInput, clawCtx) : hybrid.evaluatePretool(toolName, serializedInput);
|
|
1917
|
+
} else {
|
|
1918
|
+
guard = hybrid.evaluatePretool(toolName, serializedInput);
|
|
1919
|
+
}
|
|
1265
1920
|
if (!guard || guard.mode === 'allow') {
|
|
1266
1921
|
return null;
|
|
1267
1922
|
}
|
|
@@ -1374,6 +2029,19 @@ async function checkMetricCondition(metricCondition) {
|
|
|
1374
2029
|
return true;
|
|
1375
2030
|
}
|
|
1376
2031
|
|
|
2032
|
+
/**
|
|
2033
|
+
* Whether this run is autonomous — i.e. no human is present to resolve an
|
|
2034
|
+
* `approve` (human-in-the-loop) gate. Opt-in ONLY via THUMBGATE_AUTONOMOUS=1
|
|
2035
|
+
* (or "true"); interactive and existing CI behavior is unchanged unless an
|
|
2036
|
+
* operator explicitly sets it. In an autonomous agent loop an approval gate has
|
|
2037
|
+
* nobody to sign off, so it must fail CLOSED (deny) rather than defer forever or
|
|
2038
|
+
* slip through — a guardrail has to guard precisely when it is unattended.
|
|
2039
|
+
*/
|
|
2040
|
+
function isAutonomousRun() {
|
|
2041
|
+
const raw = String(process.env.THUMBGATE_AUTONOMOUS || '').trim().toLowerCase();
|
|
2042
|
+
return raw === '1' || raw === 'true';
|
|
2043
|
+
}
|
|
2044
|
+
|
|
1377
2045
|
async function evaluateGatesAsync(toolName, toolInput, configPath) {
|
|
1378
2046
|
let config;
|
|
1379
2047
|
try {
|
|
@@ -1399,10 +2067,26 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
|
|
|
1399
2067
|
if (localOnlyRemoteSideEffectGate) {
|
|
1400
2068
|
return recordStructuralGateBlock(toolName, toolInput, localOnlyRemoteSideEffectGate);
|
|
1401
2069
|
}
|
|
2070
|
+
const statefulHelperBypassGate = evaluateStatefulHelperBypassGate(toolName, toolInput);
|
|
2071
|
+
if (statefulHelperBypassGate) {
|
|
2072
|
+
return recordStructuralGateBlock(toolName, toolInput, statefulHelperBypassGate);
|
|
2073
|
+
}
|
|
2074
|
+
if (isBreakGlassSettingsRecoveryAction(toolName, toolInput)) {
|
|
2075
|
+
recordAuditEvent({
|
|
2076
|
+
toolName,
|
|
2077
|
+
toolInput,
|
|
2078
|
+
decision: 'allow',
|
|
2079
|
+
gateId: BREAK_GLASS_CONDITION,
|
|
2080
|
+
message: 'Break-glass recovery allowed hook settings edit',
|
|
2081
|
+
severity: 'high',
|
|
2082
|
+
source: 'gates-engine',
|
|
2083
|
+
});
|
|
2084
|
+
return null;
|
|
2085
|
+
}
|
|
1402
2086
|
|
|
1403
2087
|
const pendingThreadResolutionGate = evaluatePendingPrThreadResolutionGate(toolName, toolInput);
|
|
1404
2088
|
if (pendingThreadResolutionGate) {
|
|
1405
|
-
recordStat(pendingThreadResolutionGate.gate, 'block');
|
|
2089
|
+
recordStat(pendingThreadResolutionGate.gate, 'block', null, { toolName, toolInput });
|
|
1406
2090
|
const auditRecord = recordAuditEvent({
|
|
1407
2091
|
toolName,
|
|
1408
2092
|
toolInput,
|
|
@@ -1418,7 +2102,7 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
|
|
|
1418
2102
|
|
|
1419
2103
|
const boostedRiskGuard = evaluateBoostedRiskTagGuard(toolName, toolInput);
|
|
1420
2104
|
if (boostedRiskGuard) {
|
|
1421
|
-
recordStat(boostedRiskGuard.gate, 'block');
|
|
2105
|
+
recordStat(boostedRiskGuard.gate, 'block', null, { toolName, toolInput });
|
|
1422
2106
|
const auditRecord = recordAuditEvent({
|
|
1423
2107
|
toolName,
|
|
1424
2108
|
toolInput,
|
|
@@ -1432,6 +2116,23 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
|
|
|
1432
2116
|
return boostedRiskGuard;
|
|
1433
2117
|
}
|
|
1434
2118
|
|
|
2119
|
+
// Tier 1b: Planning and Trajectory (v1.26.0 - CodeRabbit Pattern).
|
|
2120
|
+
// Keep runtime enforcement explicit so advisory planning checks do not mask
|
|
2121
|
+
// higher-priority deny/approve gates in established workflows.
|
|
2122
|
+
if (isRuntimePlanGateEnabled()) {
|
|
2123
|
+
const planGate = evaluatePlanGate(toolName, toolInput);
|
|
2124
|
+
if (planGate) {
|
|
2125
|
+
recordStat(planGate.gate, planGate.decision === 'deny' ? 'block' : 'warn', null, { toolName, toolInput });
|
|
2126
|
+
return planGate;
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
const trajectory = getTrajectoryScore();
|
|
2130
|
+
if (trajectory.isDrifting) {
|
|
2131
|
+
recordStat('strategic-drift', 'block', null, { toolName, toolInput });
|
|
2132
|
+
return { decision: 'deny', gate: 'strategic-drift', message: trajectory.message, severity: 'high' };
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
|
|
1435
2136
|
// Fast-path: feedback/recall tools skip metric gates entirely (avoids Stripe API calls)
|
|
1436
2137
|
const METRIC_SKIP_TOOLS = ['capture_feedback', 'feedback_stats', 'recall', 'feedback_summary', 'prevention_rules'];
|
|
1437
2138
|
const skipMetrics = METRIC_SKIP_TOOLS.includes(toolName);
|
|
@@ -1477,23 +2178,48 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
|
|
|
1477
2178
|
});
|
|
1478
2179
|
|
|
1479
2180
|
if (gate.action === 'block') {
|
|
1480
|
-
|
|
1481
|
-
|
|
2181
|
+
const denyResult = { decision: 'deny', gate: gate.id, message, severity: gate.severity, reasoning };
|
|
2182
|
+
// Free-tier daily block cap: after N blocks/day, deny → warn + upgrade CTA
|
|
2183
|
+
const cappedResult = applyDailyBlockCap(denyResult);
|
|
2184
|
+
if (cappedResult) {
|
|
2185
|
+
recordStat(gate.id, 'warn', gate, { toolName, toolInput });
|
|
2186
|
+
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'warn', gateId: gate.id, message: cappedResult.message, severity: gate.severity, source: 'gates-engine', dailyBlockCapApplied: true });
|
|
2187
|
+
auditToFeedback(auditRecord);
|
|
2188
|
+
return cappedResult;
|
|
2189
|
+
}
|
|
2190
|
+
recordStat(gate.id, 'block', gate, { toolName, toolInput });
|
|
1482
2191
|
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'deny', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
|
|
1483
2192
|
auditToFeedback(auditRecord);
|
|
1484
|
-
return
|
|
2193
|
+
return denyResult;
|
|
1485
2194
|
}
|
|
1486
2195
|
|
|
1487
2196
|
if (gate.action === 'approve') {
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
2197
|
+
const approvalEnabled = process.env.THUMBGATE_APPROVAL_GATES !== '0';
|
|
2198
|
+
if (approvalEnabled) {
|
|
2199
|
+
if (isAutonomousRun()) {
|
|
2200
|
+
// Autonomous run: no human to approve. Fail CLOSED so the actions that
|
|
2201
|
+
// most need sign-off cannot slip through unattended.
|
|
2202
|
+
const failClosedMessage = `[autonomous run — no approver present, failing closed] ${message}`;
|
|
2203
|
+
recordStat(gate.id, 'block', gate, { toolName, toolInput });
|
|
2204
|
+
const failClosedAudit = recordAuditEvent({ toolName, toolInput, decision: 'deny', gateId: gate.id, message: failClosedMessage, severity: gate.severity, source: 'gates-engine', autonomousFailClosed: true });
|
|
2205
|
+
auditToFeedback(failClosedAudit);
|
|
2206
|
+
return { decision: 'deny', gate: gate.id, message: failClosedMessage, severity: gate.severity, reasoning, requiresApproval: true, failedClosed: true };
|
|
2207
|
+
}
|
|
2208
|
+
recordStat(gate.id, 'approve', gate, { toolName, toolInput });
|
|
2209
|
+
const result = { decision: 'approve', gate: gate.id, message, severity: gate.severity, reasoning, requiresApproval: true };
|
|
2210
|
+
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'approve', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
|
|
2211
|
+
auditToFeedback(auditRecord);
|
|
2212
|
+
return result;
|
|
2213
|
+
}
|
|
2214
|
+
recordStat(gate.id, 'warn', gate, { toolName, toolInput });
|
|
2215
|
+
const result = { decision: 'warn', gate: gate.id, message: `[approval gate disabled] ${message}`, severity: gate.severity, reasoning };
|
|
2216
|
+
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'warn', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
|
|
1491
2217
|
auditToFeedback(auditRecord);
|
|
1492
2218
|
return result;
|
|
1493
2219
|
}
|
|
1494
2220
|
|
|
1495
2221
|
if (gate.action === 'log') {
|
|
1496
|
-
recordStat(gate.id, 'log', gate);
|
|
2222
|
+
recordStat(gate.id, 'log', gate, { toolName, toolInput });
|
|
1497
2223
|
const result = { decision: 'log', gate: gate.id, message, severity: gate.severity, reasoning, logged: true };
|
|
1498
2224
|
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'log', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
|
|
1499
2225
|
auditToFeedback(auditRecord);
|
|
@@ -1502,7 +2228,7 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
|
|
|
1502
2228
|
}
|
|
1503
2229
|
|
|
1504
2230
|
if (gate.action === 'warn') {
|
|
1505
|
-
recordStat(gate.id, 'warn', gate);
|
|
2231
|
+
recordStat(gate.id, 'warn', gate, { toolName, toolInput });
|
|
1506
2232
|
const result = { decision: 'warn', gate: gate.id, message, severity: gate.severity, reasoning };
|
|
1507
2233
|
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'warn', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
|
|
1508
2234
|
auditToFeedback(auditRecord);
|
|
@@ -1510,14 +2236,15 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
|
|
|
1510
2236
|
}
|
|
1511
2237
|
}
|
|
1512
2238
|
|
|
1513
|
-
const
|
|
2239
|
+
const skipAdvisoryGuards = isSafeSecretStorageWrite(toolName, toolInput, process.cwd());
|
|
2240
|
+
const sentinelReport = skipAdvisoryGuards ? null : evaluateWorkflowSentinel(toolName, toolInput, {
|
|
1514
2241
|
governanceState,
|
|
1515
2242
|
});
|
|
1516
2243
|
const sentinelDecision = recordSentinelDecision(sentinelReport, toolName, toolInput);
|
|
1517
2244
|
const memoryGuard = evaluateMemoryGuard(toolName, toolInput);
|
|
1518
2245
|
if (memoryGuard) {
|
|
1519
2246
|
const enrichedMemoryGuard = enrichResultWithSentinel(memoryGuard, sentinelReport);
|
|
1520
|
-
recordStat(enrichedMemoryGuard.gate, 'block');
|
|
2247
|
+
recordStat(enrichedMemoryGuard.gate, 'block', null, { toolName, toolInput });
|
|
1521
2248
|
recordMemoryGuardDecision(sentinelDecision, enrichedMemoryGuard);
|
|
1522
2249
|
const auditRecord = recordAuditEvent({
|
|
1523
2250
|
toolName,
|
|
@@ -1534,7 +2261,7 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
|
|
|
1534
2261
|
|
|
1535
2262
|
if (sentinelReport && sentinelReport.decision !== 'allow') {
|
|
1536
2263
|
const sentinelResult = buildSentinelGateResult(sentinelReport);
|
|
1537
|
-
recordStat(sentinelResult.gate, sentinelResult.decision === 'deny' ? 'block' : 'warn');
|
|
2264
|
+
recordStat(sentinelResult.gate, sentinelResult.decision === 'deny' ? 'block' : 'warn', null, { toolName, toolInput });
|
|
1538
2265
|
recordSentinelBlockDecision(sentinelDecision, sentinelResult);
|
|
1539
2266
|
const auditRecord = recordAuditEvent({
|
|
1540
2267
|
toolName,
|
|
@@ -1580,10 +2307,26 @@ function evaluateGates(toolName, toolInput, configPath) {
|
|
|
1580
2307
|
if (localOnlyRemoteSideEffectGate) {
|
|
1581
2308
|
return recordStructuralGateBlock(toolName, toolInput, localOnlyRemoteSideEffectGate);
|
|
1582
2309
|
}
|
|
2310
|
+
const statefulHelperBypassGate = evaluateStatefulHelperBypassGate(toolName, toolInput);
|
|
2311
|
+
if (statefulHelperBypassGate) {
|
|
2312
|
+
return recordStructuralGateBlock(toolName, toolInput, statefulHelperBypassGate);
|
|
2313
|
+
}
|
|
2314
|
+
if (isBreakGlassSettingsRecoveryAction(toolName, toolInput)) {
|
|
2315
|
+
recordAuditEvent({
|
|
2316
|
+
toolName,
|
|
2317
|
+
toolInput,
|
|
2318
|
+
decision: 'allow',
|
|
2319
|
+
gateId: BREAK_GLASS_CONDITION,
|
|
2320
|
+
message: 'Break-glass recovery allowed hook settings edit',
|
|
2321
|
+
severity: 'high',
|
|
2322
|
+
source: 'gates-engine',
|
|
2323
|
+
});
|
|
2324
|
+
return null;
|
|
2325
|
+
}
|
|
1583
2326
|
|
|
1584
2327
|
const pendingThreadResolutionGate = evaluatePendingPrThreadResolutionGate(toolName, toolInput);
|
|
1585
2328
|
if (pendingThreadResolutionGate) {
|
|
1586
|
-
recordStat(pendingThreadResolutionGate.gate, 'block');
|
|
2329
|
+
recordStat(pendingThreadResolutionGate.gate, 'block', null, { toolName, toolInput });
|
|
1587
2330
|
const auditRecord = recordAuditEvent({
|
|
1588
2331
|
toolName,
|
|
1589
2332
|
toolInput,
|
|
@@ -1599,7 +2342,7 @@ function evaluateGates(toolName, toolInput, configPath) {
|
|
|
1599
2342
|
|
|
1600
2343
|
const boostedRiskGuard = evaluateBoostedRiskTagGuard(toolName, toolInput);
|
|
1601
2344
|
if (boostedRiskGuard) {
|
|
1602
|
-
recordStat(boostedRiskGuard.gate, 'block');
|
|
2345
|
+
recordStat(boostedRiskGuard.gate, 'block', null, { toolName, toolInput });
|
|
1603
2346
|
const auditRecord = recordAuditEvent({
|
|
1604
2347
|
toolName,
|
|
1605
2348
|
toolInput,
|
|
@@ -1613,6 +2356,23 @@ function evaluateGates(toolName, toolInput, configPath) {
|
|
|
1613
2356
|
return boostedRiskGuard;
|
|
1614
2357
|
}
|
|
1615
2358
|
|
|
2359
|
+
// Tier 1b: Planning and Trajectory (v1.26.0 - CodeRabbit Pattern).
|
|
2360
|
+
// Keep runtime enforcement explicit so advisory planning checks do not mask
|
|
2361
|
+
// higher-priority deny/approve gates in established workflows.
|
|
2362
|
+
if (isRuntimePlanGateEnabled()) {
|
|
2363
|
+
const planGate = evaluatePlanGate(toolName, toolInput);
|
|
2364
|
+
if (planGate) {
|
|
2365
|
+
recordStat(planGate.gate, planGate.decision === 'deny' ? 'block' : 'warn', null, { toolName, toolInput });
|
|
2366
|
+
return planGate;
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
const trajectory = getTrajectoryScore();
|
|
2370
|
+
if (trajectory.isDrifting) {
|
|
2371
|
+
recordStat('strategic-drift', 'block', null, { toolName, toolInput });
|
|
2372
|
+
return { decision: 'deny', gate: 'strategic-drift', message: trajectory.message, severity: 'high' };
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
|
|
1616
2376
|
for (const gate of config.gates) {
|
|
1617
2377
|
const matchDetails = matchGate(gate, toolName, toolInput);
|
|
1618
2378
|
if (!matchDetails.matched) continue;
|
|
@@ -1631,23 +2391,48 @@ function evaluateGates(toolName, toolInput, configPath) {
|
|
|
1631
2391
|
const reasoning = buildReasoning(gate, toolName, toolInput, matchDetails);
|
|
1632
2392
|
|
|
1633
2393
|
if (gate.action === 'block') {
|
|
1634
|
-
|
|
1635
|
-
|
|
2394
|
+
const denyResult = { decision: 'deny', gate: gate.id, message, severity: gate.severity, reasoning };
|
|
2395
|
+
// Free-tier daily block cap: after N blocks/day, deny → warn + upgrade CTA
|
|
2396
|
+
const cappedResult = applyDailyBlockCap(denyResult);
|
|
2397
|
+
if (cappedResult) {
|
|
2398
|
+
recordStat(gate.id, 'warn', gate, { toolName, toolInput });
|
|
2399
|
+
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'warn', gateId: gate.id, message: cappedResult.message, severity: gate.severity, source: 'gates-engine', dailyBlockCapApplied: true });
|
|
2400
|
+
auditToFeedback(auditRecord);
|
|
2401
|
+
return cappedResult;
|
|
2402
|
+
}
|
|
2403
|
+
recordStat(gate.id, 'block', gate, { toolName, toolInput });
|
|
1636
2404
|
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'deny', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
|
|
1637
2405
|
auditToFeedback(auditRecord);
|
|
1638
|
-
return
|
|
2406
|
+
return denyResult;
|
|
1639
2407
|
}
|
|
1640
2408
|
|
|
1641
2409
|
if (gate.action === 'approve') {
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
2410
|
+
const approvalEnabled = process.env.THUMBGATE_APPROVAL_GATES !== '0';
|
|
2411
|
+
if (approvalEnabled) {
|
|
2412
|
+
if (isAutonomousRun()) {
|
|
2413
|
+
// Autonomous run: no human to approve. Fail CLOSED so the actions that
|
|
2414
|
+
// most need sign-off cannot slip through unattended.
|
|
2415
|
+
const failClosedMessage = `[autonomous run — no approver present, failing closed] ${message}`;
|
|
2416
|
+
recordStat(gate.id, 'block', gate, { toolName, toolInput });
|
|
2417
|
+
const failClosedAudit = recordAuditEvent({ toolName, toolInput, decision: 'deny', gateId: gate.id, message: failClosedMessage, severity: gate.severity, source: 'gates-engine', autonomousFailClosed: true });
|
|
2418
|
+
auditToFeedback(failClosedAudit);
|
|
2419
|
+
return { decision: 'deny', gate: gate.id, message: failClosedMessage, severity: gate.severity, reasoning, requiresApproval: true, failedClosed: true };
|
|
2420
|
+
}
|
|
2421
|
+
recordStat(gate.id, 'approve', gate, { toolName, toolInput });
|
|
2422
|
+
const result = { decision: 'approve', gate: gate.id, message, severity: gate.severity, reasoning, requiresApproval: true };
|
|
2423
|
+
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'approve', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
|
|
2424
|
+
auditToFeedback(auditRecord);
|
|
2425
|
+
return result;
|
|
2426
|
+
}
|
|
2427
|
+
recordStat(gate.id, 'warn', gate, { toolName, toolInput });
|
|
2428
|
+
const result = { decision: 'warn', gate: gate.id, message: `[approval gate disabled] ${message}`, severity: gate.severity, reasoning };
|
|
2429
|
+
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'warn', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
|
|
1645
2430
|
auditToFeedback(auditRecord);
|
|
1646
2431
|
return result;
|
|
1647
2432
|
}
|
|
1648
2433
|
|
|
1649
2434
|
if (gate.action === 'log') {
|
|
1650
|
-
recordStat(gate.id, 'log', gate);
|
|
2435
|
+
recordStat(gate.id, 'log', gate, { toolName, toolInput });
|
|
1651
2436
|
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'log', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
|
|
1652
2437
|
auditToFeedback(auditRecord);
|
|
1653
2438
|
// 'log' action allows the tool call to proceed — continue to next gate
|
|
@@ -1655,7 +2440,7 @@ function evaluateGates(toolName, toolInput, configPath) {
|
|
|
1655
2440
|
}
|
|
1656
2441
|
|
|
1657
2442
|
if (gate.action === 'warn') {
|
|
1658
|
-
recordStat(gate.id, 'warn', gate);
|
|
2443
|
+
recordStat(gate.id, 'warn', gate, { toolName, toolInput });
|
|
1659
2444
|
const result = { decision: 'warn', gate: gate.id, message, severity: gate.severity, reasoning };
|
|
1660
2445
|
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'warn', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
|
|
1661
2446
|
auditToFeedback(auditRecord);
|
|
@@ -1663,14 +2448,15 @@ function evaluateGates(toolName, toolInput, configPath) {
|
|
|
1663
2448
|
}
|
|
1664
2449
|
}
|
|
1665
2450
|
|
|
1666
|
-
const
|
|
2451
|
+
const skipAdvisoryGuards = isSafeSecretStorageWrite(toolName, toolInput, process.cwd());
|
|
2452
|
+
const sentinelReport = skipAdvisoryGuards ? null : evaluateWorkflowSentinel(toolName, toolInput, {
|
|
1667
2453
|
governanceState,
|
|
1668
2454
|
});
|
|
1669
2455
|
const sentinelDecision = recordSentinelDecision(sentinelReport, toolName, toolInput);
|
|
1670
2456
|
const memoryGuard = evaluateMemoryGuard(toolName, toolInput);
|
|
1671
2457
|
if (memoryGuard) {
|
|
1672
2458
|
const enrichedMemoryGuard = enrichResultWithSentinel(memoryGuard, sentinelReport);
|
|
1673
|
-
recordStat(enrichedMemoryGuard.gate, 'block');
|
|
2459
|
+
recordStat(enrichedMemoryGuard.gate, 'block', null, { toolName, toolInput });
|
|
1674
2460
|
recordMemoryGuardDecision(sentinelDecision, enrichedMemoryGuard);
|
|
1675
2461
|
const auditRecord = recordAuditEvent({
|
|
1676
2462
|
toolName,
|
|
@@ -1687,7 +2473,7 @@ function evaluateGates(toolName, toolInput, configPath) {
|
|
|
1687
2473
|
|
|
1688
2474
|
if (sentinelReport && sentinelReport.decision !== 'allow') {
|
|
1689
2475
|
const sentinelResult = buildSentinelGateResult(sentinelReport);
|
|
1690
|
-
recordStat(sentinelResult.gate, sentinelResult.decision === 'deny' ? 'block' : 'warn');
|
|
2476
|
+
recordStat(sentinelResult.gate, sentinelResult.decision === 'deny' ? 'block' : 'warn', null, { toolName, toolInput });
|
|
1691
2477
|
recordSentinelBlockDecision(sentinelDecision, sentinelResult);
|
|
1692
2478
|
const auditRecord = recordAuditEvent({
|
|
1693
2479
|
toolName,
|
|
@@ -1707,14 +2493,43 @@ function evaluateGates(toolName, toolInput, configPath) {
|
|
|
1707
2493
|
return null;
|
|
1708
2494
|
}
|
|
1709
2495
|
|
|
1710
|
-
|
|
2496
|
+
// Turn a secret-exfiltration block into actionable guidance that names the
|
|
2497
|
+
// safe path, instead of a dead-end that drives agents toward brittle
|
|
2498
|
+
// workarounds (e.g. writing secrets to /tmp). The vault dirs referenced here
|
|
2499
|
+
// are the SAME constant the scanner whitelists, so the hint can never drift
|
|
2500
|
+
// from enforcement.
|
|
2501
|
+
function buildSecretRemediation(toolName = '', toolInput = {}) {
|
|
2502
|
+
const vaultDirs = (SAFE_SECRET_STORAGE_DIRS || []).map((dir) => `~/${dir}`);
|
|
2503
|
+
const primaryVault = vaultDirs[0] || '~/.resume_secrets';
|
|
2504
|
+
const vaultList = vaultDirs.join(', ') || primaryVault;
|
|
2505
|
+
|
|
2506
|
+
if (EDIT_LIKE_TOOLS && EDIT_LIKE_TOOLS.has(toolName)) {
|
|
2507
|
+
const target = toolInput.file_path || toolInput.path || toolInput.filePath || toolInput.target_path;
|
|
2508
|
+
const where = target ? ` (you targeted ${redactText(String(target))})` : '';
|
|
2509
|
+
return `To store this secret safely, write it with the Write/Edit tool to a file under ${vaultList}${where}. `
|
|
2510
|
+
+ `Those locations are whitelisted for secret storage and will NOT be blocked. `
|
|
2511
|
+
+ `Do not route around this by writing to /tmp or another path — that leaves the secret in a world-readable location and does not make it safe.`;
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
if (toolName === 'Bash') {
|
|
2515
|
+
return `Do not inline a live secret literal into a shell command — it leaks into shell history and process args. `
|
|
2516
|
+
+ `Instead, store the value with the Write tool to a file under ${vaultList}, then reference it via an environment variable or by reading that file at runtime.`;
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
return `Store secrets in the whitelisted vault (${vaultList}) using the Write tool rather than passing the literal through this action.`;
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
function buildSecretGuardResult(scanResult, context = {}) {
|
|
2523
|
+
const remediation = buildSecretRemediation(context.toolName, context.toolInput || {});
|
|
2524
|
+
const summary = buildSafeSummary(
|
|
2525
|
+
scanResult.findings,
|
|
2526
|
+
'Blocked because the action appears to expose secret material'
|
|
2527
|
+
);
|
|
1711
2528
|
return {
|
|
1712
2529
|
decision: 'deny',
|
|
1713
2530
|
gate: 'secret-exfiltration',
|
|
1714
|
-
message:
|
|
1715
|
-
|
|
1716
|
-
'Blocked because the action appears to expose secret material'
|
|
1717
|
-
),
|
|
2531
|
+
message: `${summary}. ${remediation}`,
|
|
2532
|
+
remediation,
|
|
1718
2533
|
severity: 'critical',
|
|
1719
2534
|
secretScan: {
|
|
1720
2535
|
provider: scanResult.provider,
|
|
@@ -1793,9 +2608,15 @@ function evaluateSecretGuard(input = {}) {
|
|
|
1793
2608
|
if (!scanResult.detected) {
|
|
1794
2609
|
return null;
|
|
1795
2610
|
}
|
|
1796
|
-
recordStat('secret-exfiltration', 'block'
|
|
2611
|
+
recordStat('secret-exfiltration', 'block', null, {
|
|
2612
|
+
toolName: input.tool_name || input.toolName || 'unknown',
|
|
2613
|
+
toolInput: input.tool_input || {},
|
|
2614
|
+
});
|
|
1797
2615
|
recordSecretViolation(input, scanResult);
|
|
1798
|
-
const result = buildSecretGuardResult(scanResult
|
|
2616
|
+
const result = buildSecretGuardResult(scanResult, {
|
|
2617
|
+
toolName: input.tool_name || input.toolName,
|
|
2618
|
+
toolInput: input.tool_input || {},
|
|
2619
|
+
});
|
|
1799
2620
|
// Audit trail: record secret guard denial
|
|
1800
2621
|
const auditRecord = recordAuditEvent({
|
|
1801
2622
|
toolName: input.tool_name || input.toolName || 'unknown',
|
|
@@ -1811,15 +2632,55 @@ function evaluateSecretGuard(input = {}) {
|
|
|
1811
2632
|
}
|
|
1812
2633
|
|
|
1813
2634
|
// ---------------------------------------------------------------------------
|
|
2635
|
+
function isApprovalGatesEnabled() {
|
|
2636
|
+
return process.env.THUMBGATE_APPROVAL_GATES !== '0';
|
|
2637
|
+
}
|
|
2638
|
+
|
|
1814
2639
|
// PreToolUse hook interface (stdin/stdout JSON)
|
|
1815
2640
|
// ---------------------------------------------------------------------------
|
|
1816
2641
|
|
|
1817
|
-
function
|
|
2642
|
+
function buildPreToolUseOutput(fields = {}) {
|
|
1818
2643
|
return {
|
|
2644
|
+
hookEventName: 'PreToolUse',
|
|
2645
|
+
...fields,
|
|
2646
|
+
};
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
function buildReminderOutput(context) {
|
|
2650
|
+
return buildPreToolUseOutput({
|
|
1819
2651
|
additionalContext: context,
|
|
1820
2652
|
systemReminder: context,
|
|
1821
2653
|
thumbgateSystemReminder: context,
|
|
1822
|
-
};
|
|
2654
|
+
});
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
// ---------------------------------------------------------------------------
|
|
2658
|
+
// Upgrade nudge: surfaces Pro value at usage milestones and trial expiry.
|
|
2659
|
+
// Block-action Pro CTA: brief upgrade mention after a deny/warn decision.
|
|
2660
|
+
// Highest-intent moment — user just saw ThumbGate save them from a mistake.
|
|
2661
|
+
// ---------------------------------------------------------------------------
|
|
2662
|
+
|
|
2663
|
+
function buildBlockActionProCta() {
|
|
2664
|
+
try {
|
|
2665
|
+
if (process.env.THUMBGATE_NO_NUDGE === '1') return null;
|
|
2666
|
+
if (process.env.CI || process.env.GITHUB_ACTIONS) return null;
|
|
2667
|
+
if (isProTier()) return null;
|
|
2668
|
+
if (isInTrialPeriod()) return null; // Already have full access
|
|
2669
|
+
|
|
2670
|
+
const stats = loadStats();
|
|
2671
|
+
const totalBlocks = stats.blocked || 0;
|
|
2672
|
+
if (totalBlocks < 5) return null; // Too early — let them experience the product
|
|
2673
|
+
|
|
2674
|
+
if (totalBlocks < 25) {
|
|
2675
|
+
return '\n\n💡 Pro: keep this rule synced across laptops, CI, containers, and agent runtimes → thumbgate.ai/go/pro';
|
|
2676
|
+
}
|
|
2677
|
+
if (totalBlocks < 100) {
|
|
2678
|
+
return `\n\n💡 ${totalBlocks} actions blocked. Pro keeps these lessons/rules synced everywhere → thumbgate.ai/go/pro ($19/mo)`;
|
|
2679
|
+
}
|
|
2680
|
+
return `\n\n💡 ${totalBlocks} mistakes caught. Your team could use shared hosted enforcement → thumbgate.ai/go/pro`;
|
|
2681
|
+
} catch (_) {
|
|
2682
|
+
return null;
|
|
2683
|
+
}
|
|
1823
2684
|
}
|
|
1824
2685
|
|
|
1825
2686
|
function formatOutput(result, behavioralContext) {
|
|
@@ -1838,13 +2699,28 @@ function formatOutput(result, behavioralContext) {
|
|
|
1838
2699
|
: '';
|
|
1839
2700
|
|
|
1840
2701
|
if (result.decision === 'deny') {
|
|
2702
|
+
const reminder = behavioralContext ? buildReminderOutput(behavioralContext) : {};
|
|
2703
|
+
const reminderSuffix = behavioralContext ? `\n\nSystem reminder:\n${behavioralContext}` : '';
|
|
2704
|
+
const proCta = buildBlockActionProCta() || '';
|
|
2705
|
+
return JSON.stringify({
|
|
2706
|
+
hookSpecificOutput: {
|
|
2707
|
+
hookEventName: 'PreToolUse',
|
|
2708
|
+
...reminder,
|
|
2709
|
+
permissionDecision: 'deny',
|
|
2710
|
+
permissionDecisionReason: `[GATE:${result.gate}] ${result.message}${reasoningSuffix}${reminderSuffix}${proCta}`,
|
|
2711
|
+
},
|
|
2712
|
+
});
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
if (result.decision === 'approve') {
|
|
1841
2716
|
const reminder = behavioralContext ? buildReminderOutput(behavioralContext) : {};
|
|
1842
2717
|
const reminderSuffix = behavioralContext ? `\n\nSystem reminder:\n${behavioralContext}` : '';
|
|
1843
2718
|
return JSON.stringify({
|
|
1844
2719
|
hookSpecificOutput: {
|
|
2720
|
+
hookEventName: 'PreToolUse',
|
|
1845
2721
|
...reminder,
|
|
1846
2722
|
permissionDecision: 'deny',
|
|
1847
|
-
permissionDecisionReason: `[GATE:${result.gate}] ${result.message}
|
|
2723
|
+
permissionDecisionReason: `[GATE:${result.gate}] APPROVAL REQUIRED: ${result.message} — Ask the human to confirm this action before proceeding.${reasoningSuffix}${reminderSuffix}`,
|
|
1848
2724
|
},
|
|
1849
2725
|
});
|
|
1850
2726
|
}
|
|
@@ -1854,6 +2730,7 @@ function formatOutput(result, behavioralContext) {
|
|
|
1854
2730
|
const context = `[GATE:${result.gate}] WARNING: ${result.message}${reasoningSuffix}${extra}`;
|
|
1855
2731
|
return JSON.stringify({
|
|
1856
2732
|
hookSpecificOutput: {
|
|
2733
|
+
hookEventName: 'PreToolUse',
|
|
1857
2734
|
additionalContext: context,
|
|
1858
2735
|
...(behavioralContext ? {
|
|
1859
2736
|
systemReminder: behavioralContext,
|
|
@@ -1977,9 +2854,7 @@ function buildRecentCorrectiveActionsContext(options = {}) {
|
|
|
1977
2854
|
function buildRelevantLessonContext(toolName, toolInput) {
|
|
1978
2855
|
if (!toolName) return null;
|
|
1979
2856
|
|
|
1980
|
-
const { retrieveRelevantLessons } = loadOptionalModule(
|
|
1981
|
-
retrieveRelevantLessons: () => [],
|
|
1982
|
-
}));
|
|
2857
|
+
const { retrieveRelevantLessons, calculateRetrievalEntropy } = loadOptionalModule("./lesson-retrieval", () => ({ retrieveRelevantLessons: () => [], calculateRetrievalEntropy: () => 0 }));
|
|
1983
2858
|
|
|
1984
2859
|
// Extract a searchable action context from the tool input
|
|
1985
2860
|
const actionContext = extractActionContext(toolName, toolInput);
|
|
@@ -1987,32 +2862,113 @@ function buildRelevantLessonContext(toolName, toolInput) {
|
|
|
1987
2862
|
|
|
1988
2863
|
try {
|
|
1989
2864
|
const lessons = retrieveRelevantLessons(toolName, actionContext, { maxResults: 3 });
|
|
1990
|
-
// retrieveRelevantLessons already filters at relevanceScore > 0.1 internally;
|
|
1991
|
-
// any negative lesson that survives retrieval is relevant enough to surface.
|
|
1992
|
-
const negative = lessons.filter((l) => l.signal === 'negative');
|
|
1993
|
-
if (negative.length === 0) return null;
|
|
1994
|
-
|
|
1995
|
-
const formatted = negative.map((l) => {
|
|
1996
|
-
const title = (l.title || '').replace(/^MISTAKE:\s*/, '').slice(0, 140);
|
|
1997
|
-
const advice = extractAvoidanceAdvice(l.content);
|
|
1998
|
-
return advice ? ` • ${title}\n → ${advice}` : ` • ${title}`;
|
|
1999
|
-
});
|
|
2000
2865
|
|
|
2001
|
-
|
|
2866
|
+
const entropy = calculateRetrievalEntropy(lessons);
|
|
2867
|
+
if (entropy > KNOWLEDGE_ENTROPY_THRESHOLD) {
|
|
2868
|
+
return buildKnowledgeConflictContext(toolName, toolInput, lessons, entropy);
|
|
2869
|
+
}
|
|
2870
|
+
return formatNegativeLessonContext(lessons);
|
|
2871
|
+
} catch {
|
|
2872
|
+
return null;
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
/**
|
|
2877
|
+
* Async counterpart of buildRelevantLessonContext: uses HYBRID (dense embeddings +
|
|
2878
|
+
* lexical) retrieval so the agent is warned about semantically-related past mistakes
|
|
2879
|
+
* even when they share no keywords with the current action. Wired into runAsync.
|
|
2880
|
+
* Degrades to the lexical result automatically when no embedder is available.
|
|
2881
|
+
*/
|
|
2882
|
+
async function buildRelevantLessonContextAsync(toolName, toolInput) {
|
|
2883
|
+
if (!toolName) return null;
|
|
2884
|
+
|
|
2885
|
+
const { retrieveRelevantLessonsAsync, retrieveRelevantLessons, calculateRetrievalEntropy } = loadOptionalModule(
|
|
2886
|
+
'./lesson-retrieval',
|
|
2887
|
+
() => ({ retrieveRelevantLessonsAsync: null, retrieveRelevantLessons: () => [], calculateRetrievalEntropy: () => 0 }),
|
|
2888
|
+
);
|
|
2889
|
+
|
|
2890
|
+
const actionContext = extractActionContext(toolName, toolInput);
|
|
2891
|
+
if (!actionContext) return null;
|
|
2892
|
+
|
|
2893
|
+
try {
|
|
2894
|
+
const lessons = retrieveRelevantLessonsAsync
|
|
2895
|
+
? await retrieveRelevantLessonsAsync(toolName, actionContext, { maxResults: 3 })
|
|
2896
|
+
: retrieveRelevantLessons(toolName, actionContext, { maxResults: 3 });
|
|
2897
|
+
|
|
2898
|
+
// Knowledge Conflict Detection: if retrieved lessons have high sentiment entropy,
|
|
2899
|
+
// it indicates conflicting past evidence. Warn by default; hard-block only in
|
|
2900
|
+
// strict mode for external/destructive side-effect commands.
|
|
2901
|
+
const entropy = calculateRetrievalEntropy(lessons);
|
|
2902
|
+
if (entropy > KNOWLEDGE_ENTROPY_THRESHOLD) {
|
|
2903
|
+
return buildKnowledgeConflictContext(toolName, toolInput, lessons, entropy);
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
return formatNegativeLessonContext(lessons);
|
|
2002
2907
|
} catch {
|
|
2003
2908
|
return null;
|
|
2004
2909
|
}
|
|
2005
2910
|
}
|
|
2006
2911
|
|
|
2912
|
+
/**
|
|
2913
|
+
* Shared formatter: render the negative (mistake) lessons that survived retrieval
|
|
2914
|
+
* into the PreToolUse warning block. Retrieval already filters by relevance, so any
|
|
2915
|
+
* negative lesson present is relevant enough to surface.
|
|
2916
|
+
*/
|
|
2917
|
+
function formatNegativeLessonContext(lessons) {
|
|
2918
|
+
const negative = (lessons || []).filter((l) => l.signal === 'negative');
|
|
2919
|
+
if (negative.length === 0) return null;
|
|
2920
|
+
|
|
2921
|
+
const formatted = negative.map((l) => {
|
|
2922
|
+
const title = (l.title || '').replace(/^MISTAKE:\s*/, '').slice(0, 140);
|
|
2923
|
+
const advice = extractAvoidanceAdvice(l.content);
|
|
2924
|
+
return advice ? ` • ${title}\n → ${advice}` : ` • ${title}`;
|
|
2925
|
+
});
|
|
2926
|
+
|
|
2927
|
+
return `[ThumbGate] Past mistakes relevant to this action — read before proceeding:\n${formatted.join('\n')}`;
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
function isStrictKnowledgeConflictMode() {
|
|
2931
|
+
return process.env.THUMBGATE_STRICT_KNOWLEDGE_CONFLICT === '1'
|
|
2932
|
+
|| process.env.THUMBGATE_STRICT_KNOWLEDGE_CONFLICT === 'true';
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2935
|
+
function isKnowledgeConflictHardBlockAction(toolName, toolInput = {}) {
|
|
2936
|
+
if (!isStrictKnowledgeConflictMode()) return false;
|
|
2937
|
+
if (EDIT_LIKE_TOOLS.has(toolName)) return true;
|
|
2938
|
+
if (toolName !== 'Bash') return false;
|
|
2939
|
+
return KNOWLEDGE_CONFLICT_STRICT_BASH_PATTERN.test(String(toolInput.command || ''));
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2942
|
+
function buildKnowledgeConflictContext(toolName, toolInput, lessons, entropy) {
|
|
2943
|
+
const lessonContext = formatNegativeLessonContext(lessons);
|
|
2944
|
+
const message = `Knowledge conflict warning: retrieved lessons disagree for this action (entropy ${entropy}). Treat the reminders below as cautionary context, but do not stop unrelated work solely because memory is noisy.`;
|
|
2945
|
+
|
|
2946
|
+
if (isKnowledgeConflictHardBlockAction(toolName, toolInput)) {
|
|
2947
|
+
recordStat('retrieval_entropy_high', 'block', null, { toolName, toolInput });
|
|
2948
|
+
return {
|
|
2949
|
+
decision: 'deny',
|
|
2950
|
+
gate: 'knowledge-conflict-gate',
|
|
2951
|
+
message: `✗ THUMBGATE: ${message} Strict mode is enabled for destructive or external side-effect actions; verify intent or narrow the task before proceeding.`,
|
|
2952
|
+
severity: 'high',
|
|
2953
|
+
};
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
recordStat('retrieval_entropy_high', 'warn', null, { toolName, toolInput });
|
|
2957
|
+
return mergeContextStrings(`[ThumbGate] ${message}`, lessonContext);
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2007
2960
|
function extractActionContext(toolName, toolInput) {
|
|
2008
2961
|
if (!toolInput) return toolName;
|
|
2009
2962
|
const parts = [toolName];
|
|
2010
2963
|
if (toolInput.command) parts.push(String(toolInput.command).slice(0, 400));
|
|
2011
2964
|
if (toolInput.file_path) parts.push(String(toolInput.file_path));
|
|
2965
|
+
if (toolInput.content) parts.push(String(toolInput.content).slice(0, 600));
|
|
2966
|
+
if (toolInput.new_string) parts.push(String(toolInput.new_string).slice(0, 600));
|
|
2967
|
+
if (toolInput.old_string) parts.push(String(toolInput.old_string).slice(0, 240));
|
|
2012
2968
|
if (toolInput.description) parts.push(String(toolInput.description).slice(0, 200));
|
|
2013
2969
|
if (toolInput.prompt) parts.push(String(toolInput.prompt).slice(0, 400));
|
|
2014
2970
|
if (toolInput.pattern) parts.push(String(toolInput.pattern).slice(0, 200));
|
|
2015
|
-
return parts.filter(Boolean).join(' ');
|
|
2971
|
+
return sanitizeFeedbackText(parts.filter(Boolean).join(' ')) || toolName;
|
|
2016
2972
|
}
|
|
2017
2973
|
|
|
2018
2974
|
function extractAvoidanceAdvice(content) {
|
|
@@ -2041,6 +2997,13 @@ async function runAsync(input) {
|
|
|
2041
2997
|
|
|
2042
2998
|
const toolName = input.tool_name || '';
|
|
2043
2999
|
const toolInput = input.tool_input || {};
|
|
3000
|
+
const safeSecretStorageWrite = isSafeSecretStorageWrite(toolName, toolInput, process.cwd());
|
|
3001
|
+
|
|
3002
|
+
const sequenceGuard = evaluateSequenceState(toolName, toolInput);
|
|
3003
|
+
if (sequenceGuard && sequenceGuard.decision === 'deny') {
|
|
3004
|
+
return formatOutput(applyEnforcementPosture(sequenceGuard));
|
|
3005
|
+
}
|
|
3006
|
+
|
|
2044
3007
|
const result = await evaluateGatesAsync(toolName, toolInput);
|
|
2045
3008
|
|
|
2046
3009
|
// Attach security warnings to allow/warn results
|
|
@@ -2053,11 +3016,18 @@ async function runAsync(input) {
|
|
|
2053
3016
|
}
|
|
2054
3017
|
}
|
|
2055
3018
|
|
|
2056
|
-
|
|
2057
|
-
const
|
|
3019
|
+
|
|
3020
|
+
const behavioralContext = safeSecretStorageWrite ? null : buildBehavioralContext();
|
|
3021
|
+
const lessonContext = safeSecretStorageWrite ? null : await buildRelevantLessonContextAsync(toolName, toolInput);
|
|
3022
|
+
|
|
3023
|
+
if (lessonContext && lessonContext.decision === "deny") {
|
|
3024
|
+
return formatOutput(applyEnforcementPosture(lessonContext));
|
|
3025
|
+
}
|
|
3026
|
+
|
|
2058
3027
|
const recentContext = buildRecentCorrectiveActionsContext();
|
|
2059
3028
|
const combinedContext = mergeContextStrings(lessonContext, recentContext, behavioralContext);
|
|
2060
|
-
return formatOutput(result, combinedContext);
|
|
3029
|
+
return formatOutput(applyEnforcementPosture(result), combinedContext);
|
|
3030
|
+
|
|
2061
3031
|
}
|
|
2062
3032
|
|
|
2063
3033
|
function run(input) {
|
|
@@ -2074,6 +3044,13 @@ function run(input) {
|
|
|
2074
3044
|
|
|
2075
3045
|
const toolName = input.tool_name || '';
|
|
2076
3046
|
const toolInput = input.tool_input || {};
|
|
3047
|
+
const safeSecretStorageWrite = isSafeSecretStorageWrite(toolName, toolInput, process.cwd());
|
|
3048
|
+
|
|
3049
|
+
const sequenceGuard = evaluateSequenceState(toolName, toolInput);
|
|
3050
|
+
if (sequenceGuard && sequenceGuard.decision === 'deny') {
|
|
3051
|
+
return formatOutput(applyEnforcementPosture(sequenceGuard));
|
|
3052
|
+
}
|
|
3053
|
+
|
|
2077
3054
|
const result = evaluateGates(toolName, toolInput);
|
|
2078
3055
|
|
|
2079
3056
|
// Attach security warnings to allow/warn results
|
|
@@ -2086,11 +3063,18 @@ function run(input) {
|
|
|
2086
3063
|
}
|
|
2087
3064
|
}
|
|
2088
3065
|
|
|
2089
|
-
|
|
2090
|
-
const
|
|
3066
|
+
|
|
3067
|
+
const behavioralContext = safeSecretStorageWrite ? null : buildBehavioralContext();
|
|
3068
|
+
const lessonContext = safeSecretStorageWrite ? null : buildRelevantLessonContext(toolName, toolInput);
|
|
3069
|
+
|
|
3070
|
+
if (lessonContext && lessonContext.decision === "deny") {
|
|
3071
|
+
return formatOutput(applyEnforcementPosture(lessonContext));
|
|
3072
|
+
}
|
|
3073
|
+
|
|
2091
3074
|
const recentContext = buildRecentCorrectiveActionsContext();
|
|
2092
3075
|
const combinedContext = mergeContextStrings(lessonContext, recentContext, behavioralContext);
|
|
2093
|
-
return formatOutput(result, combinedContext);
|
|
3076
|
+
return formatOutput(applyEnforcementPosture(result), combinedContext);
|
|
3077
|
+
|
|
2094
3078
|
}
|
|
2095
3079
|
|
|
2096
3080
|
// ---------------------------------------------------------------------------
|
|
@@ -2223,7 +3207,84 @@ function registerClaimGate(claimPattern, requiredActions, blockMessage) {
|
|
|
2223
3207
|
return entry;
|
|
2224
3208
|
}
|
|
2225
3209
|
|
|
2226
|
-
function
|
|
3210
|
+
function normalizeStringArray(value) {
|
|
3211
|
+
if (!Array.isArray(value)) return [];
|
|
3212
|
+
return Array.from(new Set(
|
|
3213
|
+
value
|
|
3214
|
+
.map((item) => String(item || '').trim())
|
|
3215
|
+
.filter(Boolean)
|
|
3216
|
+
));
|
|
3217
|
+
}
|
|
3218
|
+
|
|
3219
|
+
function normalizeGoalContract(goalContract) {
|
|
3220
|
+
if (!goalContract || typeof goalContract !== 'object' || Array.isArray(goalContract)) {
|
|
3221
|
+
return null;
|
|
3222
|
+
}
|
|
3223
|
+
|
|
3224
|
+
const goal = String(goalContract.goal || '').trim();
|
|
3225
|
+
const doneWhen = normalizeStringArray(goalContract.doneWhen);
|
|
3226
|
+
const requiredActions = normalizeStringArray(goalContract.proveBy);
|
|
3227
|
+
const mustNotChange = normalizeStringArray(goalContract.mustNotChange);
|
|
3228
|
+
const handoff = {
|
|
3229
|
+
workerAgent: String(goalContract.workerAgent || '').trim() || null,
|
|
3230
|
+
reviewerAgent: String(goalContract.reviewerAgent || '').trim() || null,
|
|
3231
|
+
orchestratorAgent: String(goalContract.orchestratorAgent || '').trim() || null,
|
|
3232
|
+
};
|
|
3233
|
+
|
|
3234
|
+
const matched = Boolean(
|
|
3235
|
+
goal ||
|
|
3236
|
+
doneWhen.length > 0 ||
|
|
3237
|
+
requiredActions.length > 0 ||
|
|
3238
|
+
mustNotChange.length > 0 ||
|
|
3239
|
+
handoff.workerAgent ||
|
|
3240
|
+
handoff.reviewerAgent ||
|
|
3241
|
+
handoff.orchestratorAgent
|
|
3242
|
+
);
|
|
3243
|
+
|
|
3244
|
+
if (!matched) return null;
|
|
3245
|
+
|
|
3246
|
+
return {
|
|
3247
|
+
goal: goal || null,
|
|
3248
|
+
doneWhen,
|
|
3249
|
+
requiredActions,
|
|
3250
|
+
mustNotChange,
|
|
3251
|
+
handoff,
|
|
3252
|
+
};
|
|
3253
|
+
}
|
|
3254
|
+
|
|
3255
|
+
function evaluateGoalContract(goalContract, actions = loadSessionActions()) {
|
|
3256
|
+
const normalized = normalizeGoalContract(goalContract);
|
|
3257
|
+
if (!normalized) {
|
|
3258
|
+
return {
|
|
3259
|
+
matched: false,
|
|
3260
|
+
passed: true,
|
|
3261
|
+
goal: null,
|
|
3262
|
+
doneWhen: [],
|
|
3263
|
+
requiredActions: [],
|
|
3264
|
+
missingActions: [],
|
|
3265
|
+
mustNotChange: [],
|
|
3266
|
+
handoff: {
|
|
3267
|
+
workerAgent: null,
|
|
3268
|
+
reviewerAgent: null,
|
|
3269
|
+
orchestratorAgent: null,
|
|
3270
|
+
},
|
|
3271
|
+
};
|
|
3272
|
+
}
|
|
3273
|
+
|
|
3274
|
+
const missingActions = normalized.requiredActions.filter((actionId) => !actions[actionId]);
|
|
3275
|
+
return {
|
|
3276
|
+
matched: true,
|
|
3277
|
+
passed: missingActions.length === 0,
|
|
3278
|
+
goal: normalized.goal,
|
|
3279
|
+
doneWhen: normalized.doneWhen,
|
|
3280
|
+
requiredActions: normalized.requiredActions,
|
|
3281
|
+
missingActions,
|
|
3282
|
+
mustNotChange: normalized.mustNotChange,
|
|
3283
|
+
handoff: normalized.handoff,
|
|
3284
|
+
};
|
|
3285
|
+
}
|
|
3286
|
+
|
|
3287
|
+
function verifyClaimEvidence(claimText, options = {}) {
|
|
2227
3288
|
const normalizedClaimText = String(claimText || '').trim();
|
|
2228
3289
|
if (!normalizedClaimText) {
|
|
2229
3290
|
throw new Error('claimText is required');
|
|
@@ -2251,9 +3312,23 @@ function verifyClaimEvidence(claimText) {
|
|
|
2251
3312
|
});
|
|
2252
3313
|
}
|
|
2253
3314
|
|
|
3315
|
+
const goalContract = evaluateGoalContract(options.goalContract, actions);
|
|
3316
|
+
if (goalContract.matched) {
|
|
3317
|
+
checks.push({
|
|
3318
|
+
claim: 'goal_contract',
|
|
3319
|
+
passed: goalContract.passed,
|
|
3320
|
+
missing: goalContract.missingActions,
|
|
3321
|
+
message: goalContract.passed
|
|
3322
|
+
? 'Goal contract evidence present'
|
|
3323
|
+
: `Goal contract requires evidence: ${goalContract.missingActions.join(', ')}`,
|
|
3324
|
+
goalContract,
|
|
3325
|
+
});
|
|
3326
|
+
}
|
|
3327
|
+
|
|
2254
3328
|
return {
|
|
2255
3329
|
verified: checks.every((check) => check.passed),
|
|
2256
3330
|
checks,
|
|
3331
|
+
goalContract,
|
|
2257
3332
|
};
|
|
2258
3333
|
}
|
|
2259
3334
|
|
|
@@ -2273,6 +3348,7 @@ module.exports = {
|
|
|
2273
3348
|
setTaskScope,
|
|
2274
3349
|
setBranchGovernance,
|
|
2275
3350
|
approveProtectedAction,
|
|
3351
|
+
breakGlassEmergency,
|
|
2276
3352
|
getScopeState,
|
|
2277
3353
|
getBranchGovernanceState,
|
|
2278
3354
|
isConditionSatisfied,
|
|
@@ -2287,8 +3363,10 @@ module.exports = {
|
|
|
2287
3363
|
matchesGate,
|
|
2288
3364
|
evaluateGates,
|
|
2289
3365
|
evaluateGatesAsync,
|
|
3366
|
+
isAutonomousRun,
|
|
2290
3367
|
computeExecutableHash,
|
|
2291
3368
|
formatOutput,
|
|
3369
|
+
isApprovalGatesEnabled,
|
|
2292
3370
|
run,
|
|
2293
3371
|
runAsync,
|
|
2294
3372
|
trackAction,
|
|
@@ -2297,6 +3375,8 @@ module.exports = {
|
|
|
2297
3375
|
clearSessionActions,
|
|
2298
3376
|
loadClaimGates,
|
|
2299
3377
|
registerClaimGate,
|
|
3378
|
+
normalizeGoalContract,
|
|
3379
|
+
evaluateGoalContract,
|
|
2300
3380
|
verifyClaimEvidence,
|
|
2301
3381
|
DEFAULT_CONFIG_PATH,
|
|
2302
3382
|
DEFAULT_CLAIM_GATES_PATH,
|
|
@@ -2313,6 +3393,7 @@ module.exports = {
|
|
|
2313
3393
|
buildBehavioralContext,
|
|
2314
3394
|
buildRecentCorrectiveActionsContext,
|
|
2315
3395
|
buildRelevantLessonContext,
|
|
3396
|
+
buildRelevantLessonContextAsync,
|
|
2316
3397
|
extractActionContext,
|
|
2317
3398
|
extractAvoidanceAdvice,
|
|
2318
3399
|
mergeContextStrings,
|
|
@@ -2324,10 +3405,20 @@ module.exports = {
|
|
|
2324
3405
|
evaluateBoostedRiskTagGuard,
|
|
2325
3406
|
registerPrThreadResolutionClaimGate,
|
|
2326
3407
|
evaluatePendingPrThreadResolutionGate,
|
|
3408
|
+
isReadOnlyObservabilityTool,
|
|
2327
3409
|
getLocalOnlyScopeSources,
|
|
2328
3410
|
isRemoteSideEffectCommand,
|
|
2329
3411
|
evaluateLocalOnlyRemoteSideEffectGate,
|
|
3412
|
+
recordHelperScriptWrite,
|
|
3413
|
+
evaluateStatefulHelperBypassGate,
|
|
3414
|
+
isAgentHookSettingsFile,
|
|
3415
|
+
isBreakGlassSettingsRecoveryAction,
|
|
2330
3416
|
PR_THREAD_RESOLUTION_ACTION,
|
|
3417
|
+
HELPER_BYPASS_ACTION,
|
|
3418
|
+
buildBlockActionProCta,
|
|
3419
|
+
applyDailyBlockCap,
|
|
3420
|
+
getTodayBlockCount,
|
|
3421
|
+
incrementTodayBlockCount,
|
|
2331
3422
|
};
|
|
2332
3423
|
|
|
2333
3424
|
// ---------------------------------------------------------------------------
|