@loopover/miner 0.1.0
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/DEPLOYMENT.md +156 -0
- package/Dockerfile +38 -0
- package/README.md +269 -0
- package/bin/gittensory-miner-mcp.d.ts +64 -0
- package/bin/gittensory-miner-mcp.js +279 -0
- package/bin/gittensory-miner.js +196 -0
- package/docs/coding-agent-driver.md +140 -0
- package/docs/config-precedence.md +96 -0
- package/docs/cross-repo-discovery-phase1.md +55 -0
- package/docs/discovery-index-contract.md +60 -0
- package/docs/discovery-plane-operator-guide.md +101 -0
- package/docs/env-reference.md +30 -0
- package/docs/fleet-run-manifest.md +50 -0
- package/docs/miner-goal-spec.md +76 -0
- package/docs/miner-selfimprove-calibration.md +105 -0
- package/docs/observability.md +60 -0
- package/docs/operations-runbook.md +209 -0
- package/docs/repo-agnostic-capability-audit.md +129 -0
- package/docs/unattended-scheduling.md +107 -0
- package/expected-engine.version +1 -0
- package/lib/ams-policy.d.ts +20 -0
- package/lib/ams-policy.js +68 -0
- package/lib/attempt-cli.d.ts +99 -0
- package/lib/attempt-cli.js +523 -0
- package/lib/attempt-input-builder.d.ts +24 -0
- package/lib/attempt-input-builder.js +91 -0
- package/lib/attempt-log.d.ts +37 -0
- package/lib/attempt-log.js +177 -0
- package/lib/attempt-runner.d.ts +59 -0
- package/lib/attempt-runner.js +164 -0
- package/lib/attempt-worktree.d.ts +31 -0
- package/lib/attempt-worktree.js +94 -0
- package/lib/calibration-cli.d.ts +1 -0
- package/lib/calibration-cli.js +92 -0
- package/lib/calibration-run.d.ts +161 -0
- package/lib/calibration-run.js +232 -0
- package/lib/calibration-types.d.ts +46 -0
- package/lib/calibration-types.js +74 -0
- package/lib/calibration.d.ts +24 -0
- package/lib/calibration.js +99 -0
- package/lib/ci-poller.d.ts +33 -0
- package/lib/ci-poller.js +234 -0
- package/lib/claim-adjudication.d.ts +21 -0
- package/lib/claim-adjudication.js +36 -0
- package/lib/claim-conflict-resolver.d.ts +29 -0
- package/lib/claim-conflict-resolver.js +101 -0
- package/lib/claim-ledger-cli.d.ts +59 -0
- package/lib/claim-ledger-cli.js +320 -0
- package/lib/claim-ledger-expiry.d.ts +20 -0
- package/lib/claim-ledger-expiry.js +41 -0
- package/lib/claim-ledger.d.ts +63 -0
- package/lib/claim-ledger.js +303 -0
- package/lib/cli-error.d.ts +3 -0
- package/lib/cli-error.js +27 -0
- package/lib/cli.d.ts +3 -0
- package/lib/cli.js +72 -0
- package/lib/coding-agent-construction.d.ts +17 -0
- package/lib/coding-agent-construction.js +103 -0
- package/lib/coding-agent-house-rules.d.ts +24 -0
- package/lib/coding-agent-house-rules.js +64 -0
- package/lib/coding-task-spec.d.ts +44 -0
- package/lib/coding-task-spec.js +183 -0
- package/lib/config-precedence.d.ts +25 -0
- package/lib/config-precedence.js +38 -0
- package/lib/deny-check.d.ts +11 -0
- package/lib/deny-check.js +76 -0
- package/lib/deny-hook-synthesis.d.ts +93 -0
- package/lib/deny-hook-synthesis.js +399 -0
- package/lib/deny-hooks.d.ts +28 -0
- package/lib/deny-hooks.js +155 -0
- package/lib/deployment-docs-audit.d.ts +41 -0
- package/lib/deployment-docs-audit.js +115 -0
- package/lib/discover-cli.d.ts +94 -0
- package/lib/discover-cli.js +284 -0
- package/lib/discovery-throttle.d.ts +8 -0
- package/lib/discovery-throttle.js +33 -0
- package/lib/env-file-indirection.d.ts +4 -0
- package/lib/env-file-indirection.js +45 -0
- package/lib/event-ledger-cli.d.ts +82 -0
- package/lib/event-ledger-cli.js +266 -0
- package/lib/event-ledger.d.ts +37 -0
- package/lib/event-ledger.js +210 -0
- package/lib/execute-local-write.d.ts +14 -0
- package/lib/execute-local-write.js +50 -0
- package/lib/feasibility-cli.d.ts +25 -0
- package/lib/feasibility-cli.js +80 -0
- package/lib/forge-config.d.ts +17 -0
- package/lib/forge-config.js +37 -0
- package/lib/governor-action-mode.d.ts +26 -0
- package/lib/governor-action-mode.js +47 -0
- package/lib/governor-chokepoint-persisted.d.ts +18 -0
- package/lib/governor-chokepoint-persisted.js +46 -0
- package/lib/governor-chokepoint.d.ts +14 -0
- package/lib/governor-chokepoint.js +52 -0
- package/lib/governor-kill-switch.d.ts +26 -0
- package/lib/governor-kill-switch.js +47 -0
- package/lib/governor-ledger-cli.d.ts +32 -0
- package/lib/governor-ledger-cli.js +155 -0
- package/lib/governor-ledger.d.ts +46 -0
- package/lib/governor-ledger.js +203 -0
- package/lib/governor-open-pr.d.ts +21 -0
- package/lib/governor-open-pr.js +27 -0
- package/lib/governor-pause-cli.d.ts +23 -0
- package/lib/governor-pause-cli.js +171 -0
- package/lib/governor-run-halt.d.ts +37 -0
- package/lib/governor-run-halt.js +58 -0
- package/lib/governor-state.d.ts +63 -0
- package/lib/governor-state.js +363 -0
- package/lib/governor-write-rate-limit.d.ts +30 -0
- package/lib/governor-write-rate-limit.js +64 -0
- package/lib/harness-submission-trigger.d.ts +69 -0
- package/lib/harness-submission-trigger.js +138 -0
- package/lib/http-retry.d.ts +12 -0
- package/lib/http-retry.js +52 -0
- package/lib/laptop-init.d.ts +53 -0
- package/lib/laptop-init.js +332 -0
- package/lib/live-issue-snapshot.d.ts +16 -0
- package/lib/live-issue-snapshot.js +117 -0
- package/lib/local-store.d.ts +18 -0
- package/lib/local-store.js +62 -0
- package/lib/logger.d.ts +58 -0
- package/lib/logger.js +167 -0
- package/lib/loop-cli.d.ts +66 -0
- package/lib/loop-cli.js +540 -0
- package/lib/loop-closure.d.ts +35 -0
- package/lib/loop-closure.js +66 -0
- package/lib/loop-reentry.d.ts +51 -0
- package/lib/loop-reentry.js +124 -0
- package/lib/manage-poll.d.ts +79 -0
- package/lib/manage-poll.js +254 -0
- package/lib/manage-status.d.ts +70 -0
- package/lib/manage-status.js +241 -0
- package/lib/metrics-cli.d.ts +9 -0
- package/lib/metrics-cli.js +50 -0
- package/lib/migrate-cli.d.ts +24 -0
- package/lib/migrate-cli.js +111 -0
- package/lib/miner-goal-spec.d.ts +12 -0
- package/lib/miner-goal-spec.js +67 -0
- package/lib/opportunity-fanout.d.ts +86 -0
- package/lib/opportunity-fanout.js +597 -0
- package/lib/opportunity-ranker.d.ts +36 -0
- package/lib/opportunity-ranker.js +118 -0
- package/lib/orb-export.d.ts +54 -0
- package/lib/orb-export.js +206 -0
- package/lib/plan-store-cli.d.ts +37 -0
- package/lib/plan-store-cli.js +151 -0
- package/lib/plan-store.d.ts +51 -0
- package/lib/plan-store.js +231 -0
- package/lib/policy-doc-cache.d.ts +25 -0
- package/lib/policy-doc-cache.js +79 -0
- package/lib/policy-verdict-cache.d.ts +35 -0
- package/lib/policy-verdict-cache.js +106 -0
- package/lib/portfolio-dashboard.d.ts +30 -0
- package/lib/portfolio-dashboard.js +105 -0
- package/lib/portfolio-discovery.d.ts +29 -0
- package/lib/portfolio-discovery.js +100 -0
- package/lib/portfolio-queue-cli.d.ts +87 -0
- package/lib/portfolio-queue-cli.js +509 -0
- package/lib/portfolio-queue-expiry.d.ts +20 -0
- package/lib/portfolio-queue-expiry.js +51 -0
- package/lib/portfolio-queue-manager.d.ts +50 -0
- package/lib/portfolio-queue-manager.js +134 -0
- package/lib/portfolio-queue.d.ts +62 -0
- package/lib/portfolio-queue.js +351 -0
- package/lib/pr-disposition-poller.d.ts +26 -0
- package/lib/pr-disposition-poller.js +163 -0
- package/lib/pr-number-parse.d.ts +4 -0
- package/lib/pr-number-parse.js +23 -0
- package/lib/pr-outcome.d.ts +41 -0
- package/lib/pr-outcome.js +90 -0
- package/lib/prediction-ledger.d.ts +47 -0
- package/lib/prediction-ledger.js +222 -0
- package/lib/pretooluse-hook.d.ts +31 -0
- package/lib/pretooluse-hook.js +93 -0
- package/lib/process-lifecycle.d.ts +31 -0
- package/lib/process-lifecycle.js +106 -0
- package/lib/purge-cli.d.ts +44 -0
- package/lib/purge-cli.js +198 -0
- package/lib/rejection-signal.d.ts +6 -0
- package/lib/rejection-signal.js +101 -0
- package/lib/rejection-state-machine.d.ts +37 -0
- package/lib/rejection-state-machine.js +81 -0
- package/lib/rejection-templates.d.ts +12 -0
- package/lib/rejection-templates.js +71 -0
- package/lib/replay-objective-anchor.d.ts +79 -0
- package/lib/replay-objective-anchor.js +179 -0
- package/lib/replay-snapshot.d.ts +41 -0
- package/lib/replay-snapshot.js +288 -0
- package/lib/replay-task-generation.d.ts +126 -0
- package/lib/replay-task-generation.js +223 -0
- package/lib/repo-clone.d.ts +19 -0
- package/lib/repo-clone.js +102 -0
- package/lib/run-state-cli.d.ts +27 -0
- package/lib/run-state-cli.js +154 -0
- package/lib/run-state.d.ts +37 -0
- package/lib/run-state.js +150 -0
- package/lib/schema-version.d.ts +17 -0
- package/lib/schema-version.js +71 -0
- package/lib/self-review-context.d.ts +28 -0
- package/lib/self-review-context.js +339 -0
- package/lib/slop-assessment.d.ts +3 -0
- package/lib/slop-assessment.js +20 -0
- package/lib/stack-detection.d.ts +41 -0
- package/lib/stack-detection.js +248 -0
- package/lib/status.d.ts +64 -0
- package/lib/status.js +441 -0
- package/lib/store-maintenance.d.ts +31 -0
- package/lib/store-maintenance.js +183 -0
- package/lib/submission-freshness-check.d.ts +32 -0
- package/lib/submission-freshness-check.js +93 -0
- package/lib/update-check.d.ts +36 -0
- package/lib/update-check.js +161 -0
- package/lib/version.d.ts +3 -0
- package/lib/version.js +10 -0
- package/lib/worktree-allocator.d.ts +39 -0
- package/lib/worktree-allocator.js +262 -0
- package/package.json +50 -0
- package/schema/miner-goal-spec.schema.json +111 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
// Synthesize PreToolUse deny-hook rule proposals from per-repo blocker/path history (#4522). Pure synthesis
|
|
2
|
+
// plus an optional local SQLite store for refresh + maintainer review before any synthesized rule takes effect.
|
|
3
|
+
// Approved rules merge with {@link DEFAULT_DENY_RULES}; unapproved proposals never block tool calls. Feeds the
|
|
4
|
+
// consumption surface #2343 will wire into evaluateDenyHooks — this issue owns derivation + audit, not live hook
|
|
5
|
+
// interception.
|
|
6
|
+
import { chmodSync, mkdirSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { DatabaseSync } from "node:sqlite";
|
|
11
|
+
import { DEFAULT_DENY_RULES, evaluateDenyHooks } from "./deny-hooks.js";
|
|
12
|
+
import { DEFAULT_FORGE_CONFIG } from "./forge-config.js";
|
|
13
|
+
|
|
14
|
+
const defaultDbFileName = "deny-hook-synthesis.sqlite3";
|
|
15
|
+
const PROPOSAL_STATUSES = Object.freeze(["proposed", "approved", "rejected"]);
|
|
16
|
+
const proposalStatusSet = new Set(PROPOSAL_STATUSES);
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_SYNTHESIS_CONFIG = Object.freeze({
|
|
19
|
+
minPathOccurrences: 2,
|
|
20
|
+
maxProposals: 20,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function normalizeRepoFullName(repoFullName) {
|
|
24
|
+
if (typeof repoFullName !== "string") throw new Error("invalid_repo_full_name");
|
|
25
|
+
const [owner, repo, extra] = repoFullName.trim().split("/");
|
|
26
|
+
if (!owner || !repo || extra !== undefined) throw new Error("invalid_repo_full_name");
|
|
27
|
+
return `${owner}/${repo}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Optional forge host, scoping rows so two hosts serving the same owner/repo name never collide (#5563).
|
|
31
|
+
* Omitted/nullish → the github.com default, so every pre-existing single-forge caller is unaffected. */
|
|
32
|
+
function normalizeApiBaseUrl(apiBaseUrl) {
|
|
33
|
+
if (apiBaseUrl === undefined || apiBaseUrl === null) return DEFAULT_FORGE_CONFIG.apiBaseUrl;
|
|
34
|
+
if (typeof apiBaseUrl !== "string" || !apiBaseUrl.trim()) throw new Error("invalid_api_base_url");
|
|
35
|
+
return apiBaseUrl.trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeOptionalStringArray(value) {
|
|
39
|
+
if (value === undefined || value === null) return [];
|
|
40
|
+
if (!Array.isArray(value)) return [];
|
|
41
|
+
return value.filter((entry) => typeof entry === "string" && entry.trim()).map((entry) => entry.trim());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Validate one blocker-history row from the review stack (gate block/close audit). */
|
|
45
|
+
export function normalizeBlockerHistoryRecord(record) {
|
|
46
|
+
if (!record || typeof record !== "object" || Array.isArray(record)) return null;
|
|
47
|
+
const blockerCodes = normalizeOptionalStringArray(record.blockerCodes);
|
|
48
|
+
if (blockerCodes.length === 0) return null;
|
|
49
|
+
const changedPaths = normalizeOptionalStringArray(record.changedPaths);
|
|
50
|
+
const guardrailMatches = normalizeOptionalStringArray(record.guardrailMatches);
|
|
51
|
+
const repoFullName = typeof record.repoFullName === "string" && record.repoFullName.trim()
|
|
52
|
+
? normalizeRepoFullName(record.repoFullName)
|
|
53
|
+
: null;
|
|
54
|
+
return {
|
|
55
|
+
repoFullName,
|
|
56
|
+
blockerCodes,
|
|
57
|
+
changedPaths,
|
|
58
|
+
guardrailMatches,
|
|
59
|
+
pullNumber: Number.isInteger(record.pullNumber) && record.pullNumber > 0 ? record.pullNumber : null,
|
|
60
|
+
recordedAt: typeof record.recordedAt === "string" && record.recordedAt.trim() ? record.recordedAt.trim() : null,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function normalizeBlockerHistory(records) {
|
|
65
|
+
if (!Array.isArray(records)) return [];
|
|
66
|
+
const normalized = [];
|
|
67
|
+
for (const record of records) {
|
|
68
|
+
const entry = normalizeBlockerHistoryRecord(record);
|
|
69
|
+
if (entry) normalized.push(entry);
|
|
70
|
+
}
|
|
71
|
+
return normalized;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Canonicalize a changed path the same way guardrail matching does (case/separator insensitive). */
|
|
75
|
+
export function canonicalizeChangedPath(path) {
|
|
76
|
+
if (typeof path !== "string") return null;
|
|
77
|
+
const trimmed = path.trim().replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/^\/+/, "");
|
|
78
|
+
if (!trimmed || trimmed.includes("..")) return null;
|
|
79
|
+
return trimmed.toLowerCase();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Convert a repo-relative changed path into a deny-hook glob matching DEFAULT_DENY_RULES shape. */
|
|
83
|
+
export function changedPathToDenyGlob(path) {
|
|
84
|
+
const canonical = canonicalizeChangedPath(path);
|
|
85
|
+
if (!canonical) return null;
|
|
86
|
+
return `**/${canonical}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function ruleSignature(rule) {
|
|
90
|
+
return JSON.stringify({
|
|
91
|
+
matcher: rule.matcher,
|
|
92
|
+
pathPattern: rule.pathPattern ?? null,
|
|
93
|
+
inputIncludesAll: rule.inputIncludesAll ?? null,
|
|
94
|
+
reason: rule.reason,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** True when a synthesized glob is already enforced by a built-in default deny rule. */
|
|
99
|
+
export function isCoveredByDefaultDenyRules(pathPattern) {
|
|
100
|
+
if (typeof pathPattern !== "string" || !pathPattern.trim()) return false;
|
|
101
|
+
const samplePath = pathPattern.replace(/^\*\*\//, "");
|
|
102
|
+
if (!samplePath) return false;
|
|
103
|
+
return !evaluateDenyHooks({ name: "Write", input: { file_path: samplePath } }, DEFAULT_DENY_RULES).allowed;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function collectPathsFromRecord(record) {
|
|
107
|
+
const paths = new Set();
|
|
108
|
+
for (const path of [...record.changedPaths, ...record.guardrailMatches]) {
|
|
109
|
+
const canonical = canonicalizeChangedPath(path);
|
|
110
|
+
if (canonical) paths.add(canonical);
|
|
111
|
+
}
|
|
112
|
+
return paths;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Aggregate path and blocker-code frequencies from normalized history. Pure. */
|
|
116
|
+
export function aggregateBlockerHistory(records) {
|
|
117
|
+
const normalized = normalizeBlockerHistory(records);
|
|
118
|
+
const pathCounts = new Map();
|
|
119
|
+
const pathBlockers = new Map();
|
|
120
|
+
const blockerCounts = new Map();
|
|
121
|
+
|
|
122
|
+
for (const record of normalized) {
|
|
123
|
+
for (const code of record.blockerCodes) {
|
|
124
|
+
blockerCounts.set(code, (blockerCounts.get(code) ?? 0) + 1);
|
|
125
|
+
}
|
|
126
|
+
for (const path of collectPathsFromRecord(record)) {
|
|
127
|
+
pathCounts.set(path, (pathCounts.get(path) ?? 0) + 1);
|
|
128
|
+
const blockers = pathBlockers.get(path) ?? new Set();
|
|
129
|
+
for (const code of record.blockerCodes) blockers.add(code);
|
|
130
|
+
pathBlockers.set(path, blockers);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
pathCounts,
|
|
136
|
+
pathBlockers,
|
|
137
|
+
blockerCounts,
|
|
138
|
+
recordCount: normalized.length,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function stableProposalId(kind, key) {
|
|
143
|
+
const digest = createHash("sha256").update(`${kind}:${key}`).digest("hex").slice(0, 16);
|
|
144
|
+
return `${kind}:${digest}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function buildPathProposal(path, occurrenceCount, blockerCodes) {
|
|
148
|
+
const pathPattern = changedPathToDenyGlob(path);
|
|
149
|
+
if (!pathPattern || isCoveredByDefaultDenyRules(pathPattern)) return null;
|
|
150
|
+
const sortedBlockers = [...blockerCodes].sort();
|
|
151
|
+
const reason = `Synthesized deny rule: ${occurrenceCount} gate block(s) touched ${path} (${sortedBlockers.join(", ") || "path history"}). Review before enabling.`;
|
|
152
|
+
const rule = { matcher: "*", pathPattern, reason };
|
|
153
|
+
return {
|
|
154
|
+
id: stableProposalId("path", pathPattern),
|
|
155
|
+
status: "proposed",
|
|
156
|
+
rule,
|
|
157
|
+
audit: {
|
|
158
|
+
kind: "path_history",
|
|
159
|
+
path,
|
|
160
|
+
pathPattern,
|
|
161
|
+
occurrenceCount,
|
|
162
|
+
blockerCodes: sortedBlockers,
|
|
163
|
+
synthesizedAt: new Date(0).toISOString(),
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Derive candidate deny-hook rules from blocker/path history. Returns proposal objects only — nothing is active
|
|
170
|
+
* until a maintainer approves them (see resolveEffectiveDenyRules).
|
|
171
|
+
*/
|
|
172
|
+
export function synthesizeDenyRuleProposals(records, config = {}) {
|
|
173
|
+
const minPathOccurrences = Number.isInteger(config.minPathOccurrences)
|
|
174
|
+
? Math.max(1, config.minPathOccurrences)
|
|
175
|
+
: DEFAULT_SYNTHESIS_CONFIG.minPathOccurrences;
|
|
176
|
+
const maxProposals = Number.isInteger(config.maxProposals)
|
|
177
|
+
? Math.max(1, config.maxProposals)
|
|
178
|
+
: DEFAULT_SYNTHESIS_CONFIG.maxProposals;
|
|
179
|
+
|
|
180
|
+
const { pathCounts, pathBlockers, recordCount } = aggregateBlockerHistory(records);
|
|
181
|
+
if (recordCount === 0) return [];
|
|
182
|
+
|
|
183
|
+
const rankedPaths = [...pathCounts.entries()]
|
|
184
|
+
.filter(([, count]) => count >= minPathOccurrences)
|
|
185
|
+
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
|
|
186
|
+
|
|
187
|
+
const proposals = [];
|
|
188
|
+
const seenSignatures = new Set(DEFAULT_DENY_RULES.map(ruleSignature));
|
|
189
|
+
for (const [path, count] of rankedPaths) {
|
|
190
|
+
const proposal = buildPathProposal(path, count, pathBlockers.get(path) ?? new Set());
|
|
191
|
+
if (!proposal) continue;
|
|
192
|
+
const signature = ruleSignature(proposal.rule);
|
|
193
|
+
if (seenSignatures.has(signature)) continue;
|
|
194
|
+
seenSignatures.add(signature);
|
|
195
|
+
proposals.push({
|
|
196
|
+
...proposal,
|
|
197
|
+
audit: { ...proposal.audit, synthesizedAt: new Date().toISOString() },
|
|
198
|
+
});
|
|
199
|
+
if (proposals.length >= maxProposals) break;
|
|
200
|
+
}
|
|
201
|
+
return proposals;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Merge built-in defaults with maintainer-approved synthesized rules (deduped, defaults first). */
|
|
205
|
+
export function resolveEffectiveDenyRules(options = {}) {
|
|
206
|
+
const includeDefaults = options.includeDefaults !== false;
|
|
207
|
+
const approvedProposals = Array.isArray(options.approvedProposals) ? options.approvedProposals : [];
|
|
208
|
+
const merged = includeDefaults ? [...DEFAULT_DENY_RULES] : [];
|
|
209
|
+
const seen = new Set(merged.map(ruleSignature));
|
|
210
|
+
for (const proposal of approvedProposals) {
|
|
211
|
+
if (proposal?.status !== "approved") continue;
|
|
212
|
+
const rule = proposal.rule;
|
|
213
|
+
if (!rule || typeof rule !== "object") continue;
|
|
214
|
+
const signature = ruleSignature(rule);
|
|
215
|
+
if (seen.has(signature)) continue;
|
|
216
|
+
seen.add(signature);
|
|
217
|
+
merged.push(rule);
|
|
218
|
+
}
|
|
219
|
+
return merged;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Apply maintainer approval/rejection to in-memory proposals. Pure. */
|
|
223
|
+
export function setProposalStatuses(proposals, updates) {
|
|
224
|
+
if (!Array.isArray(proposals)) throw new Error("invalid_proposals");
|
|
225
|
+
const updateMap = updates instanceof Map
|
|
226
|
+
? updates
|
|
227
|
+
: new Map(Object.entries(updates ?? {}).filter(([id]) => typeof id === "string"));
|
|
228
|
+
return proposals.map((proposal) => {
|
|
229
|
+
const nextStatus = updateMap.get(proposal.id);
|
|
230
|
+
if (!nextStatus || !proposalStatusSet.has(nextStatus)) return proposal;
|
|
231
|
+
return { ...proposal, status: nextStatus };
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function resolveDenyHookSynthesisDbPath(env = process.env) {
|
|
236
|
+
const explicitPath = typeof env.GITTENSORY_MINER_DENY_HOOK_SYNTHESIS_DB === "string"
|
|
237
|
+
? env.GITTENSORY_MINER_DENY_HOOK_SYNTHESIS_DB.trim()
|
|
238
|
+
: "";
|
|
239
|
+
if (explicitPath) return explicitPath;
|
|
240
|
+
|
|
241
|
+
const explicitConfigDir = typeof env.GITTENSORY_MINER_CONFIG_DIR === "string"
|
|
242
|
+
? env.GITTENSORY_MINER_CONFIG_DIR.trim()
|
|
243
|
+
: "";
|
|
244
|
+
if (explicitConfigDir) return join(explicitConfigDir, defaultDbFileName);
|
|
245
|
+
|
|
246
|
+
const configHome = typeof env.XDG_CONFIG_HOME === "string" && env.XDG_CONFIG_HOME.trim()
|
|
247
|
+
? env.XDG_CONFIG_HOME.trim()
|
|
248
|
+
: join(homedir(), ".config");
|
|
249
|
+
return join(configHome, "gittensory-miner", defaultDbFileName);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function normalizeDbPath(dbPath) {
|
|
253
|
+
const path = (dbPath ?? resolveDenyHookSynthesisDbPath()).trim();
|
|
254
|
+
if (!path) throw new Error("invalid_deny_hook_synthesis_db_path");
|
|
255
|
+
return path;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function rowToProposal(row) {
|
|
259
|
+
return {
|
|
260
|
+
id: row.id,
|
|
261
|
+
status: row.status,
|
|
262
|
+
rule: JSON.parse(row.rule_json),
|
|
263
|
+
audit: JSON.parse(row.audit_json),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Rebuild deny_rule_proposals' (repo_full_name, id) PRIMARY KEY into a (api_base_url, repo_full_name, id)
|
|
268
|
+
// composite (#5563) -- two forge hosts serving a same-named owner/repo must not share one proposal row. SQLite
|
|
269
|
+
// cannot ALTER a PRIMARY KEY in place, so this rebuilds the table: create the new shape, copy every existing row
|
|
270
|
+
// with the pre-#4784 implicit single-forge default backfilled, drop the old table, rename the new one in.
|
|
271
|
+
// Guarded by a column-presence check (this module has no schema-version framework of its own, unlike the
|
|
272
|
+
// package's other local stores) so this only runs once per file.
|
|
273
|
+
function ensureDenyRuleProposalsForgeScope(db) {
|
|
274
|
+
const hasApiBaseUrlColumn = db
|
|
275
|
+
.prepare("PRAGMA table_info(deny_rule_proposals)")
|
|
276
|
+
.all()
|
|
277
|
+
.some((column) => column.name === "api_base_url");
|
|
278
|
+
if (hasApiBaseUrlColumn) return;
|
|
279
|
+
db.exec(`
|
|
280
|
+
CREATE TABLE deny_rule_proposals_v2 (
|
|
281
|
+
api_base_url TEXT NOT NULL,
|
|
282
|
+
repo_full_name TEXT NOT NULL,
|
|
283
|
+
id TEXT NOT NULL,
|
|
284
|
+
status TEXT NOT NULL CHECK (status IN ('proposed', 'approved', 'rejected')),
|
|
285
|
+
rule_json TEXT NOT NULL,
|
|
286
|
+
audit_json TEXT NOT NULL,
|
|
287
|
+
updated_at TEXT NOT NULL,
|
|
288
|
+
PRIMARY KEY (api_base_url, repo_full_name, id)
|
|
289
|
+
)
|
|
290
|
+
`);
|
|
291
|
+
// OR IGNORE: a row this store's own read path already treats as unusable garbage (an unrecognized `status`,
|
|
292
|
+
// e.g. from a hand-edited or otherwise corrupted file) would violate the CHECK constraint above and abort the
|
|
293
|
+
// whole migration. Skipping it here is consistent with that same fail-closed posture, rather than turning one
|
|
294
|
+
// bad row into a permanently unmigratable file.
|
|
295
|
+
db.prepare(
|
|
296
|
+
`INSERT OR IGNORE INTO deny_rule_proposals_v2 (api_base_url, repo_full_name, id, status, rule_json, audit_json, updated_at)
|
|
297
|
+
SELECT ?, repo_full_name, id, status, rule_json, audit_json, updated_at FROM deny_rule_proposals`,
|
|
298
|
+
).run(DEFAULT_FORGE_CONFIG.apiBaseUrl);
|
|
299
|
+
db.exec("DROP TABLE deny_rule_proposals");
|
|
300
|
+
db.exec("ALTER TABLE deny_rule_proposals_v2 RENAME TO deny_rule_proposals");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Local SQLite store for synthesized deny-rule proposals. Refresh re-derives proposals from history while
|
|
305
|
+
* preserving maintainer decisions on ids that still exist.
|
|
306
|
+
*/
|
|
307
|
+
export function initDenyHookSynthesisStore(dbPath = resolveDenyHookSynthesisDbPath()) {
|
|
308
|
+
const resolvedPath = normalizeDbPath(dbPath);
|
|
309
|
+
mkdirSync(dirname(resolvedPath), { recursive: true, mode: 0o700 });
|
|
310
|
+
const db = new DatabaseSync(resolvedPath);
|
|
311
|
+
chmodSync(resolvedPath, 0o600);
|
|
312
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
313
|
+
db.exec(`
|
|
314
|
+
CREATE TABLE IF NOT EXISTS deny_rule_proposals (
|
|
315
|
+
repo_full_name TEXT NOT NULL,
|
|
316
|
+
id TEXT NOT NULL,
|
|
317
|
+
status TEXT NOT NULL CHECK (status IN ('proposed', 'approved', 'rejected')),
|
|
318
|
+
rule_json TEXT NOT NULL,
|
|
319
|
+
audit_json TEXT NOT NULL,
|
|
320
|
+
updated_at TEXT NOT NULL,
|
|
321
|
+
PRIMARY KEY (repo_full_name, id)
|
|
322
|
+
)
|
|
323
|
+
`);
|
|
324
|
+
ensureDenyRuleProposalsForgeScope(db);
|
|
325
|
+
|
|
326
|
+
const upsertStatement = db.prepare(`
|
|
327
|
+
INSERT INTO deny_rule_proposals (api_base_url, repo_full_name, id, status, rule_json, audit_json, updated_at)
|
|
328
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
329
|
+
ON CONFLICT(api_base_url, repo_full_name, id) DO UPDATE SET
|
|
330
|
+
status = excluded.status,
|
|
331
|
+
rule_json = excluded.rule_json,
|
|
332
|
+
audit_json = excluded.audit_json,
|
|
333
|
+
updated_at = excluded.updated_at
|
|
334
|
+
`);
|
|
335
|
+
const getStatusStatement = db.prepare(
|
|
336
|
+
"SELECT status FROM deny_rule_proposals WHERE api_base_url = ? AND repo_full_name = ? AND id = ?",
|
|
337
|
+
);
|
|
338
|
+
const listStatement = db.prepare(
|
|
339
|
+
"SELECT repo_full_name, id, status, rule_json, audit_json, updated_at FROM deny_rule_proposals WHERE api_base_url = ? AND repo_full_name = ? ORDER BY id ASC",
|
|
340
|
+
);
|
|
341
|
+
const setStatusStatement = db.prepare(`
|
|
342
|
+
UPDATE deny_rule_proposals SET status = ?, updated_at = ? WHERE api_base_url = ? AND repo_full_name = ? AND id = ?
|
|
343
|
+
`);
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
dbPath: resolvedPath,
|
|
347
|
+
refreshProposals(repoFullName, history, config = {}, apiBaseUrl) {
|
|
348
|
+
const forge = normalizeApiBaseUrl(apiBaseUrl);
|
|
349
|
+
const repo = normalizeRepoFullName(repoFullName);
|
|
350
|
+
const synthesized = synthesizeDenyRuleProposals(history, config);
|
|
351
|
+
const updatedAt = new Date().toISOString();
|
|
352
|
+
db.exec("BEGIN IMMEDIATE");
|
|
353
|
+
try {
|
|
354
|
+
for (const proposal of synthesized) {
|
|
355
|
+
const existing = getStatusStatement.get(forge, repo, proposal.id);
|
|
356
|
+
const status = existing?.status && proposalStatusSet.has(existing.status) && existing.status !== "proposed"
|
|
357
|
+
? existing.status
|
|
358
|
+
: "proposed";
|
|
359
|
+
upsertStatement.run(
|
|
360
|
+
forge,
|
|
361
|
+
repo,
|
|
362
|
+
proposal.id,
|
|
363
|
+
status,
|
|
364
|
+
JSON.stringify(proposal.rule),
|
|
365
|
+
JSON.stringify(proposal.audit),
|
|
366
|
+
updatedAt,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
db.exec("COMMIT");
|
|
370
|
+
} catch (error) {
|
|
371
|
+
db.exec("ROLLBACK");
|
|
372
|
+
throw error;
|
|
373
|
+
}
|
|
374
|
+
return listStatement.all(forge, repo).map(rowToProposal);
|
|
375
|
+
},
|
|
376
|
+
listProposals(repoFullName, apiBaseUrl) {
|
|
377
|
+
const forge = normalizeApiBaseUrl(apiBaseUrl);
|
|
378
|
+
const repo = normalizeRepoFullName(repoFullName);
|
|
379
|
+
return listStatement.all(forge, repo).map(rowToProposal);
|
|
380
|
+
},
|
|
381
|
+
setProposalStatus(repoFullName, proposalId, status, apiBaseUrl) {
|
|
382
|
+
const forge = normalizeApiBaseUrl(apiBaseUrl);
|
|
383
|
+
const repo = normalizeRepoFullName(repoFullName);
|
|
384
|
+
if (typeof proposalId !== "string" || !proposalId.trim()) throw new Error("invalid_proposal_id");
|
|
385
|
+
if (!proposalStatusSet.has(status)) throw new Error("invalid_proposal_status");
|
|
386
|
+
setStatusStatement.run(status, new Date().toISOString(), forge, repo, proposalId.trim());
|
|
387
|
+
},
|
|
388
|
+
resolveEffectiveRules(repoFullName, options = {}) {
|
|
389
|
+
const proposals = this.listProposals(repoFullName, options.apiBaseUrl);
|
|
390
|
+
return resolveEffectiveDenyRules({
|
|
391
|
+
includeDefaults: options.includeDefaults,
|
|
392
|
+
approvedProposals: proposals,
|
|
393
|
+
});
|
|
394
|
+
},
|
|
395
|
+
close() {
|
|
396
|
+
db.close();
|
|
397
|
+
},
|
|
398
|
+
};
|
|
399
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type DenyRule = {
|
|
2
|
+
/** Tool-name glob (`*` = any within a segment, `**` across segments) or an exact tool name. */
|
|
3
|
+
matcher: string;
|
|
4
|
+
/** Optional glob tested against every path-shaped string in the tool-call input. */
|
|
5
|
+
pathPattern?: string;
|
|
6
|
+
/** Optional substrings that must ALL appear in one string-shaped input field (e.g. a shell command). */
|
|
7
|
+
inputIncludesAll?: string[];
|
|
8
|
+
/** Optional pattern that must match a whole whitespace-separated token (quotes stripped) of one
|
|
9
|
+
* string-shaped input field — for flag-shaped needles where a substring test would false-positive
|
|
10
|
+
* on an unrelated longer flag (e.g. `-f` vs. `--follow-tags`). */
|
|
11
|
+
inputTokenPattern?: RegExp;
|
|
12
|
+
/** Human-readable reason surfaced when this rule blocks a call. */
|
|
13
|
+
reason: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type DenyVerdict = {
|
|
17
|
+
allowed: boolean;
|
|
18
|
+
blockedBy?: DenyRule;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ProposedToolCall = {
|
|
22
|
+
name: string;
|
|
23
|
+
input: Record<string, unknown>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_DENY_RULES: DenyRule[];
|
|
27
|
+
|
|
28
|
+
export function evaluateDenyHooks(toolCall: ProposedToolCall, rules?: DenyRule[]): DenyVerdict;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// PreToolUse-style deny-hook primitives (#2295). A pure, deterministic rule evaluator modeled on Claude Code's
|
|
2
|
+
// PreToolUse deny-hook shape: given a proposed tool call and a set of deny rules, it decides allow/block WITHOUT
|
|
3
|
+
// executing, intercepting, or mutating anything. There is NO live tool-call interception in this phase — a later
|
|
4
|
+
// phase's real coding-agent driver plugs an event source into `evaluateDenyHooks`; this module is only the
|
|
5
|
+
// decision function. No IO, no globals, no Date/random: identical inputs always yield the identical verdict.
|
|
6
|
+
//
|
|
7
|
+
// A rule fires when its tool-name `matcher` matches AND every constraint it declares also matches:
|
|
8
|
+
// - `pathPattern` (a glob) must match some path-shaped string in the tool-call input, and/or
|
|
9
|
+
// - `inputIncludesAll` (substrings) must ALL appear in a single string-shaped input field (e.g. a command), and/or
|
|
10
|
+
// - `inputTokenPattern` (a RegExp) must match a whole whitespace-separated token (quotes stripped) of a single
|
|
11
|
+
// string-shaped input field — for flag-shaped needles like `-f`, where a substring test would also fire on
|
|
12
|
+
// `--follow-tags`.
|
|
13
|
+
// A rule with none of these constraints fires on the matcher alone. The built-in DEFAULT_DENY_RULES mirror the
|
|
14
|
+
// forbidden-path patterns enforced in `scripts/check-mcp-package.mjs` plus a conservative git force-push guard.
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Compile a glob to an anchored, case-insensitive RegExp. `**` matches across path segments (any char incl.
|
|
18
|
+
* `/`); a leading `**/` also matches zero directories; `*` matches within a single segment (no `/`); every
|
|
19
|
+
* other char is literal. Inputs are normalized before matching so `./`, nested, and Windows-style variants
|
|
20
|
+
* cannot bypass the built-in path rules.
|
|
21
|
+
*/
|
|
22
|
+
function globToRegExp(glob) {
|
|
23
|
+
let source = "";
|
|
24
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
25
|
+
const char = glob[i];
|
|
26
|
+
if (char === "*") {
|
|
27
|
+
if (glob[i + 1] === "*") {
|
|
28
|
+
i += 1;
|
|
29
|
+
if (glob[i + 1] === "/") {
|
|
30
|
+
i += 1;
|
|
31
|
+
source += "(?:.*/)?"; // '**/' — any (or zero) leading directories
|
|
32
|
+
} else {
|
|
33
|
+
source += ".*"; // '**' — any char, including '/'
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
source += "[^/]*"; // '*' — any char except '/'
|
|
37
|
+
}
|
|
38
|
+
} else {
|
|
39
|
+
source += char.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return new RegExp(`^${source}$`, "i");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizePathCandidate(value) {
|
|
46
|
+
return value
|
|
47
|
+
.replace(/\\/g, "/")
|
|
48
|
+
.replace(/^\.\/+/, "")
|
|
49
|
+
.replace(/\/\.\//g, "/")
|
|
50
|
+
.replace(/\/+$/, "");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Collect string values anywhere in a tool-call input so rules can test nested tool arguments without
|
|
54
|
+
* hard-coding field names. Non-object input yields no strings (rule can't match). */
|
|
55
|
+
function collectInputStrings(input, seen = new WeakSet()) {
|
|
56
|
+
const strings = [];
|
|
57
|
+
if (!input || typeof input !== "object") return strings;
|
|
58
|
+
if (seen.has(input)) return strings;
|
|
59
|
+
seen.add(input);
|
|
60
|
+
const values = Array.isArray(input) ? input : Object.values(input);
|
|
61
|
+
for (const value of values) {
|
|
62
|
+
if (typeof value === "string") strings.push(value);
|
|
63
|
+
else if (value && typeof value === "object") strings.push(...collectInputStrings(value, seen));
|
|
64
|
+
}
|
|
65
|
+
return strings;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Split a string-shaped input field into whitespace-separated tokens with surrounding quotes stripped —
|
|
69
|
+
* shared by path-candidate expansion and flag-token matching below. */
|
|
70
|
+
function splitTokens(value) {
|
|
71
|
+
return value
|
|
72
|
+
.split(/\s+/)
|
|
73
|
+
.map((token) => token.replace(/^["']+|["']+$/g, ""))
|
|
74
|
+
.filter(Boolean);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The candidate strings a path glob is tested against for one input value: the whole value AND each
|
|
79
|
+
* whitespace-separated token (surrounding quotes stripped). A protected path is frequently embedded as one
|
|
80
|
+
* argument of a command-shaped string (`git add .github/workflows/ci.yml`), so the evaluator tokenizes here
|
|
81
|
+
* rather than relying on a later caller to split the command first — a bare path-valued field still matches via
|
|
82
|
+
* the whole-value candidate.
|
|
83
|
+
*/
|
|
84
|
+
function pathCandidates(value) {
|
|
85
|
+
const candidates = new Set([value, normalizePathCandidate(value)]);
|
|
86
|
+
for (const trimmed of splitTokens(value)) {
|
|
87
|
+
candidates.add(trimmed);
|
|
88
|
+
candidates.add(normalizePathCandidate(trimmed));
|
|
89
|
+
}
|
|
90
|
+
return [...candidates].filter(Boolean);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function matcherMatches(matcher, toolName) {
|
|
94
|
+
if (typeof matcher !== "string") return false;
|
|
95
|
+
return globToRegExp(matcher).test(typeof toolName === "string" ? toolName : "");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function ruleMatches(rule, toolName, inputStrings) {
|
|
99
|
+
if (!rule || typeof rule !== "object") return false;
|
|
100
|
+
if (!matcherMatches(rule.matcher, toolName)) return false;
|
|
101
|
+
if (typeof rule.pathPattern === "string") {
|
|
102
|
+
const pattern = globToRegExp(rule.pathPattern);
|
|
103
|
+
if (!inputStrings.some((value) => pathCandidates(value).some((candidate) => pattern.test(candidate)))) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (Array.isArray(rule.inputIncludesAll)) {
|
|
108
|
+
const needles = rule.inputIncludesAll.filter((needle) => typeof needle === "string");
|
|
109
|
+
if (!inputStrings.some((value) => needles.every((needle) => value.includes(needle)))) return false;
|
|
110
|
+
}
|
|
111
|
+
if (rule.inputTokenPattern instanceof RegExp) {
|
|
112
|
+
if (!inputStrings.some((value) => splitTokens(value).some((token) => rule.inputTokenPattern.test(token)))) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The built-in house-rule deny set — a non-empty starting example a later phase can extend or replace. Mirrors the
|
|
121
|
+
* forbidden-path regex in `scripts/check-mcp-package.mjs` (CI workflows, env files, secret-bearing paths, private
|
|
122
|
+
* key material) and adds conservative git force-push guards (a command carrying `push` plus a force flag).
|
|
123
|
+
*/
|
|
124
|
+
export const DEFAULT_DENY_RULES = [
|
|
125
|
+
{ matcher: "*", pathPattern: "**/.github/workflows/**", reason: "Never modify CI workflows (.github/workflows/**)." },
|
|
126
|
+
{ matcher: "*", pathPattern: "**/.env*", reason: "Never read or write environment files (.env*)." },
|
|
127
|
+
{ matcher: "*", pathPattern: "**/.dev.vars", reason: "Never read or write local Worker secrets (.dev.vars)." },
|
|
128
|
+
{ matcher: "*", pathPattern: "**/.npmrc", reason: "Never read or write npm credential files (.npmrc)." },
|
|
129
|
+
{ matcher: "*", pathPattern: "**/*secret*/**", reason: "Never touch secret-bearing directories (**/*secret*/**)." },
|
|
130
|
+
{ matcher: "*", pathPattern: "**/*secret*", reason: "Never touch secret-bearing paths (**/*secret*)." },
|
|
131
|
+
// Ordered before **/*.pem below: a file like id_private_key.pem matches both patterns, and
|
|
132
|
+
// evaluateDenyHooks returns the first matching rule's reason — this one is more specific
|
|
133
|
+
// (#2942, keeps the "private key material" reason for *private*key*.pem files).
|
|
134
|
+
{ matcher: "*", pathPattern: "**/*private*key*", reason: "Never touch private key material (**/*private*key*)." },
|
|
135
|
+
{ matcher: "*", pathPattern: "**/*.pem", reason: "Never touch PEM key material (*.pem)." },
|
|
136
|
+
{ matcher: "*", inputIncludesAll: ["push", "--force"], reason: "Never force-push (git push --force)." },
|
|
137
|
+
// Token-matched rather than substring-matched: a substring test for "-f" would also fire on an
|
|
138
|
+
// unrelated long flag like --follow-tags. Matches a whole short-option token (bundled or not)
|
|
139
|
+
// whose letters include "f", e.g. -f, -uf, -fu, but not a "--"-prefixed long flag.
|
|
140
|
+
{ matcher: "*", inputIncludesAll: ["push"], inputTokenPattern: /^-[a-z]*f[a-z]*$/i, reason: "Never force-push (git push -f)." },
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Evaluate a proposed tool call against deny rules and return the first block, or allow. Pure and side-effect-free
|
|
145
|
+
* — it NEVER runs or intercepts the tool call; a later phase's real hook wiring acts on the verdict. An empty rule
|
|
146
|
+
* set (or a call matching no rule) always allows. Defaults to {@link DEFAULT_DENY_RULES} when no rules are given.
|
|
147
|
+
*/
|
|
148
|
+
export function evaluateDenyHooks(toolCall, rules = DEFAULT_DENY_RULES) {
|
|
149
|
+
const toolName = toolCall && typeof toolCall === "object" ? toolCall.name : undefined;
|
|
150
|
+
const inputStrings = collectInputStrings(toolCall && typeof toolCall === "object" ? toolCall.input : undefined);
|
|
151
|
+
for (const rule of Array.isArray(rules) ? rules : []) {
|
|
152
|
+
if (ruleMatches(rule, toolName, inputStrings)) return { allowed: false, blockedBy: rule };
|
|
153
|
+
}
|
|
154
|
+
return { allowed: true };
|
|
155
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Parsed claims a DEPLOYMENT.md makes about the miner's runtime surface. */
|
|
2
|
+
export type DeploymentDocsClaims = {
|
|
3
|
+
envVars: string[];
|
|
4
|
+
filePaths: string[];
|
|
5
|
+
subcommands: string[];
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/** Filesystem-independent view of the live source tree the parsed claims are checked against. */
|
|
9
|
+
export type DeploymentDocsReality = {
|
|
10
|
+
hasEnvRead: (name: string) => boolean;
|
|
11
|
+
pathExists: (relativePath: string) => boolean;
|
|
12
|
+
isRegisteredCommand: (name: string) => boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Result of cross-checking claims against reality: `ok` plus a message per stale claim. */
|
|
16
|
+
export type DeploymentDocsAuditResult = {
|
|
17
|
+
ok: boolean;
|
|
18
|
+
failures: string[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function scanEnvVarTokens(text: string): Set<string>;
|
|
22
|
+
|
|
23
|
+
export function extractEnvVarClaims(markdown: string): string[];
|
|
24
|
+
|
|
25
|
+
export function extractSubcommandClaims(markdown: string): string[];
|
|
26
|
+
|
|
27
|
+
export function isRepoRelativePath(target: string): boolean;
|
|
28
|
+
|
|
29
|
+
export function extractFilePathClaims(markdown: string): string[];
|
|
30
|
+
|
|
31
|
+
export function scanRegisteredCommands(binSource: string): Set<string>;
|
|
32
|
+
|
|
33
|
+
export function auditDeploymentDocs(
|
|
34
|
+
claims: DeploymentDocsClaims,
|
|
35
|
+
reality: DeploymentDocsReality,
|
|
36
|
+
): DeploymentDocsAuditResult;
|
|
37
|
+
|
|
38
|
+
export function assertDeploymentDocsInSync(
|
|
39
|
+
claims: DeploymentDocsClaims,
|
|
40
|
+
reality: DeploymentDocsReality,
|
|
41
|
+
): DeploymentDocsAuditResult;
|