@bigknoxy/hashpilot 4.6.3
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/LICENSE +21 -0
- package/README.md +777 -0
- package/docs/ADAPTER-CONTRACT.md +1260 -0
- package/docs/ARCHITECTURE.md +846 -0
- package/docs/CLI-QUICKREF.md +827 -0
- package/docs/COMPETITIVE-ANALYSIS.md +307 -0
- package/docs/INSTALL.md +403 -0
- package/docs/INTEGRATION-CLAUDE.md +126 -0
- package/docs/INTEGRATION-MCP.md +196 -0
- package/docs/INTEGRATION-OPENCODE.md +136 -0
- package/docs/INTEGRATION-PI.md +195 -0
- package/package.json +77 -0
- package/scripts/build-site.sh +39 -0
- package/scripts/doctor.sh +218 -0
- package/scripts/gen-cli-quickref.ts +232 -0
- package/scripts/install-cli.sh +60 -0
- package/scripts/install.sh +466 -0
- package/scripts/roadmap-lint.ts +200 -0
- package/scripts/uninstall.sh +202 -0
- package/src/cli-node.cjs +51 -0
- package/src/cli.ts +209 -0
- package/src/commands/ast.ts +255 -0
- package/src/commands/diff.ts +98 -0
- package/src/commands/edit.ts +93 -0
- package/src/commands/hash.ts +64 -0
- package/src/commands/intent.ts +68 -0
- package/src/commands/maintenance.ts +191 -0
- package/src/commands/mcp.ts +28 -0
- package/src/commands/provenance.ts +111 -0
- package/src/commands/read.ts +117 -0
- package/src/commands/route.ts +42 -0
- package/src/commands/shared.ts +65 -0
- package/src/commands/telemetry.ts +126 -0
- package/src/commands/verify.ts +61 -0
- package/src/core/ast-edit.ts +2357 -0
- package/src/core/batch-edit.ts +185 -0
- package/src/core/config.ts +189 -0
- package/src/core/diff-engine.ts +474 -0
- package/src/core/doctor.ts +303 -0
- package/src/core/encoding.ts +116 -0
- package/src/core/envelope.ts +163 -0
- package/src/core/exit-codes.ts +198 -0
- package/src/core/format.ts +339 -0
- package/src/core/grep.ts +180 -0
- package/src/core/hash-edit.ts +416 -0
- package/src/core/index.ts +155 -0
- package/src/core/intent.ts +584 -0
- package/src/core/locking.ts +292 -0
- package/src/core/module-system.ts +142 -0
- package/src/core/operations.ts +557 -0
- package/src/core/output.ts +122 -0
- package/src/core/path-normalize.ts +61 -0
- package/src/core/paths.ts +326 -0
- package/src/core/plan-executor.ts +437 -0
- package/src/core/platform.ts +132 -0
- package/src/core/provenance.ts +214 -0
- package/src/core/read.ts +111 -0
- package/src/core/redact.ts +98 -0
- package/src/core/resolve-content.ts +12 -0
- package/src/core/router.ts +463 -0
- package/src/core/snapshot.ts +346 -0
- package/src/core/telemetry.ts +838 -0
- package/src/core/utils.ts +7 -0
- package/src/core/verify-baseline.ts +186 -0
- package/src/core/verify-scope.ts +282 -0
- package/src/core/verify.ts +753 -0
- package/src/mcp/server.ts +325 -0
- package/templates/claude-section.md +12 -0
- package/templates/opencode-agent.md +106 -0
- package/templates/opencode-skill.md +241 -0
- package/templates/pi-extension.ts +288 -0
- package/templates/pi-skill.md +123 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { exportEvents, type TelemetryEvent } from "./telemetry";
|
|
2
|
+
import { computeHash } from "./read";
|
|
3
|
+
import { generateUnifiedDiff } from "./diff-engine";
|
|
4
|
+
import { loadConfig, type HashPilotConfig } from "./config";
|
|
5
|
+
import { isSensitiveFile, redactSecrets } from "./redact";
|
|
6
|
+
|
|
7
|
+
// ── Types ──────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export interface ProvenanceInput {
|
|
10
|
+
actor?: string;
|
|
11
|
+
taskId?: string;
|
|
12
|
+
changeSetId?: string;
|
|
13
|
+
reason?: string;
|
|
14
|
+
source?: string;
|
|
15
|
+
newSource?: string;
|
|
16
|
+
stepIndex?: number;
|
|
17
|
+
stepTotal?: number;
|
|
18
|
+
context?: string;
|
|
19
|
+
filePath?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ProvenanceEntry {
|
|
23
|
+
timestamp: string;
|
|
24
|
+
sessionId: string;
|
|
25
|
+
actor: string;
|
|
26
|
+
taskId?: string;
|
|
27
|
+
changeSetId?: string;
|
|
28
|
+
reason: string;
|
|
29
|
+
operation: string;
|
|
30
|
+
route: string;
|
|
31
|
+
success: boolean;
|
|
32
|
+
beforeHash?: string;
|
|
33
|
+
afterHash?: string;
|
|
34
|
+
diff?: string;
|
|
35
|
+
stepIndex?: number;
|
|
36
|
+
stepTotal?: number;
|
|
37
|
+
context?: string;
|
|
38
|
+
verification?: "pass" | "fail" | "skip";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ChangeSetResult {
|
|
42
|
+
changeSetId: string;
|
|
43
|
+
taskId?: string;
|
|
44
|
+
actor: string;
|
|
45
|
+
reason: string;
|
|
46
|
+
editCount: number;
|
|
47
|
+
entries: ProvenanceEntry[];
|
|
48
|
+
timeRange: { first: string; last: string };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Config cache ───────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
let _cachedConfig: HashPilotConfig | null = null;
|
|
54
|
+
|
|
55
|
+
function getConfig(): HashPilotConfig {
|
|
56
|
+
if (!_cachedConfig) _cachedConfig = loadConfig();
|
|
57
|
+
return _cachedConfig;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Drop the cached config so the next call re-reads it. Pass an override to pin
|
|
62
|
+
* a config instead of loading from disk (tests use this to exercise opt-in
|
|
63
|
+
* behavior like `provenance.captureDiffs` without touching the user's files).
|
|
64
|
+
*/
|
|
65
|
+
export function clearConfigCache(override?: HashPilotConfig): void {
|
|
66
|
+
_cachedConfig = override ?? null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Factory ────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
export function createChangeSet(): string {
|
|
72
|
+
return crypto.randomUUID();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Field builder ──────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
function truncate(val: string, maxLen: number): string {
|
|
78
|
+
return val.length > maxLen ? val.slice(0, maxLen) : val;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function buildProvenanceFields(input: ProvenanceInput): Partial<TelemetryEvent> {
|
|
82
|
+
const fields: Partial<TelemetryEvent> = {};
|
|
83
|
+
const config = getConfig();
|
|
84
|
+
|
|
85
|
+
const actor = input.actor ?? config.provenance?.defaultActor;
|
|
86
|
+
if (actor !== undefined) fields.actor = truncate(actor, 80);
|
|
87
|
+
if (input.taskId !== undefined) fields.taskId = truncate(input.taskId, 80);
|
|
88
|
+
if (input.changeSetId !== undefined) fields.changeSetId = input.changeSetId;
|
|
89
|
+
if (input.reason !== undefined) fields.reason = truncate(input.reason, 200);
|
|
90
|
+
if (input.stepIndex !== undefined) fields.stepIndex = input.stepIndex;
|
|
91
|
+
if (input.stepTotal !== undefined) fields.stepTotal = input.stepTotal;
|
|
92
|
+
|
|
93
|
+
if (input.source !== undefined) {
|
|
94
|
+
fields.beforeHash = computeHash(input.source);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (input.source !== undefined && input.newSource !== undefined) {
|
|
98
|
+
fields.afterHash = computeHash(input.newSource);
|
|
99
|
+
// Diff capture is opt-in and never applies to files that are secret by
|
|
100
|
+
// definition. Hashes still record *that* the file changed.
|
|
101
|
+
const captureDiffs = config.provenance?.captureDiffs === true;
|
|
102
|
+
const sensitive = input.filePath !== undefined && isSensitiveFile(input.filePath);
|
|
103
|
+
if (captureDiffs && !sensitive && input.source !== input.newSource) {
|
|
104
|
+
fields.diff = redactSecrets(generateUnifiedDiff(
|
|
105
|
+
input.source, input.newSource,
|
|
106
|
+
input.filePath ? input.filePath.replace(/^\//, "") : "unknown", 3
|
|
107
|
+
));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (input.context !== undefined) {
|
|
112
|
+
const maxLen = config.provenance?.maxContextLength ?? 500;
|
|
113
|
+
fields.context = input.context.length > maxLen
|
|
114
|
+
? input.context.slice(0, maxLen) + "..."
|
|
115
|
+
: input.context;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return fields;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Query functions ────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
function diffCoversLine(diff: string, targetLine: number): boolean {
|
|
124
|
+
const hunkRe = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/gm;
|
|
125
|
+
let match: RegExpExecArray | null;
|
|
126
|
+
while ((match = hunkRe.exec(diff)) !== null) {
|
|
127
|
+
const newStart = parseInt(match[3], 10);
|
|
128
|
+
const newCount = match[4] ? parseInt(match[4], 10) : 1;
|
|
129
|
+
if (targetLine >= newStart && targetLine < newStart + newCount) {
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function toProvenanceEntry(e: TelemetryEvent): ProvenanceEntry {
|
|
137
|
+
return {
|
|
138
|
+
timestamp: e.timestamp,
|
|
139
|
+
sessionId: e.sessionId,
|
|
140
|
+
actor: e.actor ?? "unknown",
|
|
141
|
+
taskId: e.taskId,
|
|
142
|
+
changeSetId: e.changeSetId,
|
|
143
|
+
reason: e.reason ?? e.operation,
|
|
144
|
+
operation: e.operation,
|
|
145
|
+
route: e.route,
|
|
146
|
+
success: e.success,
|
|
147
|
+
beforeHash: e.beforeHash,
|
|
148
|
+
afterHash: e.afterHash,
|
|
149
|
+
diff: e.diff,
|
|
150
|
+
stepIndex: e.stepIndex,
|
|
151
|
+
stepTotal: e.stepTotal,
|
|
152
|
+
context: e.context,
|
|
153
|
+
// Maps from telemetry's `verification_result` field
|
|
154
|
+
verification: e.verification_result,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function provenanceQuery(file: string, line?: number, fuzzy?: boolean): ProvenanceEntry[] {
|
|
159
|
+
const all = exportEvents();
|
|
160
|
+
const fileEvents = all.filter((e) => e.file === file);
|
|
161
|
+
|
|
162
|
+
const filtered = line !== undefined
|
|
163
|
+
? fileEvents.filter((e) => {
|
|
164
|
+
if (!e.diff) return fuzzy; // no diff → only include if fuzzy
|
|
165
|
+
return diffCoversLine(e.diff, line);
|
|
166
|
+
})
|
|
167
|
+
: fileEvents;
|
|
168
|
+
|
|
169
|
+
return filtered
|
|
170
|
+
.map(toProvenanceEntry)
|
|
171
|
+
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function changeSetQuery(changeSetId: string): ChangeSetResult | null {
|
|
175
|
+
const all = exportEvents();
|
|
176
|
+
const entries = all
|
|
177
|
+
.filter((e) => e.changeSetId === changeSetId)
|
|
178
|
+
.map(toProvenanceEntry)
|
|
179
|
+
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
|
180
|
+
|
|
181
|
+
if (entries.length === 0) return null;
|
|
182
|
+
|
|
183
|
+
const first = entries[0];
|
|
184
|
+
const last = entries[entries.length - 1];
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
changeSetId,
|
|
188
|
+
taskId: first.taskId,
|
|
189
|
+
actor: first.actor,
|
|
190
|
+
reason: first.reason,
|
|
191
|
+
editCount: entries.length,
|
|
192
|
+
entries,
|
|
193
|
+
timeRange: { first: first.timestamp, last: last.timestamp },
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── Human-readable formatting ──────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
export function formatProvenanceHuman(entries: ProvenanceEntry[]): string {
|
|
200
|
+
if (entries.length === 0) return "No edits found for this file.";
|
|
201
|
+
|
|
202
|
+
const lines: string[] = [];
|
|
203
|
+
for (const e of entries) {
|
|
204
|
+
const ts = e.timestamp.slice(0, 19).replace("T", " ");
|
|
205
|
+
const status = e.success ? "OK" : "FAIL";
|
|
206
|
+
const step = e.stepTotal ? ` [${(e.stepIndex ?? 0) + 1}/${e.stepTotal}]` : "";
|
|
207
|
+
const task = e.taskId ? ` task=${e.taskId}` : "";
|
|
208
|
+
const reason = e.reason !== e.operation ? ` "${e.reason}"` : "";
|
|
209
|
+
lines.push(
|
|
210
|
+
`${ts} ${e.actor}${task} ${e.operation} ${e.route} ${status}${step}${reason}`
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return lines.join("\n");
|
|
214
|
+
}
|
package/src/core/read.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
import { readDecoded } from "./encoding";
|
|
3
|
+
|
|
4
|
+
export interface ReadResult {
|
|
5
|
+
path: string;
|
|
6
|
+
content: string;
|
|
7
|
+
hash: string;
|
|
8
|
+
lines: number;
|
|
9
|
+
error?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Hash width, shared by every anchor. See `computeLineHash`. */
|
|
13
|
+
const HASH_WIDTH = 12;
|
|
14
|
+
|
|
15
|
+
export function computeHash(content: string): string {
|
|
16
|
+
return createHash("sha256").update(content).digest("hex").slice(0, HASH_WIDTH);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A single line's anchor hash. Identical to `computeHash` — it exists to name
|
|
21
|
+
* the intent, not to compute something different.
|
|
22
|
+
*
|
|
23
|
+
* It used to truncate to 8 characters while `computeHash` used 12, so the
|
|
24
|
+
* `lineHash` from `read-hash` never matched what `replace-hash` computed: the
|
|
25
|
+
* read → write round-trip always failed with `STALE_ANCHOR`, which is
|
|
26
|
+
* documented as retryable, so an agent retried it forever.
|
|
27
|
+
*/
|
|
28
|
+
export function computeLineHash(line: string): string {
|
|
29
|
+
return computeHash(line);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function readMany(files: string[]): Promise<ReadResult[]> {
|
|
33
|
+
const results = await Promise.all(
|
|
34
|
+
files.map(async (p) => {
|
|
35
|
+
try {
|
|
36
|
+
const { text: content } = await readDecoded(p);
|
|
37
|
+
return {
|
|
38
|
+
path: p,
|
|
39
|
+
content,
|
|
40
|
+
hash: computeHash(content),
|
|
41
|
+
lines: content.split("\n").length - (content.endsWith("\n") ? 1 : 0),
|
|
42
|
+
};
|
|
43
|
+
} catch (e: any) {
|
|
44
|
+
return { path: p, content: "", hash: "", lines: 0, error: e.message };
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
);
|
|
48
|
+
return results;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ReadHashResult {
|
|
52
|
+
path: string;
|
|
53
|
+
line: number;
|
|
54
|
+
content: string;
|
|
55
|
+
lineHash: string;
|
|
56
|
+
contextHash: string;
|
|
57
|
+
contextBefore: string[];
|
|
58
|
+
contextAfter: string[];
|
|
59
|
+
error?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function readHash(
|
|
63
|
+
filePath: string,
|
|
64
|
+
line: number,
|
|
65
|
+
contextLines: number = 3
|
|
66
|
+
): Promise<ReadHashResult> {
|
|
67
|
+
try {
|
|
68
|
+
const { text: content } = await readDecoded(filePath);
|
|
69
|
+
const lines = content.split("\n");
|
|
70
|
+
const targetLine = lines[line - 1];
|
|
71
|
+
// A blank line is an empty string, which is falsy — checking truthiness here
|
|
72
|
+
// reported a legitimate blank line as out of range (#40 falsy-parameter audit).
|
|
73
|
+
if (targetLine === undefined) {
|
|
74
|
+
return {
|
|
75
|
+
path: filePath,
|
|
76
|
+
line,
|
|
77
|
+
content: "",
|
|
78
|
+
lineHash: "",
|
|
79
|
+
contextHash: "",
|
|
80
|
+
contextBefore: [],
|
|
81
|
+
contextAfter: [],
|
|
82
|
+
error: `Line ${line} out of range (file has ${lines.length} lines)`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const start = Math.max(0, line - 1 - contextLines);
|
|
86
|
+
const end = Math.min(lines.length, line - 1 + contextLines + 1);
|
|
87
|
+
const before = lines.slice(start, line - 1);
|
|
88
|
+
const after = lines.slice(line, end);
|
|
89
|
+
const contextText = [...before, targetLine, ...after].join("\n");
|
|
90
|
+
return {
|
|
91
|
+
path: filePath,
|
|
92
|
+
line,
|
|
93
|
+
content: targetLine,
|
|
94
|
+
lineHash: computeLineHash(targetLine),
|
|
95
|
+
contextHash: computeHash(contextText),
|
|
96
|
+
contextBefore: before,
|
|
97
|
+
contextAfter: after,
|
|
98
|
+
};
|
|
99
|
+
} catch (e: any) {
|
|
100
|
+
return {
|
|
101
|
+
path: filePath,
|
|
102
|
+
line,
|
|
103
|
+
content: "",
|
|
104
|
+
lineHash: "",
|
|
105
|
+
contextHash: "",
|
|
106
|
+
contextBefore: [],
|
|
107
|
+
contextAfter: [],
|
|
108
|
+
error: e.message,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Secret redaction for anything that reaches the telemetry log.
|
|
5
|
+
*
|
|
6
|
+
* Telemetry is written to `~/.agentic-tools/logs/` in plaintext and may be
|
|
7
|
+
* exported or shared. Provenance diffs put real source lines in there, so a
|
|
8
|
+
* `.env` edit or a pasted key would be persisted verbatim. Redaction runs on
|
|
9
|
+
* every string field before the event is serialized.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const REDACTED = "[REDACTED]";
|
|
13
|
+
|
|
14
|
+
interface Rule {
|
|
15
|
+
name: string;
|
|
16
|
+
pattern: RegExp;
|
|
17
|
+
/** Replacement; `$1` etc. refer to capture groups kept as context. */
|
|
18
|
+
replacement: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const RULES: Rule[] = [
|
|
22
|
+
{ name: "aws-access-key-id", pattern: /\b(?:AKIA|ASIA|AGPA|AIDA|AROA|ANPA|ANVA)[0-9A-Z]{16}\b/g, replacement: REDACTED },
|
|
23
|
+
{ name: "aws-secret-access-key", pattern: /\b(aws_secret_access_key\s*[:=]\s*)\S+/gi, replacement: `$1${REDACTED}` },
|
|
24
|
+
{ name: "openai-key", pattern: /\bsk-[A-Za-z0-9_-]{16,}\b/g, replacement: REDACTED },
|
|
25
|
+
{ name: "anthropic-key", pattern: /\bsk-ant-[A-Za-z0-9_-]{16,}\b/g, replacement: REDACTED },
|
|
26
|
+
{ name: "github-token", pattern: /\b(?:ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]{16,}\b/g, replacement: REDACTED },
|
|
27
|
+
{ name: "slack-token", pattern: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g, replacement: REDACTED },
|
|
28
|
+
{ name: "google-api-key", pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g, replacement: REDACTED },
|
|
29
|
+
{ name: "jwt", pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, replacement: REDACTED },
|
|
30
|
+
{ name: "private-key-block", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, replacement: `-----BEGIN PRIVATE KEY-----${REDACTED}-----END PRIVATE KEY-----` },
|
|
31
|
+
{ name: "authorization-header", pattern: /\b(authorization\s*[:=]\s*["']?)(?:bearer|basic|token)\s+\S+/gi, replacement: `$1${REDACTED}` },
|
|
32
|
+
{ name: "connection-string-password", pattern: /(\b[a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:)[^\s@/]+(@)/gi, replacement: `$1${REDACTED}$2` },
|
|
33
|
+
// Assignments whose *name* implies a secret. Deliberately last: the value is
|
|
34
|
+
// replaced wholesale rather than pattern-matched, so it catches formats the
|
|
35
|
+
// rules above do not know about.
|
|
36
|
+
{
|
|
37
|
+
name: "secretish-assignment",
|
|
38
|
+
pattern: /\b([A-Za-z0-9_.-]*(?:secret|token|password|passwd|api[_-]?key|access[_-]?key|credential)[A-Za-z0-9_.-]*\s*[:=]\s*)(["']?)([^\s"',;)}]{6,})\2/gi,
|
|
39
|
+
replacement: `$1$2${REDACTED}$2`,
|
|
40
|
+
},
|
|
41
|
+
// Bare cloud-credential "*Key" field names (Azure AccountKey, Cosmos DB
|
|
42
|
+
// PrimaryKey/MasterKey, Redis AuthKey, non-PEM PrivateKey). The prefix word
|
|
43
|
+
// must sit immediately before "key" *and* "key" must be the end of the
|
|
44
|
+
// identifier (right before `:`/`=`), so this doesn't fire on identifiers
|
|
45
|
+
// that merely contain "key" followed by more name, e.g. `primaryKeyColumn`.
|
|
46
|
+
{
|
|
47
|
+
name: "cloud-credential-key",
|
|
48
|
+
pattern: /\b([A-Za-z0-9_.-]*(?:account|primary|master|auth|private)[_-]?key["']?\s*[:=]\s*)(["']?)([^\s"',;)}]{20,})\2/gi,
|
|
49
|
+
replacement: `$1$2${REDACTED}$2`,
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/** Redact every known secret shape in a string. Returns the input unchanged when nothing matches. */
|
|
54
|
+
export function redactSecrets(input: string): string {
|
|
55
|
+
let out = input;
|
|
56
|
+
for (const rule of RULES) out = out.replace(rule.pattern, rule.replacement);
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Files whose *contents* are secret by definition. Their diffs and source are
|
|
62
|
+
* never recorded, regardless of what redaction would catch.
|
|
63
|
+
*/
|
|
64
|
+
const SENSITIVE_FILE_PATTERNS: RegExp[] = [
|
|
65
|
+
/^\.env(\..*)?$/i,
|
|
66
|
+
/\.pem$/i,
|
|
67
|
+
/\.key$/i,
|
|
68
|
+
/\.p12$/i,
|
|
69
|
+
/\.pfx$/i,
|
|
70
|
+
/^id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/i,
|
|
71
|
+
/^credentials$/i,
|
|
72
|
+
/^\.npmrc$/i,
|
|
73
|
+
/^\.netrc$/i,
|
|
74
|
+
/^.*\.keystore$/i,
|
|
75
|
+
/^secrets?\.(ya?ml|json|toml)$/i,
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
/** True when the file's contents must never appear in telemetry. */
|
|
79
|
+
export function isSensitiveFile(filePath: string): boolean {
|
|
80
|
+
const name = basename(filePath);
|
|
81
|
+
return SENSITIVE_FILE_PATTERNS.some((re) => re.test(name));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Recursively redact the string fields of a telemetry event. Object keys and
|
|
86
|
+
* non-string values pass through untouched — only values can carry secrets.
|
|
87
|
+
*/
|
|
88
|
+
export function redactEvent<T>(event: T): T {
|
|
89
|
+
const walk = (value: unknown): unknown => {
|
|
90
|
+
if (typeof value === "string") return redactSecrets(value);
|
|
91
|
+
if (Array.isArray(value)) return value.map(walk);
|
|
92
|
+
if (value && typeof value === "object") {
|
|
93
|
+
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, walk(v)]));
|
|
94
|
+
}
|
|
95
|
+
return value;
|
|
96
|
+
};
|
|
97
|
+
return walk(event) as T;
|
|
98
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a CLI content argument: `@path` reads the file, anything else is
|
|
3
|
+
* taken literally.
|
|
4
|
+
*
|
|
5
|
+
* An explicit empty string is a deletion, not an omitted argument (#40), so
|
|
6
|
+
* only `undefined` short-circuits.
|
|
7
|
+
*/
|
|
8
|
+
export async function resolveContent(val?: string): Promise<string | undefined> {
|
|
9
|
+
if (val === undefined) return undefined;
|
|
10
|
+
if (val.startsWith("@")) return await Bun.file(val.slice(1)).text();
|
|
11
|
+
return val;
|
|
12
|
+
}
|