context-armor 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/src/cli.js ADDED
@@ -0,0 +1,290 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
5
+ import { parseSize } from "./config.js";
6
+ import { protectedPathsFromFindings, writeProtectionPolicies } from "./policy.js";
7
+ import { formatJson, formatPretty, formatSarif } from "./reporters.js";
8
+ import { scan, summarize } from "./scanner.js";
9
+ import { severityRank } from "./rules.js";
10
+
11
+ const packageJson = JSON.parse(
12
+ await fs.readFile(new URL("../package.json", import.meta.url), "utf8"),
13
+ );
14
+
15
+ const HELP = `Context Armor ${packageJson.version}
16
+ Local-first preflight scanning before AI agents read your workspace.
17
+
18
+ Usage:
19
+ context-armor scan [path] [options]
20
+ context-armor protect [path] [options]
21
+ context-armor init [path]
22
+ context-armor doctor
23
+
24
+ Scan options:
25
+ --format <pretty|json|sarif> Output format (default: pretty)
26
+ --output <file> Write the report to a file
27
+ --fail-on <level|none> Exit 1 at critical, high, medium, or low
28
+ --max-file-size <size> Per-file limit, e.g. 8mb
29
+ --max-files <count> Stop after this many discovered files
30
+ --config <file> Use an explicit JSON config
31
+ --baseline <file> Suppress matching fingerprints
32
+ --write-baseline <file> Save current fingerprints as a baseline
33
+ --ocr Scan images with local Tesseract
34
+ --ocr-languages <value> Tesseract languages (default: eng+kor)
35
+ --no-documents Do not extract Office/PDF documents
36
+ --no-gitignore Do not respect .gitignore
37
+ --no-hidden Skip hidden files
38
+ --no-color Disable ANSI colors
39
+ --quiet Print only errors
40
+ -h, --help Show help
41
+ -v, --version Show version
42
+
43
+ Examples:
44
+ npx context-armor scan .
45
+ npx context-armor scan . --format sarif --output results.sarif
46
+ npx context-armor protect . --fail-on critical
47
+ `;
48
+
49
+ function readValue(args, index, flag) {
50
+ if (index + 1 >= args.length) throw new Error(`${flag} requires a value`);
51
+ return args[index + 1];
52
+ }
53
+
54
+ function parseArgs(args) {
55
+ const parsed = {
56
+ command: "scan",
57
+ target: ".",
58
+ format: "pretty",
59
+ color: undefined,
60
+ };
61
+ let index = 0;
62
+ if (args[index] && !args[index].startsWith("-")) {
63
+ parsed.command = args[index];
64
+ index += 1;
65
+ }
66
+ if (["scan", "protect", "init"].includes(parsed.command) && args[index] && !args[index].startsWith("-")) {
67
+ parsed.target = args[index];
68
+ index += 1;
69
+ }
70
+
71
+ while (index < args.length) {
72
+ const flag = args[index];
73
+ if (flag === "-h" || flag === "--help") parsed.help = true;
74
+ else if (flag === "-v" || flag === "--version") parsed.version = true;
75
+ else if (flag === "--ocr") parsed.ocr = true;
76
+ else if (flag === "--no-documents") parsed.documents = false;
77
+ else if (flag === "--no-gitignore") parsed.respectGitignore = false;
78
+ else if (flag === "--no-hidden") parsed.includeHidden = false;
79
+ else if (flag === "--no-color") parsed.color = false;
80
+ else if (flag === "--quiet") parsed.quiet = true;
81
+ else if (flag === "--format") {
82
+ parsed.format = readValue(args, index, flag);
83
+ index += 1;
84
+ } else if (flag === "--output") {
85
+ parsed.output = readValue(args, index, flag);
86
+ index += 1;
87
+ } else if (flag === "--fail-on") {
88
+ parsed.failOn = readValue(args, index, flag);
89
+ index += 1;
90
+ } else if (flag === "--max-file-size") {
91
+ parsed.maxFileSize = parseSize(readValue(args, index, flag));
92
+ index += 1;
93
+ } else if (flag === "--max-files") {
94
+ parsed.maxFiles = Number(readValue(args, index, flag));
95
+ index += 1;
96
+ } else if (flag === "--config") {
97
+ parsed.configPath = readValue(args, index, flag);
98
+ index += 1;
99
+ } else if (flag === "--baseline") {
100
+ parsed.baseline = readValue(args, index, flag);
101
+ index += 1;
102
+ } else if (flag === "--write-baseline") {
103
+ parsed.writeBaseline = readValue(args, index, flag);
104
+ index += 1;
105
+ } else if (flag === "--ocr-languages") {
106
+ parsed.ocrLanguages = readValue(args, index, flag);
107
+ index += 1;
108
+ } else {
109
+ throw new Error(`Unknown option: ${flag}`);
110
+ }
111
+ index += 1;
112
+ }
113
+ return parsed;
114
+ }
115
+
116
+ async function loadBaseline(file) {
117
+ if (!file) return new Set();
118
+ const value = JSON.parse(await fs.readFile(path.resolve(file), "utf8"));
119
+ const fingerprints = Array.isArray(value) ? value : value.fingerprints;
120
+ if (!Array.isArray(fingerprints)) throw new Error(`Invalid baseline file: ${file}`);
121
+ return new Set(fingerprints);
122
+ }
123
+
124
+ async function applyBaseline(result, baselineFile) {
125
+ const fingerprints = await loadBaseline(baselineFile);
126
+ if (!fingerprints.size) return result;
127
+ const original = result.findings.length;
128
+ result.findings = result.findings.filter(
129
+ (finding) => !fingerprints.has(finding.fingerprint),
130
+ );
131
+ result.suppressed = original - result.findings.length;
132
+ result.summary = summarize(result.findings);
133
+ return result;
134
+ }
135
+
136
+ async function writeBaseline(file, result) {
137
+ const value = {
138
+ schemaVersion: "1.0",
139
+ generatedAt: new Date().toISOString(),
140
+ fingerprints: result.findings.map((finding) => finding.fingerprint).sort(),
141
+ };
142
+ await fs.writeFile(path.resolve(file), `${JSON.stringify(value, null, 2)}\n`, "utf8");
143
+ }
144
+
145
+ function shouldFail(result, failOn) {
146
+ if (!failOn || failOn === "none") return false;
147
+ if (!(failOn in severityRank)) throw new Error(`Invalid --fail-on level: ${failOn}`);
148
+ return result.findings.some(
149
+ (finding) => severityRank[finding.severity] <= severityRank[failOn],
150
+ );
151
+ }
152
+
153
+ async function initProject(target) {
154
+ const root = path.resolve(target);
155
+ await fs.mkdir(root, { recursive: true });
156
+ const files = {
157
+ "context-armor.config.json": `${JSON.stringify(
158
+ {
159
+ failOn: "high",
160
+ maxFileSize: "8mb",
161
+ documents: true,
162
+ ocr: false,
163
+ respectGitignore: true,
164
+ ignore: ["fixtures/public/**"],
165
+ disabledRules: [],
166
+ severity: {},
167
+ },
168
+ null,
169
+ 2,
170
+ )}\n`,
171
+ ".contextarmorignore":
172
+ "# Context Armor-only exclusions\n# Add generated fixtures or intentionally public data here.\n",
173
+ ".github/workflows/context-armor.yml": `name: Context Armor
174
+ on:
175
+ pull_request:
176
+ push:
177
+ branches: [main]
178
+ permissions:
179
+ contents: read
180
+ security-events: write
181
+ jobs:
182
+ scan:
183
+ runs-on: ubuntu-latest
184
+ steps:
185
+ - uses: actions/checkout@v4
186
+ - uses: feelyday/context-armor@v0
187
+ with:
188
+ path: .
189
+ fail_on: high
190
+ output: context-armor-results.sarif
191
+ - uses: github/codeql-action/upload-sarif@v3
192
+ if: always()
193
+ with:
194
+ sarif_file: context-armor-results.sarif
195
+ `,
196
+ };
197
+ const created = [];
198
+ for (const [relative, contents] of Object.entries(files)) {
199
+ const file = path.join(root, relative);
200
+ await fs.mkdir(path.dirname(file), { recursive: true });
201
+ try {
202
+ await fs.writeFile(file, contents, { encoding: "utf8", flag: "wx" });
203
+ created.push(relative);
204
+ } catch (error) {
205
+ if (error?.code !== "EEXIST") throw error;
206
+ }
207
+ }
208
+ return created;
209
+ }
210
+
211
+ function commandAvailable(command, args = ["--version"]) {
212
+ const result = spawnSync(command, args, { encoding: "utf8", timeout: 5_000 });
213
+ return {
214
+ available: !result.error && result.status === 0,
215
+ version: (result.stdout || result.stderr || "").trim().split("\n")[0],
216
+ };
217
+ }
218
+
219
+ function formatResult(result, format, color) {
220
+ if (format === "pretty") return formatPretty(result, { color });
221
+ if (format === "json") return formatJson(result);
222
+ if (format === "sarif") return formatSarif(result);
223
+ throw new Error(`Unknown format: ${format}`);
224
+ }
225
+
226
+ export async function runCli(args) {
227
+ const parsed = parseArgs(args);
228
+ if (parsed.version) {
229
+ process.stdout.write(`${packageJson.version}\n`);
230
+ return;
231
+ }
232
+ if (parsed.help || parsed.command === "help") {
233
+ process.stdout.write(HELP);
234
+ return;
235
+ }
236
+ if (parsed.command === "doctor") {
237
+ const checks = [
238
+ ["Node.js", { available: Number(process.versions.node.split(".")[0]) >= 20, version: process.version }],
239
+ ["pdftotext", commandAvailable("pdftotext")],
240
+ ["Tesseract (optional)", commandAvailable("tesseract")],
241
+ ["Git", commandAvailable("git")],
242
+ ];
243
+ for (const [name, result] of checks) {
244
+ process.stdout.write(`${result.available ? "✓" : "○"} ${name}${result.version ? ` — ${result.version}` : ""}\n`);
245
+ }
246
+ return;
247
+ }
248
+ if (parsed.command === "init") {
249
+ const created = await initProject(parsed.target);
250
+ process.stdout.write(
251
+ created.length
252
+ ? `Created ${created.join(", ")}\n`
253
+ : "Context Armor is already initialized; no files changed.\n",
254
+ );
255
+ return;
256
+ }
257
+ if (!["scan", "protect"].includes(parsed.command)) {
258
+ throw new Error(`Unknown command: ${parsed.command}`);
259
+ }
260
+
261
+ let result = await scan(parsed.target, {
262
+ configPath: parsed.configPath,
263
+ failOn: parsed.failOn,
264
+ maxFileSize: parsed.maxFileSize,
265
+ maxFiles: parsed.maxFiles,
266
+ ocr: parsed.ocr,
267
+ ocrLanguages: parsed.ocrLanguages,
268
+ documents: parsed.documents,
269
+ respectGitignore: parsed.respectGitignore,
270
+ includeHidden: parsed.includeHidden,
271
+ toolVersion: packageJson.version,
272
+ });
273
+ result = await applyBaseline(result, parsed.baseline);
274
+ if (parsed.writeBaseline) await writeBaseline(parsed.writeBaseline, result);
275
+
276
+ if (parsed.command === "protect") {
277
+ const paths = protectedPathsFromFindings(result.findings, parsed.failOn || "high");
278
+ const files = await writeProtectionPolicies(path.resolve(parsed.target), paths);
279
+ result.protection = { paths: paths.length, files };
280
+ }
281
+
282
+ const output = formatResult(result, parsed.format, parsed.color);
283
+ if (parsed.output) await fs.writeFile(path.resolve(parsed.output), output, "utf8");
284
+ else if (!parsed.quiet) process.stdout.write(output);
285
+
286
+ const effectiveFailOn = parsed.failOn || result.policy?.failOn || "high";
287
+ if (shouldFail(result, effectiveFailOn)) process.exitCode = 1;
288
+ }
289
+
290
+ export { applyBaseline, initProject, parseArgs, shouldFail };
package/src/config.js ADDED
@@ -0,0 +1,78 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export const defaultConfig = Object.freeze({
5
+ failOn: "high",
6
+ maxFileSize: 8 * 1024 * 1024,
7
+ maxFiles: 100_000,
8
+ documents: true,
9
+ ocr: false,
10
+ respectGitignore: true,
11
+ includeHidden: true,
12
+ ignore: [
13
+ "**/.git/**",
14
+ "**/node_modules/**",
15
+ "**/vendor/**",
16
+ "**/.next/**",
17
+ "**/dist/**",
18
+ "**/build/**",
19
+ "**/coverage/**",
20
+ "**/.context-armor/**",
21
+ ],
22
+ disabledRules: [],
23
+ severity: {},
24
+ });
25
+
26
+ function parseSize(value) {
27
+ if (typeof value === "number" && Number.isFinite(value)) return value;
28
+ const match = String(value).trim().match(/^(\d+(?:\.\d+)?)\s*(b|kb|kib|mb|mib|gb|gib)?$/i);
29
+ if (!match) throw new Error(`Invalid size: ${value}`);
30
+ const units = {
31
+ b: 1,
32
+ kb: 1000,
33
+ kib: 1024,
34
+ mb: 1000 ** 2,
35
+ mib: 1024 ** 2,
36
+ gb: 1000 ** 3,
37
+ gib: 1024 ** 3,
38
+ };
39
+ return Math.floor(Number(match[1]) * (units[(match[2] || "b").toLowerCase()] || 1));
40
+ }
41
+
42
+ export async function loadConfig(root, explicitPath) {
43
+ const candidates = explicitPath
44
+ ? [path.resolve(explicitPath)]
45
+ : [
46
+ path.join(root, "context-armor.config.json"),
47
+ path.join(root, ".contextarmorrc.json"),
48
+ ];
49
+
50
+ let userConfig = {};
51
+ let configPath = null;
52
+ for (const candidate of candidates) {
53
+ try {
54
+ userConfig = JSON.parse(await fs.readFile(candidate, "utf8"));
55
+ configPath = candidate;
56
+ break;
57
+ } catch (error) {
58
+ if (error?.code === "ENOENT") continue;
59
+ if (error instanceof SyntaxError) {
60
+ throw new Error(`Invalid JSON in ${candidate}: ${error.message}`);
61
+ }
62
+ throw error;
63
+ }
64
+ }
65
+
66
+ const config = {
67
+ ...defaultConfig,
68
+ ...userConfig,
69
+ ignore: [...defaultConfig.ignore, ...(userConfig.ignore || [])],
70
+ disabledRules: [...(userConfig.disabledRules || [])],
71
+ severity: { ...(userConfig.severity || {}) },
72
+ };
73
+ config.maxFileSize = parseSize(config.maxFileSize);
74
+
75
+ return { config, configPath };
76
+ }
77
+
78
+ export { parseSize };
@@ -0,0 +1,154 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { inflateRawSync } from "node:zlib";
5
+
6
+ const OFFICE_EXTENSIONS = new Set([".docx", ".xlsx", ".pptx"]);
7
+ const IMAGE_EXTENSIONS = new Set([
8
+ ".png",
9
+ ".jpg",
10
+ ".jpeg",
11
+ ".webp",
12
+ ".tif",
13
+ ".tiff",
14
+ ".bmp",
15
+ ".heic",
16
+ ]);
17
+
18
+ function decodeXml(value) {
19
+ return value
20
+ .replace(/<w:tab\/>/g, "\t")
21
+ .replace(/<w:br\/>/g, "\n")
22
+ .replace(/<\/(?:w:p|a:p|row)>/g, "\n")
23
+ .replace(/<[^>]+>/g, " ")
24
+ .replace(/&lt;/g, "<")
25
+ .replace(/&gt;/g, ">")
26
+ .replace(/&quot;/g, '"')
27
+ .replace(/&apos;/g, "'")
28
+ .replace(/&amp;/g, "&")
29
+ .replace(/&#(\d+);/g, (_, number) => String.fromCodePoint(Number(number)))
30
+ .replace(/[ \t]+/g, " ")
31
+ .replace(/\n[ \t]+/g, "\n");
32
+ }
33
+
34
+ function findEndOfCentralDirectory(buffer) {
35
+ const signature = 0x06054b50;
36
+ const start = Math.max(0, buffer.length - 65_557);
37
+ for (let offset = buffer.length - 22; offset >= start; offset -= 1) {
38
+ if (buffer.readUInt32LE(offset) === signature) return offset;
39
+ }
40
+ return -1;
41
+ }
42
+
43
+ export function readZipEntries(buffer, limits = {}) {
44
+ const maxEntries = limits.maxEntries ?? 10_000;
45
+ const maxOutput = limits.maxOutput ?? 32 * 1024 * 1024;
46
+ const eocd = findEndOfCentralDirectory(buffer);
47
+ if (eocd < 0) throw new Error("ZIP central directory not found");
48
+
49
+ const entryCount = Math.min(buffer.readUInt16LE(eocd + 10), maxEntries);
50
+ let offset = buffer.readUInt32LE(eocd + 16);
51
+ let outputBytes = 0;
52
+ const entries = [];
53
+
54
+ for (let index = 0; index < entryCount; index += 1) {
55
+ if (offset + 46 > buffer.length || buffer.readUInt32LE(offset) !== 0x02014b50) break;
56
+ const method = buffer.readUInt16LE(offset + 10);
57
+ const compressedSize = buffer.readUInt32LE(offset + 20);
58
+ const uncompressedSize = buffer.readUInt32LE(offset + 24);
59
+ const nameLength = buffer.readUInt16LE(offset + 28);
60
+ const extraLength = buffer.readUInt16LE(offset + 30);
61
+ const commentLength = buffer.readUInt16LE(offset + 32);
62
+ const localOffset = buffer.readUInt32LE(offset + 42);
63
+ const name = buffer.subarray(offset + 46, offset + 46 + nameLength).toString("utf8");
64
+ offset += 46 + nameLength + extraLength + commentLength;
65
+
66
+ if (
67
+ name.endsWith("/") ||
68
+ uncompressedSize > maxOutput ||
69
+ outputBytes + uncompressedSize > maxOutput ||
70
+ localOffset + 30 > buffer.length ||
71
+ buffer.readUInt32LE(localOffset) !== 0x04034b50
72
+ ) {
73
+ continue;
74
+ }
75
+
76
+ const localNameLength = buffer.readUInt16LE(localOffset + 26);
77
+ const localExtraLength = buffer.readUInt16LE(localOffset + 28);
78
+ const dataStart = localOffset + 30 + localNameLength + localExtraLength;
79
+ const dataEnd = dataStart + compressedSize;
80
+ if (dataEnd > buffer.length) continue;
81
+
82
+ const compressed = buffer.subarray(dataStart, dataEnd);
83
+ let data;
84
+ if (method === 0) data = compressed;
85
+ else if (method === 8) data = inflateRawSync(compressed, { maxOutputLength: maxOutput });
86
+ else continue;
87
+
88
+ outputBytes += data.length;
89
+ entries.push({ name, data });
90
+ }
91
+ return entries;
92
+ }
93
+
94
+ export async function extractOfficeText(file, maxBytes) {
95
+ const buffer = await fs.readFile(file);
96
+ const entries = readZipEntries(buffer, { maxOutput: Math.max(maxBytes * 4, 32 * 1024 * 1024) });
97
+ const relevant = entries.filter(({ name }) => {
98
+ if (!name.endsWith(".xml")) return false;
99
+ return /^(?:word\/|xl\/(?:sharedStrings|worksheets\/)|ppt\/(?:slides|notesSlides)\/)/.test(
100
+ name,
101
+ );
102
+ });
103
+ return relevant.map(({ data }) => decodeXml(data.toString("utf8"))).join("\n");
104
+ }
105
+
106
+ function commandText(command, args, maxBytes) {
107
+ const result = spawnSync(command, args, {
108
+ encoding: "utf8",
109
+ timeout: 20_000,
110
+ maxBuffer: maxBytes,
111
+ windowsHide: true,
112
+ });
113
+ if (result.error || result.status !== 0) return null;
114
+ return result.stdout || "";
115
+ }
116
+
117
+ export async function extractDocument(file, options) {
118
+ const extension = path.extname(file).toLowerCase();
119
+ if (OFFICE_EXTENSIONS.has(extension)) {
120
+ try {
121
+ return {
122
+ text: await extractOfficeText(file, options.maxFileSize),
123
+ inspected: true,
124
+ extractor: "office-xml",
125
+ };
126
+ } catch (error) {
127
+ return { text: "", inspected: false, extractor: "office-xml", error: error.message };
128
+ }
129
+ }
130
+ if (extension === ".pdf") {
131
+ const text = commandText("pdftotext", ["-q", "-enc", "UTF-8", file, "-"], options.maxFileSize * 4);
132
+ return text === null
133
+ ? { text: "", inspected: false, extractor: "pdftotext" }
134
+ : { text, inspected: true, extractor: "pdftotext" };
135
+ }
136
+ if (IMAGE_EXTENSIONS.has(extension)) {
137
+ if (!options.ocr) return { text: "", inspected: false, extractor: "ocr-disabled" };
138
+ const text = commandText(
139
+ "tesseract",
140
+ [file, "stdout", "-l", options.ocrLanguages || "eng+kor", "--psm", "6"],
141
+ options.maxFileSize * 4,
142
+ );
143
+ return text === null
144
+ ? { text: "", inspected: false, extractor: "tesseract" }
145
+ : { text, inspected: true, extractor: "tesseract" };
146
+ }
147
+ return null;
148
+ }
149
+
150
+ export function isDocumentExtension(extension) {
151
+ return OFFICE_EXTENSIONS.has(extension) || extension === ".pdf" || IMAGE_EXTENSIONS.has(extension);
152
+ }
153
+
154
+ export { decodeXml, IMAGE_EXTENSIONS, OFFICE_EXTENSIONS };
package/src/ignore.js ADDED
@@ -0,0 +1,87 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ function normalize(value) {
5
+ return value.split(path.sep).join("/").replace(/^\.\//, "");
6
+ }
7
+
8
+ export function globToRegExp(glob) {
9
+ let source = "";
10
+ const raw = normalize(glob);
11
+ const directoryOnly = raw.endsWith("/");
12
+ const normalized = directoryOnly ? raw.slice(0, -1) : raw;
13
+ for (let index = 0; index < normalized.length; index += 1) {
14
+ const character = normalized[index];
15
+ const next = normalized[index + 1];
16
+ if (character === "*" && next === "*") {
17
+ const after = normalized[index + 2];
18
+ if (after === "/") {
19
+ source += "(?:.*/)?";
20
+ index += 2;
21
+ } else {
22
+ source += ".*";
23
+ index += 1;
24
+ }
25
+ } else if (character === "*") {
26
+ source += "[^/]*";
27
+ } else if (character === "?") {
28
+ source += "[^/]";
29
+ } else if (character === "/") {
30
+ source += "\\/";
31
+ } else {
32
+ source += character.replace(/[|\\{}()[\]^$+?.-]/g, "\\$&");
33
+ }
34
+ }
35
+
36
+ const anchored = normalized.startsWith("/");
37
+ const prefix = anchored ? "^" : "(?:^|/)";
38
+ const suffix = directoryOnly ? "(?:/.*)?$" : "(?:$|/.*$)";
39
+ return new RegExp(`${prefix}${source.replace(/^\\\//, "")}${suffix}`);
40
+ }
41
+
42
+ function parseIgnoreLines(contents) {
43
+ return contents
44
+ .split(/\r?\n/)
45
+ .map((line) => line.trim())
46
+ .filter((line) => line && !line.startsWith("#"))
47
+ .map((line) => ({
48
+ negated: line.startsWith("!"),
49
+ pattern: line.startsWith("!") ? line.slice(1) : line,
50
+ }))
51
+ .filter((entry) => entry.pattern);
52
+ }
53
+
54
+ async function readOptional(file) {
55
+ try {
56
+ return await fs.readFile(file, "utf8");
57
+ } catch (error) {
58
+ if (error?.code === "ENOENT") return "";
59
+ throw error;
60
+ }
61
+ }
62
+
63
+ export async function createIgnoreMatcher(root, config) {
64
+ const entries = config.ignore.map((pattern) => ({ pattern, negated: false }));
65
+ const ownIgnore = await readOptional(path.join(root, ".contextarmorignore"));
66
+ entries.push(...parseIgnoreLines(ownIgnore));
67
+ if (config.respectGitignore) {
68
+ const gitIgnore = await readOptional(path.join(root, ".gitignore"));
69
+ entries.push(...parseIgnoreLines(gitIgnore));
70
+ }
71
+
72
+ const compiled = entries.map((entry) => ({
73
+ ...entry,
74
+ regex: globToRegExp(entry.pattern),
75
+ }));
76
+
77
+ return (relativePath, isDirectory = false) => {
78
+ const candidate = normalize(relativePath) + (isDirectory ? "/" : "");
79
+ let ignored = false;
80
+ for (const entry of compiled) {
81
+ if (entry.regex.test(candidate)) ignored = !entry.negated;
82
+ }
83
+ return ignored;
84
+ };
85
+ }
86
+
87
+ export { normalize, parseIgnoreLines };
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { scan } from "./scanner.js";
2
+ export { defaultConfig, loadConfig } from "./config.js";
3
+ export { contentRules, filenameRules, severityRank } from "./rules.js";
4
+ export { formatJson, formatPretty, formatSarif } from "./reporters.js";
package/src/policy.js ADDED
@@ -0,0 +1,85 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { severityRank } from "./rules.js";
4
+
5
+ const BLOCK_START = "# context-armor:start";
6
+ const BLOCK_END = "# context-armor:end";
7
+
8
+ async function readOptional(file) {
9
+ try {
10
+ return await fs.readFile(file, "utf8");
11
+ } catch (error) {
12
+ if (error?.code === "ENOENT") return "";
13
+ throw error;
14
+ }
15
+ }
16
+
17
+ async function writeMarkedBlock(file, paths) {
18
+ const existing = await readOptional(file);
19
+ const start = existing.indexOf(BLOCK_START);
20
+ const end = existing.indexOf(BLOCK_END);
21
+ let clean = existing;
22
+ if (start >= 0 && end >= start) {
23
+ clean = `${existing.slice(0, start)}${existing.slice(end + BLOCK_END.length)}`;
24
+ }
25
+ clean = clean.trimEnd();
26
+ const block = [BLOCK_START, "# Generated by Context Armor. Re-run `context-armor protect`.", ...paths, BLOCK_END].join(
27
+ "\n",
28
+ );
29
+ await fs.writeFile(file, `${clean ? `${clean}\n\n` : ""}${block}\n`, "utf8");
30
+ }
31
+
32
+ async function writeClaudePolicy(root, protectedPaths) {
33
+ const directory = path.join(root, ".claude");
34
+ const file = path.join(directory, "settings.local.json");
35
+ await fs.mkdir(directory, { recursive: true });
36
+ let settings = {};
37
+ const current = await readOptional(file);
38
+ if (current.trim()) {
39
+ try {
40
+ settings = JSON.parse(current);
41
+ } catch {
42
+ throw new Error(`Cannot update invalid JSON: ${file}`);
43
+ }
44
+ }
45
+ settings.permissions ||= {};
46
+ const existing = new Set(settings.permissions.deny || []);
47
+ for (const protectedPath of protectedPaths) {
48
+ existing.add(`Read(${protectedPath})`);
49
+ existing.add(`Edit(${protectedPath})`);
50
+ }
51
+ settings.permissions.deny = [...existing].sort();
52
+ await fs.writeFile(file, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
53
+ }
54
+
55
+ export function protectedPathsFromFindings(findings, threshold = "high") {
56
+ const thresholdRank = severityRank[threshold];
57
+ return [
58
+ ...new Set(
59
+ findings
60
+ .filter((finding) => severityRank[finding.severity] <= thresholdRank)
61
+ .map((finding) => finding.path),
62
+ ),
63
+ ].sort();
64
+ }
65
+
66
+ export async function writeProtectionPolicies(root, protectedPaths) {
67
+ const stateDirectory = path.join(root, ".context-armor");
68
+ await fs.mkdir(stateDirectory, { recursive: true });
69
+ await fs.writeFile(
70
+ path.join(stateDirectory, "protected-paths.txt"),
71
+ `${protectedPaths.join("\n")}${protectedPaths.length ? "\n" : ""}`,
72
+ "utf8",
73
+ );
74
+ await Promise.all([
75
+ writeMarkedBlock(path.join(root, ".cursorignore"), protectedPaths),
76
+ writeMarkedBlock(path.join(root, ".geminiignore"), protectedPaths),
77
+ writeClaudePolicy(root, protectedPaths),
78
+ ]);
79
+ return [
80
+ ".context-armor/protected-paths.txt",
81
+ ".cursorignore",
82
+ ".geminiignore",
83
+ ".claude/settings.local.json",
84
+ ];
85
+ }