Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
node_modules/
dist/
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ jobs:
steps:
- uses: actions/checkout@v6

- uses: Conalh/PolicyMesh@v0.1.15
- uses: Conalh/PolicyMesh@v0.1.16
with:
fail-on: none
```
Expand Down
37 changes: 37 additions & 0 deletions dist/audit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { countConfiguredSurfaces, parseRepoPolicies } from './parsers/index.js';
import { buildEffectiveUnion, buildSurfaceMatrix, runMeshRules } from './mesh/engine.js';
const severityRank = {
none: 0,
low: 1,
medium: 2,
high: 3,
critical: 4
};
export async function auditRepo(root) {
const policies = await parseRepoPolicies(root);
const findings = [...(policies.parseFindings ?? []), ...runMeshRules(policies)];
return {
rating: rateFindings(findings),
findingCount: findings.length,
surfaceCount: countConfiguredSurfaces(policies),
findings,
effectiveUnion: buildEffectiveUnion(policies),
matrix: buildSurfaceMatrix(policies)
};
}
function rateFindings(findings) {
let rating = 'none';
for (const finding of findings) {
if (severityRank[finding.severity] > severityRank[rating]) {
rating = finding.severity;
}
}
return rating;
}
export function meetsFailThreshold(rating, failOn) {
if (failOn === 'none') {
return false;
}
return severityRank[rating] >= severityRank[failOn];
}
export { severityRank };
171 changes: 171 additions & 0 deletions dist/discovery.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
export async function readJsonObject(path) {
return (await readJsonObjectWithSource(path)).json;
}
export async function readJsonObjectWithSource(path) {
let raw = '';
try {
raw = await readFile(path, 'utf8');
}
catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
return { json: {}, text: '' };
}
throw error;
}
const stripped = stripJsonComments(raw);
try {
const parsed = JSON.parse(stripped);
return { json: isRecord(parsed) ? parsed : {}, text: raw };
}
catch (error) {
if (error instanceof SyntaxError) {
return {
json: {},
text: raw,
parseError: {
message: error.message,
line: lineOfJsonParseError(stripped, error)
}
};
}
throw error;
}
}
// VS Code and Cursor both ship MCP configs as JSONC — comments and the
// occasional trailing comma are normal, not malformed. We strip them
// before JSON.parse so those files audit cleanly. Replacing comment
// bytes with spaces (and preserving newlines in block comments) keeps
// the original byte/line positions intact for error reporting and the
// downstream line locators in lineOfJsonKey / lineOfJsonStringValue.
function stripJsonComments(raw) {
let out = '';
let inString = false;
let escape = false;
for (let index = 0; index < raw.length; index += 1) {
const char = raw[index];
const next = raw[index + 1];
if (inString) {
out += char;
if (escape) {
escape = false;
}
else if (char === '\\') {
escape = true;
}
else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
out += char;
continue;
}
if (char === '/' && next === '/') {
while (index < raw.length && raw[index] !== '\n') {
out += ' ';
index += 1;
}
// restore loop invariant: the for-loop's index++ will advance past '\n'
if (index < raw.length) {
out += raw[index];
}
continue;
}
if (char === '/' && next === '*') {
out += ' ';
index += 2;
while (index < raw.length && !(raw[index] === '*' && raw[index + 1] === '/')) {
out += raw[index] === '\n' ? '\n' : ' ';
index += 1;
}
if (index < raw.length) {
out += ' ';
index += 1; // for-loop will advance past the '/'
}
continue;
}
out += char;
}
return stripTrailingCommas(out);
}
// Trailing commas before `]` or `}` are legal in JSONC; JSON.parse rejects
// them. Removing them after comment-stripping keeps byte positions stable
// because we replace each removed comma with a space.
function stripTrailingCommas(raw) {
let out = '';
let inString = false;
let escape = false;
for (let index = 0; index < raw.length; index += 1) {
const char = raw[index];
if (inString) {
out += char;
if (escape) {
escape = false;
}
else if (char === '\\') {
escape = true;
}
else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
out += char;
continue;
}
if (char === ',') {
let look = index + 1;
while (look < raw.length && /\s/.test(raw[look])) {
look += 1;
}
if (raw[look] === ']' || raw[look] === '}') {
out += ' ';
continue;
}
}
out += char;
}
return out;
}
export function configPath(root, relativePath) {
return join(root, relativePath);
}
export function isRecord(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function lineOfJsonKey(text, key) {
const keyPattern = new RegExp(`"${escapeRegExp(key)}"\\s*:`);
return lineOfPattern(text, keyPattern);
}
export function lineOfJsonStringValue(text, value) {
const encoded = JSON.stringify(value);
return lineOfPattern(text, new RegExp(escapeRegExp(encoded)));
}
function lineOfPattern(text, pattern) {
const lines = text.split(/\r?\n/);
const index = lines.findIndex((line) => pattern.test(line));
return index === -1 ? undefined : index + 1;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function lineOfJsonParseError(text, error) {
const positionMatch = /position (\d+)/.exec(error.message);

Check warning on line 159 in dist/discovery.js

View workflow job for this annotation

GitHub Actions / scope-review

TaskBound high scope creep

Added code can spawn shell commands or subprocesses during the task. Recommendation: Confirm the command source is trusted and in scope.
if (!positionMatch) {
return undefined;
}
const position = Number(positionMatch[1]);
if (!Number.isInteger(position) || position < 0) {
return undefined;
}
return text.slice(0, position).split(/\r?\n/).length;
}
function isNodeError(error) {
return error instanceof Error && 'code' in error;
}
90 changes: 90 additions & 0 deletions dist/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#!/usr/bin/env node
import { stat } from 'node:fs/promises';
import { relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { auditRepo } from './audit.js';
import { renderReport } from './report.js';
export { auditRepo } from './audit.js';
export async function main(argv = process.argv.slice(2)) {
if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
process.stdout.write('Usage: policymesh audit --repo <path> [--format text|markdown|json|github]\n');
return 0;
}
if (argv[0] === 'audit') {
return runAudit(argv.slice(1));
}
process.stderr.write(`Unknown command: ${argv[0]}\n`);
return 2;
}
async function runAudit(argv) {
const parsed = parseAuditArgs(argv);
if (!parsed.ok) {
process.stderr.write(`${parsed.error}\n${usage()}\n`);
return 2;
}
const repoError = await validateRepoPath(parsed.repo);
if (repoError) {
process.stderr.write(`${repoError}\n`);
return 2;
}
const report = await auditRepo(parsed.repo);
process.stdout.write(renderReport(report, parsed.format, {
githubAnnotationPathPrefix: githubAnnotationPathPrefix(parsed.repo)
}));
return 0;
}
function parseAuditArgs(argv) {
let repo = process.cwd();
let format = 'text';
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const value = argv[index + 1];
if (arg === '--repo') {
if (!value || value.startsWith('--')) {
return { ok: false, error: 'Missing value for --repo' };
}
repo = value;
index += 1;
}
else if (arg === '--format') {
if (!isReportFormat(value)) {
return { ok: false, error: `Invalid format: ${value ?? ''}` };
}
format = value;
index += 1;
}
else {
return { ok: false, error: `Unknown argument: ${arg}` };
}
}
return { ok: true, repo, format };
}
function isReportFormat(value) {
return value === 'text' || value === 'markdown' || value === 'json' || value === 'github';
}
function githubAnnotationPathPrefix(repo) {
const prefix = relative(process.cwd(), resolve(repo));
return prefix && prefix !== '.' && !prefix.startsWith('..') ? prefix : undefined;
}
async function validateRepoPath(repo) {
try {
const stats = await stat(repo);
return stats.isDirectory() ? undefined : `Repository path is not a directory: ${repo}`;
}
catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
return `Repository path does not exist: ${repo}`;
}
throw error;
}
}
function isNodeError(error) {
return error instanceof Error && 'code' in error;
}
const invokedPath = process.argv[1] ? fileURLToPath(import.meta.url) === process.argv[1] : false;
if (invokedPath) {
process.exitCode = await main();
}
function usage() {
return 'Usage: policymesh audit --repo <path> [--format text|markdown|json|github]';
}
Loading