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
7 changes: 0 additions & 7 deletions .claude/settings.json

This file was deleted.

8 changes: 7 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,18 @@ permissions:
jobs:
build-test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# agent-gov-core@>=0.7 requires Node >=20; test the supported
# range so package consumers aren't surprised on LTS Node 20/22.
node-version: [20, 22, 24]
steps:
- uses: actions/checkout@v6

- uses: actions/setup-node@v6
with:
node-version: 24
node-version: ${{ matrix.node-version }}
cache: npm

- run: npm ci
Expand Down
10 changes: 7 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
node_modules/

# Local codex working state — not for tracking. The detector fixtures
# under test/fixtures/**/.codex/ are deliberately included; only the
# top-level dogfood .codex/ is ignored.
# Local agent working state — not for tracking. The historical demo PR
# (#3) intentionally introduced these at the repo root with risky
# settings; they were merged into main and then needed cleanup. Ignore
# them at the root going forward so a passing scan can't silently
# regress. Detector fixtures under test/fixtures/**/ stay tracked.
/.codex/
/.claude/
/.mcp.json
8 changes: 0 additions & 8 deletions .mcp.json

This file was deleted.

55 changes: 22 additions & 33 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,48 +67,37 @@ runs:

# Single scan: stdout streams GitHub annotations so the runner
# picks up ::warning lines, while --out-markdown and --out-json
# capture the other two renderings from the same run. Previously
# this ran the CLI three times (markdown/json/github), repeating
# both git snapshot materialization and full detector work.
# capture the other two renderings from the same run. The CLI
# itself enforces --fail-on (exit 1) so we don't reimplement
# the rank table in bash. Capture the CLI status without
# `set -e` halting before outputs are written.
set +e
node "$GITHUB_ACTION_PATH/dist/index.js" diff \
--repo "$repo" --base "$base" --head "$head" \
--format github \
--out-markdown "$report_file" \
--out-json "$json_file"
--out-json "$json_file" \
--fail-on "$fail_on"
cli_status=$?
set -e

# Surface the markdown report in the Action log for parity
# with the prior `tee` of `--format markdown`.
cat "$report_file"

if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
cat "$report_file" >> "$GITHUB_STEP_SUMMARY"
if [ -f "$report_file" ]; then
cat "$report_file"
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
cat "$report_file" >> "$GITHUB_STEP_SUMMARY"
fi
fi

rating="$(node -e "const fs = require('node:fs'); console.log(JSON.parse(fs.readFileSync(process.argv[1], 'utf8')).rating)" "$json_file")"
finding_count="$(node -e "const fs = require('node:fs'); console.log(JSON.parse(fs.readFileSync(process.argv[1], 'utf8')).findingCount)" "$json_file")"
echo "rating=$rating" >> "$GITHUB_OUTPUT"
echo "finding-count=$finding_count" >> "$GITHUB_OUTPUT"

rank() {
case "$1" in
none) echo 0 ;;
low) echo 1 ;;
medium) echo 2 ;;
high) echo 3 ;;
critical) echo 4 ;;
*) echo -1 ;;
esac
}

fail_rank="$(rank "$fail_on")"
rating_rank="$(rank "$rating")"

if [ "$fail_rank" -lt 0 ]; then
echo "::error::Invalid fail-on value '$fail_on'. Use none, low, medium, high, or critical."
exit 2
if [ -f "$json_file" ]; then
rating="$(node -e "const fs = require('node:fs'); console.log(JSON.parse(fs.readFileSync(process.argv[1], 'utf8')).rating)" "$json_file")"
finding_count="$(node -e "const fs = require('node:fs'); console.log(JSON.parse(fs.readFileSync(process.argv[1], 'utf8')).findingCount)" "$json_file")"
echo "rating=$rating" >> "$GITHUB_OUTPUT"
echo "finding-count=$finding_count" >> "$GITHUB_OUTPUT"
fi

