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
17 changes: 17 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1 +1,18 @@
* text=auto

# Pin source and generated text files to LF so Windows checkouts under
# core.autocrlf=true don't oscillate between LF and CRLF.
*.js text eol=lf
*.mjs text eol=lf
*.cjs text eol=lf
*.ts text eol=lf
*.json text eol=lf
*.jsonc text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.toml text eol=lf
*.md text eol=lf
*.sh text eol=lf
.gitattributes text eol=lf
.gitignore text eol=lf
LICENSE text eol=lf
48 changes: 48 additions & 0 deletions .github/ISSUE_TEMPLATE/team-adoption.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Team adoption signal
description: Share rollout pain when ScopeTrail's gap is about ownership, reporting, exceptions, or many repositories.
title: "[team adoption]: "
labels: ["adoption", "team-signal"]
body:
- type: textarea
id: pain
attributes:
label: Rollout pain
description: Describe the team-level gap. ScopeTrail is intentionally small at v0, so signal beats prescription here.
validations:
required: true
- type: dropdown
id: theme
attributes:
label: Primary theme
description: Which adoption gap does this fall under?
options:
- Ownership (who triages findings?)
- Reporting (rollup across repos / over time)
- Exceptions (allowlists, suppressions, justified drift)
- Many repositories (scale of rollout)
- Other
validations:
required: true
- type: dropdown
id: scope
attributes:
label: Affected scope
description: Help prioritize whether this matters for one repo or repeated team workflows.
options:
- One repository
- Multiple repositories
- Organization-wide pattern
- Not sure yet
validations:
required: true
- type: input
id: repository-count
attributes:
label: Approximate repository count
description: Optional estimate if this affects more than one repository.
placeholder: "Example: 1, 5, 20+"
- type: textarea
id: current-workaround
attributes:
label: Current workaround
description: How is the team handling this today? (Manual review, spreadsheet, a different tool, nothing yet, etc.)
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
node_modules/

# Local codex working state — not for tracking
.codex/
# 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.
/.codex/
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ ScopeTrail v0 detects:
- Removed Claude Code deny rules for sensitive files such as `.env`.
- Claude Code hook changes: **removed**, **added**, and **command-changed** (a strict `PreToolUse` swapped for a no-op script is the same risk as a removal — both are now caught).
- Codex config drift such as full-access/elevated sandboxes, weakened approval policy, enabled network access, or trusted project settings.
- Codex `[mcp_servers.NAME]` additions, launch-command changes, and unpinned commands (the same risk model as `.mcp.json` MCP detection, applied to TOML).

The git-mode snapshot list is derived from the detectors themselves, so adding a new surface in one place can never leave the GitHub Action silently blind to it. A regression test fails the build if a detector's target paths aren't covered.

Expand Down
17 changes: 14 additions & 3 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,20 @@ runs:
report_file="${RUNNER_TEMP:-.}/scopetrail-report.md"
json_file="${RUNNER_TEMP:-.}/scopetrail-report.json"

node "$GITHUB_ACTION_PATH/dist/index.js" diff --repo "$repo" --base "$base" --head "$head" --format markdown | tee "$report_file"
node "$GITHUB_ACTION_PATH/dist/index.js" diff --repo "$repo" --base "$base" --head "$head" --format json > "$json_file"
node "$GITHUB_ACTION_PATH/dist/index.js" diff --repo "$repo" --base "$base" --head "$head" --format github
# 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.
node "$GITHUB_ACTION_PATH/dist/index.js" diff \
--repo "$repo" --base "$base" --head "$head" \
--format github \
--out-markdown "$report_file" \
--out-json "$json_file"

