human-to-code 0.0.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Readme.md +368 -38
- package/SECURITY.md +175 -11
- package/dist/adapters/general.d.ts +30 -0
- package/dist/adapters/general.d.ts.map +1 -0
- package/dist/adapters/general.js +77 -0
- package/dist/adapters/general.js.map +1 -0
- package/dist/adapters/node.d.ts +8 -0
- package/dist/adapters/node.d.ts.map +1 -0
- package/dist/adapters/node.js +1071 -0
- package/dist/adapters/node.js.map +1 -0
- package/dist/adapters/python.d.ts +7 -0
- package/dist/adapters/python.d.ts.map +1 -0
- package/dist/adapters/python.js +540 -0
- package/dist/adapters/python.js.map +1 -0
- package/dist/adapters/rust.d.ts +7 -0
- package/dist/adapters/rust.d.ts.map +1 -0
- package/dist/adapters/rust.js +517 -0
- package/dist/adapters/rust.js.map +1 -0
- package/dist/analyzer-types.d.ts +161 -0
- package/dist/analyzer-types.d.ts.map +1 -0
- package/dist/analyzer-types.js +8 -0
- package/dist/analyzer-types.js.map +1 -0
- package/dist/analyzer-utils.d.ts +40 -0
- package/dist/analyzer-utils.d.ts.map +1 -0
- package/dist/analyzer-utils.js +591 -0
- package/dist/analyzer-utils.js.map +1 -0
- package/dist/analyzer.d.ts +21 -0
- package/dist/analyzer.d.ts.map +1 -0
- package/dist/analyzer.js +111 -0
- package/dist/analyzer.js.map +1 -0
- package/dist/certification.d.ts +102 -0
- package/dist/certification.d.ts.map +1 -0
- package/dist/certification.js +193 -0
- package/dist/certification.js.map +1 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +566 -0
- package/dist/cli.js.map +1 -0
- package/dist/compiler-skills.d.ts +25 -0
- package/dist/compiler-skills.d.ts.map +1 -0
- package/dist/compiler-skills.js +158 -0
- package/dist/compiler-skills.js.map +1 -0
- package/dist/compiler-tools.d.ts +29 -0
- package/dist/compiler-tools.d.ts.map +1 -0
- package/dist/compiler-tools.js +337 -0
- package/dist/compiler-tools.js.map +1 -0
- package/dist/config.d.ts +122 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +704 -0
- package/dist/config.js.map +1 -0
- package/dist/context.d.ts +153 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +772 -0
- package/dist/context.js.map +1 -0
- package/dist/contracts.d.ts +196 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +721 -0
- package/dist/contracts.js.map +1 -0
- package/dist/discovery.d.ts +34 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +312 -0
- package/dist/discovery.js.map +1 -0
- package/dist/documentation.d.ts +44 -0
- package/dist/documentation.d.ts.map +1 -0
- package/dist/documentation.js +418 -0
- package/dist/documentation.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/patch.d.ts +71 -0
- package/dist/patch.d.ts.map +1 -0
- package/dist/patch.js +394 -0
- package/dist/patch.js.map +1 -0
- package/dist/pinned-http.d.ts +15 -0
- package/dist/pinned-http.d.ts.map +1 -0
- package/dist/pinned-http.js +170 -0
- package/dist/pinned-http.js.map +1 -0
- package/dist/planner.d.ts +30 -0
- package/dist/planner.d.ts.map +1 -0
- package/dist/planner.js +228 -0
- package/dist/planner.js.map +1 -0
- package/dist/provider.d.ts +168 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +512 -0
- package/dist/provider.js.map +1 -0
- package/dist/providers.d.ts +41 -0
- package/dist/providers.d.ts.map +1 -0
- package/dist/providers.js +954 -0
- package/dist/providers.js.map +1 -0
- package/dist/run-store.d.ts +24 -0
- package/dist/run-store.d.ts.map +1 -0
- package/dist/run-store.js +285 -0
- package/dist/run-store.js.map +1 -0
- package/dist/schemas.d.ts +18 -0
- package/dist/schemas.d.ts.map +1 -0
- package/dist/schemas.js +228 -0
- package/dist/schemas.js.map +1 -0
- package/dist/secret-scan.d.ts +30 -0
- package/dist/secret-scan.d.ts.map +1 -0
- package/dist/secret-scan.js +122 -0
- package/dist/secret-scan.js.map +1 -0
- package/dist/snapshot.d.ts +33 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +136 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/support-matrix.d.ts +23 -0
- package/dist/support-matrix.d.ts.map +1 -0
- package/dist/support-matrix.js +199 -0
- package/dist/support-matrix.js.map +1 -0
- package/dist/types.d.ts +63 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/validation.d.ts +25 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +388 -0
- package/dist/validation.js.map +1 -0
- package/dist/workflow.d.ts +65 -0
- package/dist/workflow.d.ts.map +1 -0
- package/dist/workflow.js +1636 -0
- package/dist/workflow.js.map +1 -0
- package/package.json +36 -9
- package/.claude/settings.local.json +0 -12
- package/.github/workflows/ci.yml +0 -18
- package/CONTRIBUTING.md +0 -40
- package/src/cli.ts +0 -218
- package/src/config.ts +0 -186
- package/src/discovery.ts +0 -186
- package/src/index.ts +0 -22
- package/src/types.ts +0 -74
- package/test/config.test.ts +0 -97
- package/test/discovery.test.ts +0 -91
- package/tsconfig.json +0 -17
package/dist/context.js
ADDED
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
/** Deterministic, secret-aware context selection and provenance. */
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import { lstat, open, realpath } from "node:fs/promises";
|
|
4
|
+
import { isIP } from "node:net";
|
|
5
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { ARTIFACT_SCHEMA_VERSION, ArtifactValidationError, hashCanonical, isSafeRelativePath, sha256Text, } from "./contracts.js";
|
|
7
|
+
export const DEFAULT_CONTEXT_BUDGET = Object.freeze({
|
|
8
|
+
maxItems: 40,
|
|
9
|
+
maxBytes: 256 * 1024,
|
|
10
|
+
maxEstimatedTokens: 64 * 1024,
|
|
11
|
+
maxBytesPerItem: 64 * 1024,
|
|
12
|
+
});
|
|
13
|
+
const MAX_CONTEXT_SOURCE_BYTES = 16 * 1024 * 1024;
|
|
14
|
+
export const DEFAULT_OFFICIAL_DOCUMENTATION_HOSTS = Object.freeze([
|
|
15
|
+
"react.dev",
|
|
16
|
+
"nextjs.org",
|
|
17
|
+
"vite.dev",
|
|
18
|
+
"docs.nestjs.com",
|
|
19
|
+
"fastapi.tiangolo.com",
|
|
20
|
+
"docs.pydantic.dev",
|
|
21
|
+
"docs.python.org",
|
|
22
|
+
"python.org",
|
|
23
|
+
"doc.rust-lang.org",
|
|
24
|
+
"docs.rs",
|
|
25
|
+
"nodejs.org",
|
|
26
|
+
"typescriptlang.org",
|
|
27
|
+
"developer.mozilla.org",
|
|
28
|
+
]);
|
|
29
|
+
export class ContextSecurityError extends Error {
|
|
30
|
+
code;
|
|
31
|
+
location;
|
|
32
|
+
constructor(code, message, location) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "ContextSecurityError";
|
|
35
|
+
this.code = code;
|
|
36
|
+
this.location = location;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const PROTECTED_EXACT_NAMES = new Set([
|
|
40
|
+
"secrets.human",
|
|
41
|
+
"human-to-code.config.json",
|
|
42
|
+
".npmrc",
|
|
43
|
+
".yarnrc",
|
|
44
|
+
".yarnrc.yml",
|
|
45
|
+
".envrc",
|
|
46
|
+
".pypirc",
|
|
47
|
+
".netrc",
|
|
48
|
+
".git-credentials",
|
|
49
|
+
"credentials",
|
|
50
|
+
"credentials.json",
|
|
51
|
+
"service-account.json",
|
|
52
|
+
"kubeconfig",
|
|
53
|
+
]);
|
|
54
|
+
const PROTECTED_SEGMENTS = new Set([".git", ".hg", ".svn", ".human-to-code"]);
|
|
55
|
+
const PROTECTED_NAME_PATTERNS = [
|
|
56
|
+
/^\.env(?:\..*)?$/iu,
|
|
57
|
+
/^\.envrc$/iu,
|
|
58
|
+
/^(?:id_rsa|id_dsa|id_ecdsa|id_ed25519)(?:\..*)?$/iu,
|
|
59
|
+
/\.(?:pem|key|p12|pfx|jks|keystore)$/iu,
|
|
60
|
+
/^(?:credentials?|secrets?)(?:\..*)?$/iu,
|
|
61
|
+
];
|
|
62
|
+
/** Filename-only protection, intentionally independent of ignore rules. */
|
|
63
|
+
export function isProtectedContextPath(path) {
|
|
64
|
+
const parts = path.replaceAll("\\", "/").split("/").filter(Boolean);
|
|
65
|
+
return parts.some((part) => {
|
|
66
|
+
const lower = part.toLowerCase();
|
|
67
|
+
return PROTECTED_SEGMENTS.has(lower)
|
|
68
|
+
|| PROTECTED_EXACT_NAMES.has(lower)
|
|
69
|
+
|| PROTECTED_NAME_PATTERNS.some((pattern) => pattern.test(part));
|
|
70
|
+
}) || parts.some((part, index) => part === ".cargo" && /^credentials(?:\.toml)?$/iu.test(parts[index + 1] ?? ""))
|
|
71
|
+
|| parts.some((part, index) => part === ".docker" && (parts[index + 1] ?? "").toLowerCase() === "config.json");
|
|
72
|
+
}
|
|
73
|
+
const SECRET_PATTERNS = [
|
|
74
|
+
{ kind: "private_key", expression: /-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z0-9 ]+ )?PRIVATE KEY-----/gu },
|
|
75
|
+
{ kind: "aws_access_key", expression: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/gu },
|
|
76
|
+
{ kind: "github_token", expression: /\b(?:gh[pousr]_[A-Za-z0-9]{32,255}|github_pat_[A-Za-z0-9_]{20,255})\b/gu },
|
|
77
|
+
{ kind: "google_api_key", expression: /\bAIza[0-9A-Za-z_-]{35}\b/gu },
|
|
78
|
+
{ kind: "api_token", expression: /\b(?:sk-(?:ant-)?[A-Za-z0-9_-]{16,}|xox[baprs]-[A-Za-z0-9-]{16,})\b/gu },
|
|
79
|
+
{
|
|
80
|
+
kind: "credential_assignment",
|
|
81
|
+
expression: /\b(?:api[_-]?key|access[_-]?token|auth[_-]?token|token|client[_-]?secret|secret|credential|password|passwd|private[_-]?key|database[_-]?url|connection[_-]?string)\b\s*(?::|=)\s*["']?[A-Za-z0-9/+_.:@-]{8,}["']?/giu,
|
|
82
|
+
},
|
|
83
|
+
{ kind: "credential_url", expression: /\b(?:https?|postgres(?:ql)?|mysql|mongodb(?:\+srv)?):\/\/[^\s/:@]+:[^\s/@]+@[^\s]+/giu },
|
|
84
|
+
];
|
|
85
|
+
/** Returns metadata only; the matched credential is never exposed. */
|
|
86
|
+
export function scanSecrets(content) {
|
|
87
|
+
const offsets = [];
|
|
88
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
89
|
+
const expression = new RegExp(pattern.expression.source, pattern.expression.flags);
|
|
90
|
+
for (const match of content.matchAll(expression)) {
|
|
91
|
+
if (pattern.kind === "credential_assignment") {
|
|
92
|
+
const assignment = match[0];
|
|
93
|
+
const separator = assignment.search(/[:=]/u);
|
|
94
|
+
const right = separator < 0 ? "" : assignment.slice(separator + 1).trim();
|
|
95
|
+
const reference = /(?:process\.env|import\.meta\.env|os\.environ|environ\.(?:get|getitem)|\bgetenv\b|config\.get|settings\.|(?:get|load|read)secret|secret(?:::|provider|reference|ref)|secretmanager|\bvault\b|redacted|placeholder|example|changeme|your[_-]|<[^>]+>|\$\{)/iu.test(assignment)
|
|
96
|
+
|| (!/^["']/u.test(right) && /^[A-Za-z_$][\w$]*(?:(?:\.|::)[\w$]+)*$/u.test(right));
|
|
97
|
+
if (reference)
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const start = match.index;
|
|
101
|
+
const end = start + match[0].length;
|
|
102
|
+
offsets.push({ kind: pattern.kind, start, end });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
offsets.sort((a, b) => a.start - b.start || b.end - a.end || a.kind.localeCompare(b.kind));
|
|
106
|
+
let cursor = 0;
|
|
107
|
+
let line = 1;
|
|
108
|
+
return offsets.map((finding) => {
|
|
109
|
+
for (let index = cursor; index < finding.start; index += 1) {
|
|
110
|
+
if (content.charCodeAt(index) === 10)
|
|
111
|
+
line += 1;
|
|
112
|
+
}
|
|
113
|
+
cursor = finding.start;
|
|
114
|
+
return { ...finding, line };
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
function redactSecretFindings(content, findings) {
|
|
118
|
+
const nonOverlapping = [];
|
|
119
|
+
let cursor = -1;
|
|
120
|
+
for (const finding of findings) {
|
|
121
|
+
if (finding.start < cursor)
|
|
122
|
+
continue;
|
|
123
|
+
nonOverlapping.push(finding);
|
|
124
|
+
cursor = finding.end;
|
|
125
|
+
}
|
|
126
|
+
let output = "";
|
|
127
|
+
cursor = 0;
|
|
128
|
+
for (const finding of nonOverlapping) {
|
|
129
|
+
const removed = content.slice(finding.start, finding.end);
|
|
130
|
+
const newlineCount = removed.match(/\n/gu)?.length ?? 0;
|
|
131
|
+
output += content.slice(cursor, finding.start);
|
|
132
|
+
output += `[REDACTED:${finding.kind}]${"\n".repeat(newlineCount)}`;
|
|
133
|
+
cursor = finding.end;
|
|
134
|
+
}
|
|
135
|
+
output += content.slice(cursor);
|
|
136
|
+
return {
|
|
137
|
+
content: output,
|
|
138
|
+
redactions: nonOverlapping.map(({ kind, line }) => ({ kind, line })),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function validateBudget(partial) {
|
|
142
|
+
if (partial !== undefined && (typeof partial !== "object" || partial === null || Array.isArray(partial))) {
|
|
143
|
+
throw new ContextSecurityError("INVALID_CANDIDATE", "Context budget must be an object.");
|
|
144
|
+
}
|
|
145
|
+
const allowed = new Set(["maxItems", "maxBytes", "maxEstimatedTokens", "maxBytesPerItem"]);
|
|
146
|
+
for (const key of Object.keys(partial ?? {})) {
|
|
147
|
+
if (!allowed.has(key))
|
|
148
|
+
throw new ContextSecurityError("INVALID_CANDIDATE", `Unknown context budget key '${key}'.`);
|
|
149
|
+
}
|
|
150
|
+
const budget = { ...DEFAULT_CONTEXT_BUDGET, ...partial };
|
|
151
|
+
for (const [key, value] of Object.entries(budget)) {
|
|
152
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
153
|
+
throw new ContextSecurityError("INVALID_CANDIDATE", `Context budget '${key}' must be a positive integer.`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (budget.maxBytesPerItem > budget.maxBytes) {
|
|
157
|
+
throw new ContextSecurityError("INVALID_CANDIDATE", "maxBytesPerItem cannot exceed maxBytes.");
|
|
158
|
+
}
|
|
159
|
+
return budget;
|
|
160
|
+
}
|
|
161
|
+
function validateRange(range, totalLines, location) {
|
|
162
|
+
const resolved = range ?? { startLine: 1, endLine: totalLines };
|
|
163
|
+
if (!Number.isSafeInteger(resolved.startLine) || !Number.isSafeInteger(resolved.endLine)
|
|
164
|
+
|| resolved.startLine < 1 || resolved.endLine < resolved.startLine || resolved.endLine > totalLines) {
|
|
165
|
+
throw new ContextSecurityError("INVALID_CANDIDATE", `Invalid line range for ${location}.`, location);
|
|
166
|
+
}
|
|
167
|
+
return resolved;
|
|
168
|
+
}
|
|
169
|
+
function rangeContent(content, range) {
|
|
170
|
+
return content.split("\n").slice(range.startLine - 1, range.endLine).join("\n");
|
|
171
|
+
}
|
|
172
|
+
function estimatedTokens(content) {
|
|
173
|
+
return Math.ceil(Buffer.byteLength(content, "utf8") / 4);
|
|
174
|
+
}
|
|
175
|
+
function sourceRank(origin) {
|
|
176
|
+
return origin === "project" ? 0
|
|
177
|
+
: origin === "dependency" ? 1
|
|
178
|
+
: origin === "private_documentation" ? 2
|
|
179
|
+
: origin === "diagnostic" ? 3
|
|
180
|
+
: 4;
|
|
181
|
+
}
|
|
182
|
+
function candidateLocation(candidate) {
|
|
183
|
+
return candidate.origin === "official_documentation" ? candidate.url : candidate.path;
|
|
184
|
+
}
|
|
185
|
+
function compareCandidates(a, b) {
|
|
186
|
+
return Number(Boolean(b.required)) - Number(Boolean(a.required))
|
|
187
|
+
|| sourceRank(a.origin) - sourceRank(b.origin)
|
|
188
|
+
|| (b.priority ?? 0) - (a.priority ?? 0)
|
|
189
|
+
|| candidateLocation(a).localeCompare(candidateLocation(b))
|
|
190
|
+
|| (a.range?.startLine ?? 1) - (b.range?.startLine ?? 1)
|
|
191
|
+
|| a.reason.localeCompare(b.reason);
|
|
192
|
+
}
|
|
193
|
+
function ensureReason(candidate) {
|
|
194
|
+
if (typeof candidate.reason !== "string" || candidate.reason.trim().length === 0 || candidate.reason.length > 4096) {
|
|
195
|
+
throw new ContextSecurityError("INVALID_CANDIDATE", `Context candidate ${candidateLocation(candidate)} needs a bounded reason.`);
|
|
196
|
+
}
|
|
197
|
+
if (candidate.priority !== undefined && (!Number.isSafeInteger(candidate.priority) || candidate.priority < -1000 || candidate.priority > 1000)) {
|
|
198
|
+
throw new ContextSecurityError("INVALID_CANDIDATE", `Context candidate ${candidateLocation(candidate)} has an invalid priority.`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function assertCandidateShape(value) {
|
|
202
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
203
|
+
throw new ContextSecurityError("INVALID_CANDIDATE", "Every context candidate must be an object.");
|
|
204
|
+
}
|
|
205
|
+
const candidate = value;
|
|
206
|
+
const origins = ["project", "dependency", "private_documentation", "diagnostic", "official_documentation"];
|
|
207
|
+
if (typeof candidate.origin !== "string" || !origins.includes(candidate.origin)) {
|
|
208
|
+
throw new ContextSecurityError("INVALID_CANDIDATE", "Context candidate has an unsupported origin.");
|
|
209
|
+
}
|
|
210
|
+
const official = candidate.origin === "official_documentation";
|
|
211
|
+
const requiredKeys = official
|
|
212
|
+
? ["origin", "url", "version", "content", "contentSha256", "reason", "cached"]
|
|
213
|
+
: ["origin", "path", "reason"];
|
|
214
|
+
const allowedKeys = new Set([...requiredKeys, "range", "priority", "required", ...(official ? [] : ["version"])]);
|
|
215
|
+
for (const key of Object.keys(candidate)) {
|
|
216
|
+
if (!allowedKeys.has(key))
|
|
217
|
+
throw new ContextSecurityError("INVALID_CANDIDATE", `Unknown context candidate key '${key}'.`);
|
|
218
|
+
}
|
|
219
|
+
for (const key of requiredKeys) {
|
|
220
|
+
if (!Object.hasOwn(candidate, key))
|
|
221
|
+
throw new ContextSecurityError("INVALID_CANDIDATE", `Context candidate is missing '${key}'.`);
|
|
222
|
+
}
|
|
223
|
+
if (candidate.required !== undefined && typeof candidate.required !== "boolean") {
|
|
224
|
+
throw new ContextSecurityError("INVALID_CANDIDATE", "Context candidate required flag must be boolean.");
|
|
225
|
+
}
|
|
226
|
+
if (candidate.version !== undefined && (typeof candidate.version !== "string" || candidate.version.trim().length === 0 || candidate.version.length > 256)) {
|
|
227
|
+
throw new ContextSecurityError("INVALID_CANDIDATE", "Context candidate version must be a non-empty bounded string.");
|
|
228
|
+
}
|
|
229
|
+
if (official) {
|
|
230
|
+
if (typeof candidate.url !== "string" || candidate.url.length === 0 || candidate.url.length > 4096
|
|
231
|
+
|| typeof candidate.content !== "string" || typeof candidate.contentSha256 !== "string"
|
|
232
|
+
|| !/^[a-f0-9]{64}$/u.test(candidate.contentSha256) || typeof candidate.cached !== "boolean") {
|
|
233
|
+
throw new ContextSecurityError("INVALID_CANDIDATE", "Official documentation candidate fields are invalid.");
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
else if (typeof candidate.path !== "string") {
|
|
237
|
+
throw new ContextSecurityError("INVALID_CANDIDATE", "Local context candidate path must be a string.");
|
|
238
|
+
}
|
|
239
|
+
if (candidate.range !== undefined) {
|
|
240
|
+
if (typeof candidate.range !== "object" || candidate.range === null || Array.isArray(candidate.range)) {
|
|
241
|
+
throw new ContextSecurityError("INVALID_CANDIDATE", "Context candidate range must be an object.");
|
|
242
|
+
}
|
|
243
|
+
const range = candidate.range;
|
|
244
|
+
if (Object.keys(range).some((key) => !["startLine", "endLine"].includes(key))
|
|
245
|
+
|| !Object.hasOwn(range, "startLine") || !Object.hasOwn(range, "endLine")
|
|
246
|
+
|| !Number.isSafeInteger(range.startLine) || !Number.isSafeInteger(range.endLine)) {
|
|
247
|
+
throw new ContextSecurityError("INVALID_CANDIDATE", "Context candidate range requires integer startLine and endLine only.");
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
ensureReason(value);
|
|
251
|
+
}
|
|
252
|
+
async function assertNoSymlinkComponents(root, path, allowConfinedDependencyLinks) {
|
|
253
|
+
let cursor = root;
|
|
254
|
+
for (const part of path.split("/")) {
|
|
255
|
+
cursor = resolve(cursor, part);
|
|
256
|
+
const info = await lstat(cursor).catch((error) => {
|
|
257
|
+
void error;
|
|
258
|
+
throw new ContextSecurityError("NOT_A_FILE", "Requested context path does not exist or is unreadable.", path);
|
|
259
|
+
});
|
|
260
|
+
if (info.isSymbolicLink()) {
|
|
261
|
+
if (!allowConfinedDependencyLinks) {
|
|
262
|
+
throw new ContextSecurityError("SYMLINK_BLOCKED", "Project context paths may not traverse symbolic links.", path);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async function readLocalCandidate(realRoot, candidate, policy) {
|
|
268
|
+
if (!isSafeRelativePath(candidate.path) || isAbsolute(candidate.path)) {
|
|
269
|
+
throw new ContextSecurityError("PATH_ESCAPE", "Context path must be root-relative and confined.", candidate.path);
|
|
270
|
+
}
|
|
271
|
+
const normalized = candidate.path.replaceAll("\\", "/");
|
|
272
|
+
if (isProtectedContextPath(normalized)) {
|
|
273
|
+
throw new ContextSecurityError("PROTECTED_PATH", "A protected credential-bearing path was requested.", normalized);
|
|
274
|
+
}
|
|
275
|
+
const dependencyLink = candidate.origin === "dependency" && normalized.split("/").includes("node_modules");
|
|
276
|
+
await assertNoSymlinkComponents(realRoot, normalized, dependencyLink);
|
|
277
|
+
const absolute = resolve(realRoot, ...normalized.split("/"));
|
|
278
|
+
const canonical = await realpath(absolute).catch((error) => {
|
|
279
|
+
void error;
|
|
280
|
+
throw new ContextSecurityError("NOT_A_FILE", "Requested context path does not exist or is unreadable.", normalized);
|
|
281
|
+
});
|
|
282
|
+
const rel = relative(realRoot, canonical);
|
|
283
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
284
|
+
throw new ContextSecurityError("PATH_ESCAPE", "Resolved context path escapes the project root.", normalized);
|
|
285
|
+
}
|
|
286
|
+
const canonicalRelative = rel.split(sep).join("/");
|
|
287
|
+
if (isProtectedContextPath(canonicalRelative)) {
|
|
288
|
+
throw new ContextSecurityError("PROTECTED_PATH", "Resolved context path targets protected content.", normalized);
|
|
289
|
+
}
|
|
290
|
+
const info = await lstat(canonical);
|
|
291
|
+
if (!info.isFile() || info.isSymbolicLink() || (!dependencyLink && info.nlink > 1)) {
|
|
292
|
+
throw new ContextSecurityError(info.isSymbolicLink() ? "SYMLINK_BLOCKED" : "NOT_A_FILE", "Context source must be a single-link regular file.", normalized);
|
|
293
|
+
}
|
|
294
|
+
if (info.size > MAX_CONTEXT_SOURCE_BYTES) {
|
|
295
|
+
throw new ContextSecurityError("BUDGET_EXCEEDED", "Context source exceeds the maximum safe scan size.", normalized);
|
|
296
|
+
}
|
|
297
|
+
const handle = await open(canonical, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW).catch((error) => {
|
|
298
|
+
void error;
|
|
299
|
+
throw new ContextSecurityError("NOT_A_FILE", "Context source could not be opened without following links.", normalized);
|
|
300
|
+
});
|
|
301
|
+
let bytes;
|
|
302
|
+
try {
|
|
303
|
+
const opened = await handle.stat();
|
|
304
|
+
if (!opened.isFile() || (!dependencyLink && opened.nlink !== 1) || opened.dev !== info.dev || opened.ino !== info.ino
|
|
305
|
+
|| opened.size !== info.size || opened.size > MAX_CONTEXT_SOURCE_BYTES) {
|
|
306
|
+
throw new ContextSecurityError("SYMLINK_BLOCKED", "Context source changed during secure opening.", normalized);
|
|
307
|
+
}
|
|
308
|
+
bytes = await handle.readFile();
|
|
309
|
+
}
|
|
310
|
+
finally {
|
|
311
|
+
await handle.close().catch(() => undefined);
|
|
312
|
+
}
|
|
313
|
+
if (bytes.includes(0))
|
|
314
|
+
throw new ContextSecurityError("BINARY_BLOCKED", "Binary files cannot be model context.", normalized);
|
|
315
|
+
let content = bytes.toString("utf8");
|
|
316
|
+
const findings = scanSecrets(content);
|
|
317
|
+
if (findings.length > 0 && policy === "block") {
|
|
318
|
+
throw new ContextSecurityError("SECRET_DETECTED", `Credential-like content was found in ${normalized}; no content was selected.`, normalized);
|
|
319
|
+
}
|
|
320
|
+
const redacted = findings.length > 0 ? redactSecretFindings(content, findings) : { content, redactions: [] };
|
|
321
|
+
content = redacted.content;
|
|
322
|
+
const totalLines = content.split("\n").length;
|
|
323
|
+
const range = validateRange(candidate.range, totalLines, normalized);
|
|
324
|
+
const selected = rangeContent(content, range);
|
|
325
|
+
const sha256 = sha256Text(selected);
|
|
326
|
+
return {
|
|
327
|
+
id: sha256Text(`${candidate.origin}\0${normalized}\0${range.startLine}\0${range.endLine}\0${sha256}`),
|
|
328
|
+
origin: candidate.origin,
|
|
329
|
+
path: normalized,
|
|
330
|
+
...(candidate.version === undefined ? {} : { version: candidate.version }),
|
|
331
|
+
startLine: range.startLine,
|
|
332
|
+
endLine: range.endLine,
|
|
333
|
+
sha256,
|
|
334
|
+
reason: candidate.reason,
|
|
335
|
+
content: selected,
|
|
336
|
+
redactions: redacted.redactions.filter((finding) => finding.line >= range.startLine && finding.line <= range.endLine),
|
|
337
|
+
untrusted: true,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
function hostAllowed(hostname, allowed) {
|
|
341
|
+
const host = hostname.toLowerCase().replace(/\.$/u, "");
|
|
342
|
+
return allowed.some((entry) => {
|
|
343
|
+
const normalized = entry.toLowerCase().replace(/\.$/u, "");
|
|
344
|
+
return host === normalized || host.endsWith(`.${normalized}`);
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
function validateDocumentationUrl(raw, allowedHosts) {
|
|
348
|
+
let url;
|
|
349
|
+
try {
|
|
350
|
+
url = new URL(raw);
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
throw new ContextSecurityError("DOCUMENTATION_BLOCKED", "Documentation URL is invalid.", raw);
|
|
354
|
+
}
|
|
355
|
+
if (url.protocol !== "https:" || url.username || url.password || url.port) {
|
|
356
|
+
throw new ContextSecurityError("DOCUMENTATION_BLOCKED", "Documentation URLs must use credential-free HTTPS on the default port.");
|
|
357
|
+
}
|
|
358
|
+
if ([...url.searchParams.keys()].some((key) => /(?:token|key|secret|password|credential|signature)/iu.test(key))) {
|
|
359
|
+
throw new ContextSecurityError("DOCUMENTATION_BLOCKED", "Documentation URLs may not contain credential-like query parameters.");
|
|
360
|
+
}
|
|
361
|
+
if (isIP(url.hostname) !== 0 || url.hostname === "localhost" || !hostAllowed(url.hostname, allowedHosts)) {
|
|
362
|
+
throw new ContextSecurityError("DOCUMENTATION_BLOCKED", "Documentation host is not on the official allowlist.");
|
|
363
|
+
}
|
|
364
|
+
url.hash = "";
|
|
365
|
+
return url;
|
|
366
|
+
}
|
|
367
|
+
function readDocumentationCandidate(candidate, offline, policy, allowedHosts) {
|
|
368
|
+
const url = validateDocumentationUrl(candidate.url, allowedHosts);
|
|
369
|
+
if (!candidate.version.trim())
|
|
370
|
+
throw new ContextSecurityError("INVALID_CANDIDATE", "Official documentation evidence requires an exact version.", candidate.url);
|
|
371
|
+
if (offline && !candidate.cached)
|
|
372
|
+
throw new ContextSecurityError("OFFLINE_MISS", "Offline mode accepts only previously cached documentation.", candidate.url);
|
|
373
|
+
if (sha256Text(candidate.content) !== candidate.contentSha256) {
|
|
374
|
+
throw new ContextSecurityError("DOCUMENTATION_HASH_MISMATCH", "Documentation content does not match its cache/retrieval hash.", candidate.url);
|
|
375
|
+
}
|
|
376
|
+
if (Buffer.byteLength(candidate.content, "utf8") > MAX_CONTEXT_SOURCE_BYTES) {
|
|
377
|
+
throw new ContextSecurityError("BUDGET_EXCEEDED", "Documentation evidence exceeds the maximum safe scan size.", candidate.url);
|
|
378
|
+
}
|
|
379
|
+
const findings = scanSecrets(candidate.content);
|
|
380
|
+
if (findings.length > 0 && policy === "block") {
|
|
381
|
+
throw new ContextSecurityError("SECRET_DETECTED", "Credential-like content was found in documentation evidence.", candidate.url);
|
|
382
|
+
}
|
|
383
|
+
const redacted = findings.length > 0 ? redactSecretFindings(candidate.content, findings) : { content: candidate.content, redactions: [] };
|
|
384
|
+
const totalLines = redacted.content.split("\n").length;
|
|
385
|
+
const range = validateRange(candidate.range, totalLines, candidate.url);
|
|
386
|
+
const selected = rangeContent(redacted.content, range);
|
|
387
|
+
const sha256 = sha256Text(selected);
|
|
388
|
+
return {
|
|
389
|
+
id: sha256Text(`official_documentation\0${url.href}\0${candidate.version}\0${range.startLine}\0${range.endLine}\0${sha256}`),
|
|
390
|
+
origin: "official_documentation",
|
|
391
|
+
url: url.href,
|
|
392
|
+
version: candidate.version,
|
|
393
|
+
cached: candidate.cached,
|
|
394
|
+
startLine: range.startLine,
|
|
395
|
+
endLine: range.endLine,
|
|
396
|
+
sha256,
|
|
397
|
+
reason: candidate.reason,
|
|
398
|
+
content: selected,
|
|
399
|
+
redactions: redacted.redactions.filter((finding) => finding.line >= range.startLine && finding.line <= range.endLine),
|
|
400
|
+
untrusted: true,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Select context deterministically. This function performs no network access
|
|
405
|
+
* and never executes/imports project code.
|
|
406
|
+
*/
|
|
407
|
+
export async function selectContext(options) {
|
|
408
|
+
if (!/^[a-f0-9]{64}$/u.test(options.projectFingerprint)) {
|
|
409
|
+
throw new ContextSecurityError("INVALID_CANDIDATE", "projectFingerprint must be a lowercase SHA-256 digest.");
|
|
410
|
+
}
|
|
411
|
+
const root = resolve(options.root);
|
|
412
|
+
const rootInfo = await lstat(root).catch((error) => {
|
|
413
|
+
throw new ContextSecurityError("INVALID_ROOT", `Cannot inspect context root: ${String(error)}`);
|
|
414
|
+
});
|
|
415
|
+
if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) {
|
|
416
|
+
throw new ContextSecurityError("INVALID_ROOT", "Context root must be a real directory.");
|
|
417
|
+
}
|
|
418
|
+
const realRoot = await realpath(root);
|
|
419
|
+
const budget = validateBudget(options.budget);
|
|
420
|
+
const offline = options.offline ?? false;
|
|
421
|
+
const policy = options.secretPolicy ?? "block";
|
|
422
|
+
if (typeof offline !== "boolean" || !["block", "redact"].includes(policy)) {
|
|
423
|
+
throw new ContextSecurityError("INVALID_CANDIDATE", "Context offline or secret policy option is invalid.");
|
|
424
|
+
}
|
|
425
|
+
const allowedHosts = options.officialDocumentationHosts ?? [...DEFAULT_OFFICIAL_DOCUMENTATION_HOSTS];
|
|
426
|
+
if (!Array.isArray(allowedHosts) || allowedHosts.length === 0 || allowedHosts.some((host) => typeof host !== "string" || !/^[a-z0-9.-]+$/iu.test(host))) {
|
|
427
|
+
throw new ContextSecurityError("INVALID_CANDIDATE", "Official documentation hosts must be a non-empty hostname allowlist.");
|
|
428
|
+
}
|
|
429
|
+
if (!Array.isArray(options.candidates)) {
|
|
430
|
+
throw new ContextSecurityError("INVALID_CANDIDATE", "Context candidates must be an array.");
|
|
431
|
+
}
|
|
432
|
+
let candidates;
|
|
433
|
+
try {
|
|
434
|
+
candidates = structuredClone(options.candidates);
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
throw new ContextSecurityError("INVALID_CANDIDATE", "Context candidates must contain cloneable JSON-like data only.");
|
|
438
|
+
}
|
|
439
|
+
candidates.forEach(assertCandidateShape);
|
|
440
|
+
const evidence = [];
|
|
441
|
+
const exclusions = [];
|
|
442
|
+
const seen = new Set();
|
|
443
|
+
let usedBytes = 0;
|
|
444
|
+
let usedTokens = 0;
|
|
445
|
+
for (const candidate of candidates.sort(compareCandidates)) {
|
|
446
|
+
const location = candidateLocation(candidate);
|
|
447
|
+
const identity = `${candidate.origin}\0${location}\0${candidate.range?.startLine ?? 1}\0${candidate.range?.endLine ?? "end"}`;
|
|
448
|
+
if (seen.has(identity)) {
|
|
449
|
+
exclusions.push({ location, code: "DUPLICATE", reason: "Duplicate candidate was deterministically omitted." });
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
seen.add(identity);
|
|
453
|
+
let item;
|
|
454
|
+
try {
|
|
455
|
+
item = candidate.origin === "official_documentation"
|
|
456
|
+
? readDocumentationCandidate(candidate, offline, policy, allowedHosts)
|
|
457
|
+
: await readLocalCandidate(realRoot, candidate, policy);
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
if (candidate.required || error instanceof ContextSecurityError && ["PATH_ESCAPE", "PROTECTED_PATH", "SYMLINK_BLOCKED", "SECRET_DETECTED", "DOCUMENTATION_BLOCKED"].includes(error.code)) {
|
|
461
|
+
throw error;
|
|
462
|
+
}
|
|
463
|
+
exclusions.push({
|
|
464
|
+
location,
|
|
465
|
+
code: error instanceof ContextSecurityError && error.code === "BUDGET_EXCEEDED" ? "BUDGET" : "OPTIONAL_UNAVAILABLE",
|
|
466
|
+
reason: error instanceof Error ? error.message : "Candidate unavailable.",
|
|
467
|
+
});
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
const bytes = Buffer.byteLength(item.content, "utf8");
|
|
471
|
+
const tokens = estimatedTokens(item.content);
|
|
472
|
+
const over = bytes > budget.maxBytesPerItem
|
|
473
|
+
|| evidence.length + 1 > budget.maxItems
|
|
474
|
+
|| usedBytes + bytes > budget.maxBytes
|
|
475
|
+
|| usedTokens + tokens > budget.maxEstimatedTokens;
|
|
476
|
+
if (over) {
|
|
477
|
+
if (candidate.required) {
|
|
478
|
+
throw new ContextSecurityError("BUDGET_EXCEEDED", `Required context '${location}' does not fit the configured budget.`, location);
|
|
479
|
+
}
|
|
480
|
+
exclusions.push({ location, code: "BUDGET", reason: "Candidate did not fit the configured context budget." });
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
if (item.content.length === 0) {
|
|
484
|
+
if (candidate.required)
|
|
485
|
+
throw new ContextSecurityError("INVALID_CANDIDATE", `Required context '${location}' is empty.`, location);
|
|
486
|
+
exclusions.push({ location, code: "EMPTY", reason: "Empty candidate was omitted." });
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
evidence.push(item);
|
|
490
|
+
usedBytes += bytes;
|
|
491
|
+
usedTokens += tokens;
|
|
492
|
+
}
|
|
493
|
+
return {
|
|
494
|
+
schemaVersion: ARTIFACT_SCHEMA_VERSION,
|
|
495
|
+
projectFingerprint: options.projectFingerprint,
|
|
496
|
+
offline,
|
|
497
|
+
evidence,
|
|
498
|
+
exclusions,
|
|
499
|
+
budget: {
|
|
500
|
+
...budget,
|
|
501
|
+
usedItems: evidence.length,
|
|
502
|
+
usedBytes,
|
|
503
|
+
usedEstimatedTokens: usedTokens,
|
|
504
|
+
},
|
|
505
|
+
redactionCount: evidence.reduce((sum, item) => sum + item.redactions.length, 0),
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
const CONTEXT_REQUEST_KINDS = ["symbol", "file", "dependency-doc", "diagnostic"];
|
|
509
|
+
const REQUEST_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
|
|
510
|
+
/** Exact-schema validator for compiler-agent context tool calls. */
|
|
511
|
+
export function validateContextRequestV1(value) {
|
|
512
|
+
const issues = [];
|
|
513
|
+
const issue = (path, code, message) => { issues.push({ path, code, message }); };
|
|
514
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
515
|
+
throw new ArtifactValidationError([{ path: "$contextRequest", code: "TYPE", message: "must be an object." }]);
|
|
516
|
+
}
|
|
517
|
+
const record = value;
|
|
518
|
+
const required = ["schemaVersion", "requestId", "kind", "workspace", "query", "reason", "maxItems", "path"];
|
|
519
|
+
const allowed = new Set(required);
|
|
520
|
+
Object.keys(record).forEach((key) => { if (!allowed.has(key))
|
|
521
|
+
issue(`$contextRequest.${key}`, "UNKNOWN_KEY", "is not allowed."); });
|
|
522
|
+
required.forEach((key) => { if (!Object.hasOwn(record, key))
|
|
523
|
+
issue(`$contextRequest.${key}`, "MISSING", "is required."); });
|
|
524
|
+
if (record.schemaVersion !== 1)
|
|
525
|
+
issue("$contextRequest.schemaVersion", "VALUE", "must equal 1.");
|
|
526
|
+
if (typeof record.requestId !== "string" || !REQUEST_ID.test(record.requestId))
|
|
527
|
+
issue("$contextRequest.requestId", "VALUE", "must be a bounded identifier.");
|
|
528
|
+
if (typeof record.kind !== "string" || !CONTEXT_REQUEST_KINDS.includes(record.kind))
|
|
529
|
+
issue("$contextRequest.kind", "VALUE", `must be one of: ${CONTEXT_REQUEST_KINDS.join(", ")}.`);
|
|
530
|
+
if (typeof record.workspace !== "string" || (record.workspace !== "." && !isSafeRelativePath(record.workspace)))
|
|
531
|
+
issue("$contextRequest.workspace", "VALUE", "must be a confined workspace id/path.");
|
|
532
|
+
for (const key of ["query", "reason"]) {
|
|
533
|
+
if (typeof record[key] !== "string" || record[key].trim().length === 0 || record[key].length > 4096)
|
|
534
|
+
issue(`$contextRequest.${key}`, "VALUE", "must be a non-empty string of at most 4096 characters.");
|
|
535
|
+
}
|
|
536
|
+
if (!Number.isSafeInteger(record.maxItems) || record.maxItems < 1 || record.maxItems > 20)
|
|
537
|
+
issue("$contextRequest.maxItems", "VALUE", "must be an integer from 1 to 20.");
|
|
538
|
+
if (record.path !== null && (typeof record.path !== "string" || record.path.length > 4096 || !isSafeRelativePath(record.path) || isProtectedContextPath(record.path)))
|
|
539
|
+
issue("$contextRequest.path", "UNAUTHORIZED", "must be null or a non-protected confined path.");
|
|
540
|
+
if (record.kind === "file" && typeof record.path !== "string")
|
|
541
|
+
issue("$contextRequest.path", "MISSING", "must be a path for file requests.");
|
|
542
|
+
if (record.kind !== "file" && record.path !== null)
|
|
543
|
+
issue("$contextRequest.path", "VALUE", "must be null for non-file requests.");
|
|
544
|
+
if (typeof record.query === "string" && /(?:secrets?\.human|\.env\b|private[_ -]?key|credential|password)/iu.test(record.query))
|
|
545
|
+
issue("$contextRequest.query", "UNAUTHORIZED", "may not request credentials or protected content.");
|
|
546
|
+
if (issues.length > 0)
|
|
547
|
+
throw new ArtifactValidationError(issues);
|
|
548
|
+
return value;
|
|
549
|
+
}
|
|
550
|
+
export class ContextRequestLimitError extends Error {
|
|
551
|
+
maximum;
|
|
552
|
+
constructor(maximum) {
|
|
553
|
+
super(`The compiler agent may make at most ${maximum} context requests.`);
|
|
554
|
+
this.name = "ContextRequestLimitError";
|
|
555
|
+
this.maximum = maximum;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
/** Stateful per-run gate: validate first, then count, with a hard maximum of 8. */
|
|
559
|
+
export class ContextRequestSession {
|
|
560
|
+
maximum;
|
|
561
|
+
#accepted = [];
|
|
562
|
+
constructor(maximum = 8) {
|
|
563
|
+
if (!Number.isSafeInteger(maximum) || maximum < 1 || maximum > 8) {
|
|
564
|
+
throw new RangeError("Context request maximum must be an integer from 1 to 8.");
|
|
565
|
+
}
|
|
566
|
+
this.maximum = maximum;
|
|
567
|
+
}
|
|
568
|
+
accept(value) {
|
|
569
|
+
const request = validateContextRequestV1(value);
|
|
570
|
+
if (this.#accepted.length >= this.maximum)
|
|
571
|
+
throw new ContextRequestLimitError(this.maximum);
|
|
572
|
+
if (this.#accepted.some((existing) => existing.requestId === request.requestId)) {
|
|
573
|
+
throw new ArtifactValidationError([{ path: "$contextRequest.requestId", code: "DUPLICATE", message: "was already used in this run." }]);
|
|
574
|
+
}
|
|
575
|
+
this.#accepted.push(structuredClone(request));
|
|
576
|
+
return request;
|
|
577
|
+
}
|
|
578
|
+
get count() {
|
|
579
|
+
return this.#accepted.length;
|
|
580
|
+
}
|
|
581
|
+
get remaining() {
|
|
582
|
+
return this.maximum - this.count;
|
|
583
|
+
}
|
|
584
|
+
get accepted() {
|
|
585
|
+
return structuredClone(this.#accepted);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
/** Exact validator for context manifests loaded from disk or a run store. */
|
|
589
|
+
export function validateContextManifestV1(value) {
|
|
590
|
+
const issues = [];
|
|
591
|
+
const issue = (path, code, message) => { issues.push({ path, code, message }); };
|
|
592
|
+
const asRecord = (input, path) => {
|
|
593
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
594
|
+
issue(path, "TYPE", "must be an object.");
|
|
595
|
+
return undefined;
|
|
596
|
+
}
|
|
597
|
+
return input;
|
|
598
|
+
};
|
|
599
|
+
const exact = (record, path, required, optional = []) => {
|
|
600
|
+
const allowed = new Set([...required, ...optional]);
|
|
601
|
+
Object.keys(record).forEach((key) => { if (!allowed.has(key))
|
|
602
|
+
issue(`${path}.${key}`, "UNKNOWN_KEY", "is not allowed."); });
|
|
603
|
+
required.forEach((key) => { if (!Object.hasOwn(record, key))
|
|
604
|
+
issue(`${path}.${key}`, "MISSING", "is required."); });
|
|
605
|
+
};
|
|
606
|
+
const record = asRecord(value, "$contextManifest");
|
|
607
|
+
if (!record)
|
|
608
|
+
throw new ArtifactValidationError(issues);
|
|
609
|
+
exact(record, "$contextManifest", ["schemaVersion", "projectFingerprint", "offline", "evidence", "exclusions", "budget", "redactionCount"]);
|
|
610
|
+
if (record.schemaVersion !== 1)
|
|
611
|
+
issue("$contextManifest.schemaVersion", "VALUE", "must equal 1.");
|
|
612
|
+
if (typeof record.projectFingerprint !== "string" || !/^[a-f0-9]{64}$/u.test(record.projectFingerprint))
|
|
613
|
+
issue("$contextManifest.projectFingerprint", "VALUE", "must be a lowercase SHA-256 digest.");
|
|
614
|
+
if (typeof record.offline !== "boolean")
|
|
615
|
+
issue("$contextManifest.offline", "TYPE", "must be boolean.");
|
|
616
|
+
let calculatedRedactions = 0;
|
|
617
|
+
let calculatedBytes = 0;
|
|
618
|
+
let calculatedTokens = 0;
|
|
619
|
+
const evidenceIds = new Set();
|
|
620
|
+
const evidence = Array.isArray(record.evidence) ? record.evidence : [];
|
|
621
|
+
if (!Array.isArray(record.evidence))
|
|
622
|
+
issue("$contextManifest.evidence", "TYPE", "must be an array.");
|
|
623
|
+
evidence.forEach((raw, index) => {
|
|
624
|
+
const path = `$contextManifest.evidence[${index}]`;
|
|
625
|
+
const item = asRecord(raw, path);
|
|
626
|
+
if (!item)
|
|
627
|
+
return;
|
|
628
|
+
const official = item.origin === "official_documentation";
|
|
629
|
+
const required = official
|
|
630
|
+
? ["id", "origin", "url", "version", "cached", "startLine", "endLine", "sha256", "reason", "content", "redactions", "untrusted"]
|
|
631
|
+
: ["id", "origin", "path", "startLine", "endLine", "sha256", "reason", "content", "redactions", "untrusted"];
|
|
632
|
+
exact(item, path, required, official ? [] : ["version"]);
|
|
633
|
+
const localOrigins = ["project", "dependency", "private_documentation", "diagnostic"];
|
|
634
|
+
if (typeof item.origin !== "string" || !(official || localOrigins.includes(item.origin)))
|
|
635
|
+
issue(`${path}.origin`, "VALUE", "has an unsupported context origin.");
|
|
636
|
+
if (typeof item.id !== "string" || !/^[a-f0-9]{64}$/u.test(item.id))
|
|
637
|
+
issue(`${path}.id`, "VALUE", "must be a lowercase SHA-256 identifier.");
|
|
638
|
+
else if (evidenceIds.has(item.id))
|
|
639
|
+
issue(`${path}.id`, "DUPLICATE", "must be unique.");
|
|
640
|
+
else
|
|
641
|
+
evidenceIds.add(item.id);
|
|
642
|
+
if (!Number.isSafeInteger(item.startLine) || item.startLine < 1)
|
|
643
|
+
issue(`${path}.startLine`, "VALUE", "must be a positive integer.");
|
|
644
|
+
if (!Number.isSafeInteger(item.endLine) || item.endLine < item.startLine)
|
|
645
|
+
issue(`${path}.endLine`, "VALUE", "must be an integer no earlier than startLine.");
|
|
646
|
+
if (typeof item.reason !== "string" || item.reason.trim().length === 0 || item.reason.length > 4096)
|
|
647
|
+
issue(`${path}.reason`, "VALUE", "must be a non-empty bounded reason.");
|
|
648
|
+
if (item.untrusted !== true)
|
|
649
|
+
issue(`${path}.untrusted`, "VALUE", "must be true; context never becomes policy.");
|
|
650
|
+
if (typeof item.content !== "string")
|
|
651
|
+
issue(`${path}.content`, "TYPE", "must be a string.");
|
|
652
|
+
if (typeof item.sha256 !== "string" || !/^[a-f0-9]{64}$/u.test(item.sha256))
|
|
653
|
+
issue(`${path}.sha256`, "VALUE", "must be a lowercase SHA-256 digest.");
|
|
654
|
+
if (typeof item.content === "string") {
|
|
655
|
+
calculatedBytes += Buffer.byteLength(item.content, "utf8");
|
|
656
|
+
calculatedTokens += estimatedTokens(item.content);
|
|
657
|
+
if (item.sha256 !== sha256Text(item.content))
|
|
658
|
+
issue(`${path}.sha256`, "VALUE", "does not match selected content.");
|
|
659
|
+
if (Number.isSafeInteger(item.startLine) && Number.isSafeInteger(item.endLine)
|
|
660
|
+
&& item.content.split("\n").length !== item.endLine - item.startLine + 1) {
|
|
661
|
+
issue(`${path}.content`, "VALUE", "line count does not match its exact range.");
|
|
662
|
+
}
|
|
663
|
+
if (scanSecrets(item.content).length > 0)
|
|
664
|
+
issue(`${path}.content`, "UNAUTHORIZED", "contains credential-like content.");
|
|
665
|
+
}
|
|
666
|
+
if (official) {
|
|
667
|
+
if (typeof item.url !== "string")
|
|
668
|
+
issue(`${path}.url`, "TYPE", "must be a string.");
|
|
669
|
+
else {
|
|
670
|
+
try {
|
|
671
|
+
const url = new URL(item.url);
|
|
672
|
+
if (url.protocol !== "https:" || url.username || url.password || url.hash)
|
|
673
|
+
issue(`${path}.url`, "VALUE", "must be canonical credential-free HTTPS without a fragment.");
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
issue(`${path}.url`, "VALUE", "must be a valid URL.");
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
if (typeof item.version !== "string" || item.version.trim().length === 0)
|
|
680
|
+
issue(`${path}.version`, "VALUE", "must identify the documented version.");
|
|
681
|
+
if (typeof item.cached !== "boolean")
|
|
682
|
+
issue(`${path}.cached`, "TYPE", "must be boolean.");
|
|
683
|
+
if (record.offline === true && item.cached !== true)
|
|
684
|
+
issue(`${path}.cached`, "VALUE", "must be true for an offline manifest.");
|
|
685
|
+
}
|
|
686
|
+
else {
|
|
687
|
+
if (typeof item.path !== "string" || !isSafeRelativePath(item.path) || isProtectedContextPath(item.path))
|
|
688
|
+
issue(`${path}.path`, "UNAUTHORIZED", "must be a non-protected confined path.");
|
|
689
|
+
if (item.version !== undefined && (typeof item.version !== "string" || item.version.trim().length === 0))
|
|
690
|
+
issue(`${path}.version`, "VALUE", "must be non-empty when present.");
|
|
691
|
+
}
|
|
692
|
+
const redactions = Array.isArray(item.redactions) ? item.redactions : [];
|
|
693
|
+
if (!Array.isArray(item.redactions))
|
|
694
|
+
issue(`${path}.redactions`, "TYPE", "must be an array.");
|
|
695
|
+
calculatedRedactions += redactions.length;
|
|
696
|
+
redactions.forEach((rawRedaction, redactionIndex) => {
|
|
697
|
+
const redactionPath = `${path}.redactions[${redactionIndex}]`;
|
|
698
|
+
const redaction = asRecord(rawRedaction, redactionPath);
|
|
699
|
+
if (!redaction)
|
|
700
|
+
return;
|
|
701
|
+
exact(redaction, redactionPath, ["kind", "line"]);
|
|
702
|
+
if (!["private_key", "aws_access_key", "api_token", "github_token", "google_api_key", "credential_assignment", "credential_url"].includes(String(redaction.kind)))
|
|
703
|
+
issue(`${redactionPath}.kind`, "VALUE", "has an unsupported redaction kind.");
|
|
704
|
+
if (!Number.isSafeInteger(redaction.line) || redaction.line < item.startLine || redaction.line > item.endLine)
|
|
705
|
+
issue(`${redactionPath}.line`, "VALUE", "must fall inside the evidence range.");
|
|
706
|
+
});
|
|
707
|
+
if (typeof item.id === "string" && typeof item.sha256 === "string"
|
|
708
|
+
&& typeof item.startLine === "number" && typeof item.endLine === "number") {
|
|
709
|
+
const expectedId = official && typeof item.url === "string" && typeof item.version === "string"
|
|
710
|
+
? sha256Text(`official_documentation\0${item.url}\0${item.version}\0${item.startLine}\0${item.endLine}\0${item.sha256}`)
|
|
711
|
+
: !official && typeof item.origin === "string" && typeof item.path === "string"
|
|
712
|
+
? sha256Text(`${item.origin}\0${item.path}\0${item.startLine}\0${item.endLine}\0${item.sha256}`)
|
|
713
|
+
: undefined;
|
|
714
|
+
if (expectedId !== undefined && item.id !== expectedId)
|
|
715
|
+
issue(`${path}.id`, "VALUE", "does not match the evidence provenance fields.");
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
const exclusions = Array.isArray(record.exclusions) ? record.exclusions : [];
|
|
719
|
+
if (!Array.isArray(record.exclusions))
|
|
720
|
+
issue("$contextManifest.exclusions", "TYPE", "must be an array.");
|
|
721
|
+
exclusions.forEach((raw, index) => {
|
|
722
|
+
const path = `$contextManifest.exclusions[${index}]`;
|
|
723
|
+
const exclusion = asRecord(raw, path);
|
|
724
|
+
if (!exclusion)
|
|
725
|
+
return;
|
|
726
|
+
exact(exclusion, path, ["location", "code", "reason"]);
|
|
727
|
+
if (typeof exclusion.location !== "string" || exclusion.location.length === 0)
|
|
728
|
+
issue(`${path}.location`, "VALUE", "must be non-empty.");
|
|
729
|
+
if (!["DUPLICATE", "BUDGET", "EMPTY", "OPTIONAL_UNAVAILABLE"].includes(String(exclusion.code)))
|
|
730
|
+
issue(`${path}.code`, "VALUE", "has an unsupported exclusion code.");
|
|
731
|
+
if (typeof exclusion.reason !== "string" || exclusion.reason.trim().length === 0)
|
|
732
|
+
issue(`${path}.reason`, "VALUE", "must be non-empty.");
|
|
733
|
+
});
|
|
734
|
+
const budget = asRecord(record.budget, "$contextManifest.budget");
|
|
735
|
+
if (budget) {
|
|
736
|
+
const keys = ["maxItems", "maxBytes", "maxEstimatedTokens", "maxBytesPerItem", "usedItems", "usedBytes", "usedEstimatedTokens"];
|
|
737
|
+
exact(budget, "$contextManifest.budget", keys);
|
|
738
|
+
keys.forEach((key) => {
|
|
739
|
+
const minimum = key.startsWith("max") ? 1 : 0;
|
|
740
|
+
if (!Number.isSafeInteger(budget[key]) || budget[key] < minimum)
|
|
741
|
+
issue(`$contextManifest.budget.${key}`, "VALUE", `must be a safe integer of at least ${minimum}.`);
|
|
742
|
+
});
|
|
743
|
+
if (budget.usedItems !== evidence.length)
|
|
744
|
+
issue("$contextManifest.budget.usedItems", "VALUE", "does not match evidence length.");
|
|
745
|
+
if (budget.usedBytes !== calculatedBytes)
|
|
746
|
+
issue("$contextManifest.budget.usedBytes", "VALUE", "does not match evidence bytes.");
|
|
747
|
+
if (budget.usedEstimatedTokens !== calculatedTokens)
|
|
748
|
+
issue("$contextManifest.budget.usedEstimatedTokens", "VALUE", "does not match evidence token estimate.");
|
|
749
|
+
if (typeof budget.usedItems === "number" && typeof budget.maxItems === "number" && budget.usedItems > budget.maxItems)
|
|
750
|
+
issue("$contextManifest.budget.usedItems", "VALUE", "exceeds maxItems.");
|
|
751
|
+
if (typeof budget.usedBytes === "number" && typeof budget.maxBytes === "number" && budget.usedBytes > budget.maxBytes)
|
|
752
|
+
issue("$contextManifest.budget.usedBytes", "VALUE", "exceeds maxBytes.");
|
|
753
|
+
if (typeof budget.usedEstimatedTokens === "number" && typeof budget.maxEstimatedTokens === "number" && budget.usedEstimatedTokens > budget.maxEstimatedTokens)
|
|
754
|
+
issue("$contextManifest.budget.usedEstimatedTokens", "VALUE", "exceeds maxEstimatedTokens.");
|
|
755
|
+
if (typeof budget.maxBytesPerItem === "number" && typeof budget.maxBytes === "number" && budget.maxBytesPerItem > budget.maxBytes)
|
|
756
|
+
issue("$contextManifest.budget.maxBytesPerItem", "VALUE", "cannot exceed maxBytes.");
|
|
757
|
+
if (typeof budget.maxBytesPerItem === "number" && evidence.some((item) => typeof item === "object" && item !== null && !Array.isArray(item)
|
|
758
|
+
&& typeof item.content === "string"
|
|
759
|
+
&& Buffer.byteLength(item.content, "utf8") > budget.maxBytesPerItem)) {
|
|
760
|
+
issue("$contextManifest.budget.maxBytesPerItem", "VALUE", "is smaller than an included evidence item.");
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
if (!Number.isSafeInteger(record.redactionCount) || record.redactionCount !== calculatedRedactions)
|
|
764
|
+
issue("$contextManifest.redactionCount", "VALUE", "must equal the exact number of evidence redactions.");
|
|
765
|
+
if (issues.length > 0)
|
|
766
|
+
throw new ArtifactValidationError(issues);
|
|
767
|
+
return value;
|
|
768
|
+
}
|
|
769
|
+
export function hashContextManifest(manifest) {
|
|
770
|
+
return hashCanonical(validateContextManifestV1(manifest));
|
|
771
|
+
}
|
|
772
|
+
//# sourceMappingURL=context.js.map
|