if [ "$fail_rank" -gt 0 ] && [ "$rating_rank" -ge "$fail_rank" ]; then
echo "::error::ScopeTrail permission drift rating $rating meets fail-on threshold $fail_on."
exit 1
if [ "$cli_status" -eq 1 ]; then
echo "::error::ScopeTrail permission drift rating ${rating:-unknown} meets fail-on threshold $fail_on."
fi
exit "$cli_status"
101 changes: 53 additions & 48 deletions dist/detectors/codex-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export async function detectCodexConfigDrift(oldRoot, newRoot) {
kind: 'scope_trail.codex_sandbox_widened',
severity: sandboxRank(newEntry.value) >= 3 ? 'critical' : 'high',
file: CODEX_CONFIG_FILE,
line: newEntry.line,
line: newEntry.line || undefined,
subject: key,
message: `Codex sandbox setting was widened to ${newEntry.value}.`,
recommendation: 'Keep Codex sandbox settings as narrow as the workflow allows and review full-access/elevated changes carefully.'
Expand All @@ -48,7 +48,7 @@ export async function detectCodexConfigDrift(oldRoot, newRoot) {
kind: 'scope_trail.codex_approval_weakened',
severity: newApproval.value === 'never' ? 'high' : 'medium',
file: CODEX_CONFIG_FILE,
line: newApproval.line,
line: newApproval.line || undefined,
subject: 'approval_policy',
message: `Codex approval policy was weakened to ${newApproval.value}.`,
recommendation: 'Require human approval for risky commands unless the repository has a reviewed reason to run without prompts.'
Expand All @@ -62,7 +62,7 @@ export async function detectCodexConfigDrift(oldRoot, newRoot) {
kind: 'scope_trail.codex_network_enabled',
severity: 'medium',
file: CODEX_CONFIG_FILE,
line: newEntry.line,
line: newEntry.line || undefined,
subject: key,
message: `Codex network access was enabled for ${key}.`,
recommendation: 'Confirm network access is needed and that commands cannot exfiltrate secrets or fetch unreviewed code.'
Expand Down Expand Up @@ -267,61 +267,66 @@ function isPlainObject(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
async function readCodexConfig(root) {
let text = '';
const text = await readCodexText(root);
if (!text) {
return new Map();
}
// Use the same parsed-TOML walk as readTrustedProjects so inline
// tables — `sandbox_workspace_write = { network_access = true }` and
// `windows = { sandbox = "danger-full-access" }` — surface their leaf
// keys. The previous line-regex parser stopped at `{` and silently
// returned rating: "none" for valid TOML that widened the sandbox.
let parsed;
try {
text = await readFile(configPath(root, CODEX_CONFIG_FILE), 'utf8');
parsed = parseToml(text);
}
catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
return new Map();
}
throw error;
catch {
// detectCodexConfigDrift already short-circuits on parse errors via
// readCodexParseError; reaching here with bad TOML shouldn't happen,
// and an empty map is the right fallback if it does.
return new Map();
}
return parseTomlEntries(text);
}
function parseTomlEntries(text) {
const entries = new Map();
let section = '';
const lines = text.split(/\r?\n/);
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
const sectionMatch = /^\[([^\]]+)\]$/.exec(trimmed);
if (sectionMatch) {
section = normalizeSection(sectionMatch[1]);
collectTomlEntries(parsed, '', text, entries);
return entries;
}
function collectTomlEntries(node, prefix, text, out) {
for (const [rawKey, value] of Object.entries(node)) {
const key = rawKey.toLowerCase();
const dotted = prefix ? `${prefix}.${key}` : key;
if (isPlainObject(value)) {
collectTomlEntries(value, dotted, text, out);
continue;
}
const keyMatch = /^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/.exec(trimmed);
if (!keyMatch) {
continue;
out.set(dotted, {
line: locateTomlLine(text, dotted),
value: stringifyScalar(value)
});
}
}
function locateTomlLine(text, dottedKey) {
// Inline tables defeat dotted-key line locators (they collapse to
// line 0). Walk up the prefix so we still point at the assignment
// line rather than dropping the locator entirely.
let current = dottedKey;
while (current) {
const line = lineOfTomlKey(text, current);
if (line > 0) {
return line;
}
const key = normalizeKey(section, keyMatch[1]);
const value = parseScalarValue(keyMatch[2]);
if (value !== undefined) {
entries.set(key, { line: index + 1, value });
const lastDot = current.lastIndexOf('.');
if (lastDot === -1) {
return 0;
}
current = current.slice(0, lastDot);
}
return entries;
}
function normalizeSection(section) {
const normalized = section.trim().toLowerCase();
return normalized.startsWith('projects.') ? 'projects' : normalized;
}
function normalizeKey(section, key) {
const normalizedKey = key.trim().toLowerCase();
return section ? `${section}.${normalizedKey}` : normalizedKey;
return 0;
}
function parseScalarValue(rawValue) {
const trimmed = rawValue.trim();
const stringMatch = /^"([^"]*)"/.exec(trimmed) ?? /^'([^']*)'/.exec(trimmed);
if (stringMatch) {
return stringMatch[1].toLowerCase();
}
const bareMatch = /^(true|false|[A-Za-z0-9_.-]+)/.exec(trimmed);
return bareMatch?.[1].toLowerCase();
function stringifyScalar(value) {
if (typeof value === 'string') {
return value.toLowerCase();
}
return String(value).toLowerCase();
}
function sandboxRank(value) {
if (!value) {
Expand Down
18 changes: 17 additions & 1 deletion dist/git-snapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,23 @@ async function snapshotPathsForRef(repo, ref) {
return [...paths].sort();
}
async function verifyGitRef(repo, ref) {
await execFileAsync('git', ['-C', repo, 'rev-parse', '--verify', `${ref}^{commit}`]);
try {
await execFileAsync('git', ['-C', repo, 'rev-parse', '--verify', `${ref}^{commit}`]);
}
catch (error) {
// Without wrapping, the raw `execFile` rejection escapes as a Node
// stack trace mentioning `git rev-parse --verify`. The most common
// CI cause is a shallow checkout (`fetch-depth: 1`) that doesn't
// include the PR base ref, so surface that hint up front.
throw new ScopeTrailError(`Could not resolve git ref "${ref}" in ${repo}. ` +
'If this is a CI run, ensure actions/checkout uses fetch-depth: 0 so the PR base and head are both available locally.', { cause: error });
}
}
export class ScopeTrailError extends Error {
constructor(message, options) {
super(message, options);
this.name = 'ScopeTrailError';
}
}
async function listPathsAtRef(repo, ref) {
const { stdout } = await execFileAsync('git', ['-C', repo, 'ls-tree', '-r', '--name-only', ref], {
Expand Down
49 changes: 35 additions & 14 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import { fileURLToPath } from 'node:url';
import { detectClaudeSettingsDrift } from './detectors/claude-settings.js';
import { detectCodexConfigDrift } from './detectors/codex-config.js';
import { detectMcpDrift } from './detectors/mcp.js';
import { materializeGitSnapshot } from './git-snapshot.js';
import { createReport, renderReport } from './report.js';
import { materializeGitSnapshot, ScopeTrailError } from './git-snapshot.js';
import { createReport, isDriftRating, meetsFailOnThreshold, renderReport } from './report.js';
export async function main(argv = process.argv.slice(2)) {
if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
process.stdout.write('Usage: scopetrail diff --old <dir> --new <dir> [--format text|markdown|json|github] [--out-markdown PATH] [--out-json PATH]\n');
process.stdout.write(`${usage()}\n`);
return 0;
}
if (argv[0] === 'diff') {
Expand All @@ -31,13 +31,22 @@ async function runDiff(argv) {
newRoot = parsed.newRoot;
}
else {
const baseSnapshot = await materializeGitSnapshot(parsed.repo, parsed.base);
const headSnapshot = await materializeGitSnapshot(parsed.repo, parsed.head);
oldRoot = baseSnapshot.root;
newRoot = headSnapshot.root;
cleanup = async () => {
await Promise.all([baseSnapshot.cleanup(), headSnapshot.cleanup()]);
};
try {
const baseSnapshot = await materializeGitSnapshot(parsed.repo, parsed.base);
const headSnapshot = await materializeGitSnapshot(parsed.repo, parsed.head);
oldRoot = baseSnapshot.root;
newRoot = headSnapshot.root;
cleanup = async () => {
await Promise.all([baseSnapshot.cleanup(), headSnapshot.cleanup()]);
};
}
catch (error) {
if (error instanceof ScopeTrailError) {
process.stderr.write(`${error.message}\n`);
return 2;
}
throw error;
}
}
try {
// Run all detectors once and render the resulting report into
Expand All @@ -57,6 +66,10 @@ async function runDiff(argv) {
await writeFile(parsed.outJson, renderReport(report, 'json'));
}
process.stdout.write(renderReport(report, parsed.format));
if (meetsFailOnThreshold(report.rating, parsed.failOn)) {
process.stderr.write(`ScopeTrail rating ${report.rating} meets --fail-on threshold ${parsed.failOn}.\n`);
return 1;
}
return 0;
}
finally {
Expand All @@ -72,6 +85,7 @@ function parseDiffArgs(argv) {
let format = 'text';
let outMarkdown;
let outJson;
let failOn = 'none';
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const value = argv[index + 1];
Expand Down Expand Up @@ -116,6 +130,13 @@ function parseDiffArgs(argv) {
outJson = value;
index += 1;
}
else if (arg === '--fail-on') {
if (!value || !isDriftRating(value)) {
return { ok: false, error: `Invalid --fail-on value: ${value ?? ''}. Use none, low, medium, high, or critical.` };
}
failOn = value;
index += 1;
}
else {
return { ok: false, error: `Unknown argument: ${arg}` };
}
Expand All @@ -132,15 +153,15 @@ function parseDiffArgs(argv) {
if (!head) {
return { ok: false, error: 'Missing required --head <ref> argument.' };
}
return { ok: true, mode: 'git', repo, base, head, format, outMarkdown, outJson };
return { ok: true, mode: 'git', repo, base, head, format, outMarkdown, outJson, failOn };
}
if (!oldRoot) {
return { ok: false, error: 'Missing required --old <dir> argument or --base <ref> argument.' };
}
if (!newRoot) {
return { ok: false, error: 'Missing required --new <dir> argument.' };
}
return { ok: true, mode: 'directories', oldRoot, newRoot, format, outMarkdown, outJson };
return { ok: true, mode: 'directories', oldRoot, newRoot, format, outMarkdown, outJson, failOn };
}
function isReportFormat(value) {
return value === 'text' || value === 'markdown' || value === 'json' || value === 'github';
Expand All @@ -152,7 +173,7 @@ if (invokedPath) {
function usage() {
return [
'Usage:',
' scopetrail diff --old <dir> --new <dir> [--format text|markdown|json|github] [--out-markdown PATH] [--out-json PATH]',
' scopetrail diff --repo <repo> --base <ref> --head <ref> [--format text|markdown|json|github] [--out-markdown PATH] [--out-json PATH]'
' scopetrail diff --old <dir> --new <dir> [--format text|markdown|json|github] [--out-markdown PATH] [--out-json PATH] [--fail-on none|low|medium|high|critical]',
' scopetrail diff --repo <repo> --base <ref> --head <ref> [--format text|markdown|json|github] [--out-markdown PATH] [--out-json PATH] [--fail-on none|low|medium|high|critical]'
].join('\n');
}
Loading