# 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"
Expand Down
42 changes: 32 additions & 10 deletions dist/detectors/claude-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,18 +42,22 @@ export async function detectClaudeSettingsDrift(oldRoot, newRoot) {
});
continue;
}
// Swapping a strict guard for a no-op script is just as material as
// removing the hook outright — and the previous detector missed it.
// Any drift in the command set is material — swapping a strict
// guard for a no-op, dropping one guard out of a multi-guard hook,
// or appending a no-op alongside the strict guard. The previous
// check required `newCommands.size === oldCommands.size`, which
// missed adds and drops that changed the count.
const newCommands = newSettings.hookCommands.get(hookName) ?? new Set();
const changed = [...newCommands].filter((command) => !oldCommands.has(command));
if (changed.length > 0 && newCommands.size === oldCommands.size) {
const added = [...newCommands].filter((command) => !oldCommands.has(command));
const removed = [...oldCommands].filter((command) => !newCommands.has(command));
if (added.length > 0 || removed.length > 0) {
findings.push({
kind: 'scope_trail.hook_command_changed',
severity: isHighImpactHook(hookName) ? 'high' : 'medium',
file: CLAUDE_SETTINGS_FILE,
subject: hookName,
message: `Claude hook "${hookName}" command(s) changed: ${changed.join(', ')}.`,
recommendation: 'Review the new command — a weakened guard (e.g., a no-op script) is the same risk as a removed hook.'
message: hookCommandChangeMessage(hookName, added, removed),
recommendation: 'Review the change — a removed guard, a no-op appended next to a strict one, or any rewrite of a hook command can all weaken policy.'
});
}
}
Expand Down Expand Up @@ -140,15 +144,23 @@ function hookHasEntries(value) {
// its grants properly. Bare tokens and explicit wildcards are still broad.
export function isBroadAllow(permission) {
const normalized = permission.toLowerCase();
if (/\bbash\([^)]*\*[^)]*\)/.test(normalized)) {
// Bare verb (no parentheses) or wildcard-scoped verb for any of the
// dangerous operations. This catches `"Bash"`, `"Read"`, `"Write"`,
// `"Edit"`, `"WebFetch"`, etc. — bare tokens that grant unlimited
// access. Previously only WebFetch/WebSearch/Task went through this
// check, which silently let `"Bash"` and bare `"Read"`/`"Write"`/
// `"Edit"` slip past unflagged.
if (isBroadVerbGrant(normalized, ['bash', 'read', 'write', 'edit', 'webfetch', 'websearch', 'task'])) {
return true;
}
// Scoped grants whose target is a rooted path (absolute, home-rel,
// or Windows drive). `Read(/etc/passwd)` doesn't include `*` but is
// still broader than a workspace-relative path. Stays separate from
// the verb-grant check because that path-shape rule only applies to
// file-access verbs, not network/task verbs.
if (/\b(read|write|edit)\((~|[a-z]:\\|\/|\*\*)/.test(normalized)) {
return true;
}
if (isBroadVerbGrant(normalized, ['webfetch', 'websearch', 'task'])) {
return true;
}
if (isBroadMcpGrant(normalized)) {
return true;
}
Expand Down Expand Up @@ -208,3 +220,13 @@ function severityForRemovedDeny(permission) {
function isHighImpactHook(hookName) {
return ['pretooluse', 'posttooluse', 'permissionrequest', 'sessionend'].includes(hookName.toLowerCase());
}
function hookCommandChangeMessage(hookName, added, removed) {
const parts = [];
if (added.length > 0) {
parts.push(`added: ${added.join(', ')}`);
}
if (removed.length > 0) {
parts.push(`removed: ${removed.join(', ')}`);
}
return `Claude hook "${hookName}" command(s) changed (${parts.join('; ')}).`;
}
108 changes: 108 additions & 0 deletions dist/detectors/codex-config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { readFile } from 'node:fs/promises';
import { lineOfTomlKey, parseToml } from 'agent-gov-core';
import { configPath } from '../discovery.js';
import { isUnpinnedCommand, serverCommand } from '../mcp-risk.js';
export const CODEX_CONFIG_FILE = '.codex/config.toml';
export const CODEX_TARGET_PATHS = [CODEX_CONFIG_FILE];
export async function detectCodexConfigDrift(oldRoot, newRoot) {
Expand Down Expand Up @@ -62,8 +64,114 @@ export async function detectCodexConfigDrift(oldRoot, newRoot) {
recommendation: 'Only mark projects trusted when repository instructions, hooks, and tool permissions are reviewed.'
});
}
for (const finding of await detectCodexMcpDrift(oldRoot, newRoot)) {
findings.push(finding);
}
return findings;
}
// Codex `.codex/config.toml` carries the same `[mcp_servers.NAME]`
// shape that ScopeTrail already flags in `.mcp.json` — without this
// detector, a Codex user can add `[mcp_servers.stripe-admin]` with
// `args = ["-y", "@vendor/stripe-mcp@latest"]` and the unpinned MCP
// risk model never sees it.
async function detectCodexMcpDrift(oldRoot, newRoot) {
const findings = [];
const oldServers = await readCodexMcpServers(oldRoot);
const newServers = await readCodexMcpServers(newRoot);
for (const [name, newServer] of newServers) {
const oldServer = oldServers.get(name);
const commandChanged = oldServer && serverCommand(newServer) !== serverCommand(oldServer);
if (!oldServer) {
findings.push({
kind: 'scope_trail.codex_mcp_server_added',
severity: 'high',
file: CODEX_CONFIG_FILE,
line: lineForServer(newServer),
subject: name,
message: `Codex MCP server "${name}" was added.`,
recommendation: 'Review the server package, pin its version, and confirm the tools it exposes before merging.'
});
}
else if (commandChanged) {
findings.push({
kind: 'scope_trail.codex_mcp_server_command_changed',
severity: 'medium',
file: CODEX_CONFIG_FILE,
line: lineForServer(newServer),
subject: name,
message: `Codex MCP server "${name}" changed its launch command.`,
recommendation: 'Confirm the command change is intentional and still points at a trusted, pinned package.'
});
}
if ((!oldServer || commandChanged) && isUnpinnedCommand(newServer)) {
findings.push({
kind: 'scope_trail.codex_unpinned_mcp_command',
severity: 'high',
file: CODEX_CONFIG_FILE,
line: lineForServer(newServer),
subject: name,
message: `Codex MCP server "${name}" uses an unpinned command: ${serverCommand(newServer)}.`,
recommendation: 'Pin executable packages to an exact version and avoid pipe-to-shell installation commands.'
});
}
}
return findings;
}
async function readCodexMcpServers(root) {
const path = configPath(root, CODEX_CONFIG_FILE);
let text = '';
try {
text = await readFile(path, 'utf8');
}
catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
return new Map();
}
throw error;
}
let parsed;
try {
parsed = parseToml(text);
}
catch {
return new Map();
}
const rawServers = parsed.mcp_servers;
if (!isPlainObject(rawServers)) {
return new Map();
}
const servers = new Map();
for (const [name, entry] of Object.entries(rawServers)) {
if (!isPlainObject(entry)) {
continue;
}
servers.set(name, {
name,
text,
command: typeof entry.command === 'string' ? entry.command : undefined,
args: Array.isArray(entry.args)
? entry.args.filter((arg) => typeof arg === 'string')
: undefined,
url: typeof entry.url === 'string' ? entry.url : undefined
});
}
return servers;
}
function lineForServer(server) {
// Point at the leaf the reviewer most needs to see — `command`
// first, then any of the args/url keys. Fall back to file-level
// when nothing matches so the finding still surfaces.
for (const leaf of ['command', 'args', 'url']) {
const line = lineOfTomlKey(server.text, `mcp_servers.${server.name}.${leaf}`);
if (line) {
return line;
}
}
return undefined;
}
function isPlainObject(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
async function readCodexConfig(root) {
let text = '';
try {
Expand Down
Loading