Merge remote-tracking branch 'upstream/main' into codex/docs-governance-audit-foundation

This commit is contained in:
Seekers2001
2026-09-22 17:32:07 +08:00
11 changed files with 677 additions and 35 deletions
+1
View File
@@ -20,3 +20,4 @@ bash ./install.sh --target adal --profile minimal
- The `adal` target installs into the project-level `./.adal/` directory.
- AdaL's own config (`~/.adal/settings.json`, MCP servers, plugins) is **not** touched by ECC install.
- Use `npx ecc-universal doctor --target adal` to check install health.
- use an installed
+25
View File
@@ -173,6 +173,31 @@ For Claude Code plugin setup, updates, scope changes, and hook-profile changes:
npx ecc-universal@2.2.2 setup
```
#### Windows first-time walkthrough
If you are new to command-line tools, use this copy-and-paste path:
1. Install Node.js 18 or newer, Git, and Claude Code.
2. Open **PowerShell** from the Windows Start menu.
3. Confirm that each prerequisite is available:
```powershell
node --version
git --version
claude --version
```
4. Run the guided installer:
```powershell
npx ecc-universal@2.2.2 setup
```
5. For a typical personal setup, choose **Global user**, choose **Standard** hooks, and confirm.
6. Start a new Claude Code session and run `/plugin list` to verify that `ecc@ecc` is enabled.
This path does not require cloning the repository. If any prerequisite command is not found, install or repair that prerequisite before rerunning ECC setup.
If npm reports a version or cache error, confirm the registry version before retrying:
```bash
+204 -7
View File
@@ -359,6 +359,155 @@ function quoteAwareSegments(input) {
const SHELL_WRAPPERS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh']);
/**
* SQL clients whose `-c`/`-e`/positional arguments carry SQL statements.
* Quoted SQL (e.g. `psql -c "drop table users"`) is invisible to the
* quote-stripping SQL regex, so it is re-checked here against dequoted
* tokens where quoted content is preserved (issue #3024). Restricted to
* known clients so `git commit -m "drop table"` and `echo "drop table"`
* stay allowed.
*/
const SQL_CLIENT_COMMANDS = new Set([
'psql',
'postgres',
'mysql',
'mariadb',
'sqlite3',
'sqlite',
'sqlcmd',
'isql',
'pgcli',
'mycli',
'duckdb',
'bq',
]);
/**
* Strip SQL string literals so phrases inside query data do not trigger
* the destructive detector (e.g. `SELECT 'drop table' ...` is a read).
* Handles single-quoted literals with '' escapes, double-quoted
* identifiers, and dollar-quoted blocks ($$...$$ and $tag$...$tag$).
*
* @param {string} input
* @returns {string}
*/
function stripSqlLiterals(input) {
return String(input || '')
.replace(/'(?:[^']|'')*'/g, "''")
.replace(/"(?:[^"\\]|\\.)*"/g, '""')
.replace(/(\$[A-Za-z_][A-Za-z0-9_]*\$|\$\$)[\s\S]*?\1/g, '$$$$');
}
const SUDO_VALUE_FLAGS = new Set([
'-u',
'--user',
'-g',
'--group',
'-U',
'--other-user',
'-p',
'--prompt',
'-C',
'--close-from',
'-D',
'--chdir',
'-h',
'--host',
'-r',
'--role',
'-t',
'--type',
'-T',
'--command-timeout',
]);
/**
* Advance past `sudo`/`doas`/`env` wrappers including their flags and
* `VAR=value` assignments, so `sudo -u postgres psql ...` and
* `env PGUSER=postgres psql ...` still resolve to the real command.
*
* @param {string[]} tokens dequoted tokens for one segment
* @returns {number} index of the real command token
*/
function unwrapLeadWrappers(tokens) {
let index = 0;
for (let guard = 0; guard < 4; guard += 1) {
if (index >= tokens.length) return index;
const base = commandBasename(tokens[index]);
if (base === 'sudo' || base === 'doas') {
index += 1;
while (index < tokens.length) {
const flag = tokens[index];
if (flag === '--') {
index += 1;
break;
}
if (flag === '-' || !flag.startsWith('-')) break;
if (SUDO_VALUE_FLAGS.has(flag)) {
index += 2;
continue;
}
if (/^--[^=]+=.*$/.test(flag)) {
index += 1;
continue;
}
index += 1;
}
continue;
}
if (base === 'env') {
index += 1;
while (index < tokens.length) {
const arg = tokens[index];
if (arg === '--' || arg === '-' || arg === '-i' || arg === '--ignore-environment') {
index += 1;
continue;
}
if (arg === '-u' || arg === '--unset') {
index += 2;
continue;
}
if (arg === '-C' || arg === '--chdir') {
index += 2;
continue;
}
if (/^--unset=.*$/.test(arg) || /^--chdir=.*$/.test(arg) || /^--argv0=.*$/.test(arg)) {
index += 1;
continue;
}
if (arg.startsWith('-') && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(arg)) {
index += 1;
continue;
}
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(arg)) {
index += 1;
continue;
}
break;
}
continue;
}
break;
}
return index;
}
/**
* Detect destructive SQL passed as (possibly quoted) arguments to a known
* SQL client. Operates on dequoted tokens from `quoteAwareSegments`, so
* `psql -c "drop table users"` joins back to matchable text.
*
* @param {string[]} tokens dequoted tokens for one segment
* @returns {boolean}
*/
function isDestructiveSqlClient(tokens) {
if (!tokens || tokens.length === 0) return false;
const start = unwrapLeadWrappers(tokens);
if (start >= tokens.length) return false;
if (!SQL_CLIENT_COMMANDS.has(commandBasename(tokens[start]))) return false;
return DESTRUCTIVE_SQL_DD.test(stripSqlLiterals(tokens.slice(start).join(' ')));
}
/**
* Quote-aware destructive check: catches quoted command words, newline
* separators, quoted `find -exec`, and `sh -c`/`bash -c` wrappers that evade
@@ -374,10 +523,12 @@ function isDestructiveQuoteAware(raw, depth = 0) {
if (tokens.length === 0) continue;
if (isDestructiveRm(tokens)) return true;
if (isDestructiveGit(tokens)) return true;
if (isDestructiveSqlClient(tokens)) return true;
if (isDestructiveFindExec(tokens.join(' '))) return true;
const base = commandBasename(tokens[0]);
const wi = unwrapLeadWrappers(tokens);
const base = wi < tokens.length ? commandBasename(tokens[wi]) : '';
if (SHELL_WRAPPERS.has(base)) {
const ci = tokens.indexOf('-c');
const ci = tokens.indexOf('-c', wi);
if (ci !== -1 && tokens[ci + 1] && isDestructiveQuoteAware(tokens[ci + 1], depth + 1)) {
return true;
}
@@ -1111,16 +1262,62 @@ function isChecked(key) {
// --- Sanitize file path against injection ---
// Unicode policy for sanitizePath, mirroring the repo-wide dangerous set in
// scripts/ci/check-unicode-safety.js. Named so the ranges stay auditable and
// drift against the CI policy is visible in one place.
const ASCII_CONTROL_MAX = 0x1f;
const ASCII_DELETE = 0x7f;
const C1_CONTROLS = [0x80, 0x9f]; // Unicode C1 control block (U+0080..U+009F)
const BIDI_MARKS = [0x200e, 0x200f]; // LRM/RLM
const BIDI_EMBEDDINGS = [0x202a, 0x202e]; // LRE..PDF
const BIDI_ISOLATES = [0x2066, 0x2069]; // LRI..PDI
const ZERO_WIDTHS = [0x200b, 0x200d]; // ZWSP..ZWJ
const WORD_JOINER = 0x2060;
const BYTE_ORDER_MARK = 0xfeff;
const VARIATION_SELECTORS = [0xfe00, 0xfe0f];
const VARIATION_SUPPLEMENTS = [0xe0100, 0xe01ef]; // MONGOLIAN..TAGS (VS17..VS256)
const TAG_BLOCK = [0xe0000, 0xe007f]; // ASCII-smuggling tag characters
const MONGOLIAN_VOWEL_SEPARATOR = 0x180e;
const HANGUL_CHOSEONG_FILLER = 0x115f;
const HANGUL_JUNGSEONG_FILLER = 0x1160;
const HANGUL_FILLER = 0x3164;
const INVISIBLE_MATH_OPERATORS = [0x2061, 0x2064]; // FUNCTION APPLICATION..INVISIBLE PLUS
const LINE_SEPARATOR = 0x2028;
const PARAGRAPH_SEPARATOR = 0x2029;
const SANITIZED_PATH_MAX_LENGTH = 500;
function inRange(code, [lo, hi]) {
return code >= lo && code <= hi;
}
function sanitizePath(filePath) {
// Strip control chars (including null), bidi overrides, and newlines
// Strip control chars (including null), bidi overrides, separators,
// and the dangerous invisible characters defined by the constants
// above (mirroring scripts/ci/check-unicode-safety.js), so a denial
// message cannot carry content a human reviewer cannot see.
let sanitized = '';
for (const char of String(filePath || '')) {
const code = char.codePointAt(0);
const isAsciiControl = code <= 0x1f || code === 0x7f;
const isBidiOverride = (code >= 0x200e && code <= 0x200f) || (code >= 0x202a && code <= 0x202e) || (code >= 0x2066 && code <= 0x2069);
sanitized += isAsciiControl || isBidiOverride ? ' ' : char;
const isAsciiControl =
code <= ASCII_CONTROL_MAX || code === ASCII_DELETE || inRange(code, C1_CONTROLS);
const isBidiOverride =
inRange(code, BIDI_MARKS) || inRange(code, BIDI_EMBEDDINGS) || inRange(code, BIDI_ISOLATES);
const isUnicodeSeparator = code === LINE_SEPARATOR || code === PARAGRAPH_SEPARATOR;
const isDangerousInvisible =
inRange(code, ZERO_WIDTHS) ||
code === WORD_JOINER ||
code === BYTE_ORDER_MARK ||
inRange(code, VARIATION_SELECTORS) ||
inRange(code, VARIATION_SUPPLEMENTS) ||
inRange(code, TAG_BLOCK) ||
code === MONGOLIAN_VOWEL_SEPARATOR ||
code === HANGUL_CHOSEONG_FILLER ||
code === HANGUL_JUNGSEONG_FILLER ||
code === HANGUL_FILLER ||
inRange(code, INVISIBLE_MATH_OPERATORS);
sanitized += isAsciiControl || isBidiOverride || isUnicodeSeparator || isDangerousInvisible ? ' ' : char;
}
return sanitized.trim().slice(0, 500);
return sanitized.trim().slice(0, SANITIZED_PATH_MAX_LENGTH);
}
function normalizeForMatch(value) {
+3 -3
View File
@@ -99,7 +99,7 @@ function findFileIssues(filePath) {
const lineNum = index + 1;
// Check for console.log
if (line.includes('console.log') && !line.trim().startsWith('//') && !line.trim().startsWith('*')) {
if (line.includes('console.log') && !line.trim().startsWith('//') && !line.trim().startsWith('*') && !line.trim().startsWith('#')) {
issues.push({
type: 'console.log',
message: `console.log found at line ${lineNum}`,
@@ -109,7 +109,7 @@ function findFileIssues(filePath) {
}
// Check for debugger statements
if (/\bdebugger\b/.test(line) && !line.trim().startsWith('//')) {
if (/\bdebugger\b/.test(line) && !line.trim().startsWith('//') && !line.trim().startsWith('#')) {
issues.push({
type: 'debugger',
message: `debugger statement at line ${lineNum}`,
@@ -119,7 +119,7 @@ function findFileIssues(filePath) {
}
// Check for TODO/FIXME without issue reference
const todoMatch = line.match(/\/\/\s*(TODO|FIXME):?\s*(.+)/);
const todoMatch = line.match(/(?:\/\/|#)\s*(TODO|FIXME):?\s*(\S.*)/);
if (todoMatch && !todoMatch[2].match(/#\d+|issue/i)) {
issues.push({
type: 'todo',
-3
View File
@@ -570,7 +570,6 @@ function verifyPluginAtScope(options) {
function ensurePluginAtScope(options) {
const run = options.run || runClaude;
const configuredHooks = options.hookConfiguration || hookOptions(options.hooks);
if (options.installed) {
run(
['plugin', 'update', CURRENT_PLUGIN_ID, '--scope', options.scope],
@@ -582,8 +581,6 @@ function ensurePluginAtScope(options) {
[
'plugin', 'install', CURRENT_PLUGIN_ID,
'--scope', options.scope,
'--config', `hooks_enabled=${configuredHooks.hooks_enabled}`,
'--config', `hook_profile=${configuredHooks.hook_profile}`,
],
{ cwd: options.projectRoot, phase: 'plugin-install' }
);
+20 -5
View File
@@ -136,7 +136,7 @@ cat > "$TEMP_SCRIPT" << 'EXPORT_SCRIPT'
import { chromium } from 'playwright';
import { createServer } from 'http';
import { readFileSync, existsSync, mkdirSync, unlinkSync, writeFileSync } from 'fs';
import { join, extname, resolve } from 'path';
import { join, extname, resolve, sep } from 'path';
import { execSync } from 'child_process';
const SERVE_DIR = process.argv[2];
@@ -166,10 +166,25 @@ const MIME_TYPES = {
'.eot': 'application/vnd.ms-fontobject',
};
// Every request is confined to the deck directory: resolve the decoded path
// against SERVE_ROOT and refuse anything that escapes it (../, %2e%2e, %2f).
const SERVE_ROOT = resolve(SERVE_DIR);
const server = createServer((req, res) => {
// Decode URL-encoded characters (e.g., %20 -> space) so filenames with spaces resolve correctly
const decodedUrl = decodeURIComponent(req.url);
let filePath = join(SERVE_DIR, decodedUrl === '/' ? HTML_FILE : decodedUrl);
let decodedUrl;
try {
decodedUrl = decodeURIComponent((req.url || '/').split('?')[0]);
} catch {
res.writeHead(400);
res.end('Bad request');
return;
}
const filePath = resolve(SERVE_ROOT, '.' + (decodedUrl === '/' ? '/' + HTML_FILE : decodedUrl));
if (filePath !== SERVE_ROOT && !filePath.startsWith(SERVE_ROOT + sep)) {
res.writeHead(403);
res.end('Forbidden');
return;
}
try {
const content = readFileSync(filePath);
const ext = extname(filePath).toLowerCase();
@@ -181,9 +196,9 @@ const server = createServer((req, res) => {
}
});
// Find a free port
// Find a free port on loopback only; the deck is rendered by the local headless browser
const port = await new Promise((resolve) => {
server.listen(0, () => resolve(server.address().port));
server.listen(0, '127.0.0.1', () => resolve(server.address().port));
});
console.log(` Local server on port ${port}`);
+79 -9
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, replace
from scripts.classifier import classify_events
from scripts.parser import ComplianceSpec, ObservationEvent, Step
@@ -30,23 +30,38 @@ def _check_temporal_order(
event: ObservationEvent,
resolved: dict[str, list[ObservationEvent]],
classified: dict[str, list[ObservationEvent]],
graded: set[str],
) -> str | None:
"""Check before_step/after_step constraints. Returns failure reason or None."""
if step.detector.after_step is not None:
after_events = resolved.get(step.detector.after_step)
after_step = step.detector.after_step
after_events = resolved.get(after_step)
if after_events is None:
after_events = classified.get(step.detector.after_step, [])
if after_step in graded:
# Graded and missing from `resolved` means it failed its own checks.
# Its classified events are not evidence for anything: reusing them
# here let a dependant pass on the strength of a failed prerequisite,
# and the overstatement carried down the whole chain.
return f"after_step '{after_step}' did not pass its own checks"
# Not graded yet, so this is a forward reference to a step declared later.
# The classifier's own output is the only thing available, and using it is
# what makes an out-of-order declaration work.
after_events = classified.get(after_step, [])
if not after_events:
return f"after_step '{step.detector.after_step}' not yet detected"
return f"after_step '{after_step}' not yet detected"
latest_after = max(e.timestamp for e in after_events)
if event.timestamp <= latest_after:
return (
f"must occur after '{step.detector.after_step}' "
f"must occur after '{after_step}' "
f"(last at {latest_after}), but found at {event.timestamp}"
)
if step.detector.before_step is not None:
# Look ahead using LLM classification results
# Look ahead using LLM classification results. A failed reference step is NOT
# excluded here the way it is above: the two fall in opposite directions. An
# `after_step` fallback can only turn a failure into a pass, while a
# `before_step` one can only turn a pass into a failure, so dropping it would
# relax a constraint because some other step failed.
before_events = resolved.get(step.detector.before_step)
if before_events is None:
before_events = classified.get(step.detector.before_step, [])
@@ -61,6 +76,46 @@ def _check_temporal_order(
return None
def _demote_steps_resting_on_failures(
step_results: tuple[StepResult, ...],
after_steps: dict[str, str],
) -> tuple[StepResult, ...]:
"""Undo passes that rest on an `after_step` which ended up failing.
A step declared before its prerequisite is graded against the classifier's raw
events for that step, because `resolved` has nothing for it yet. That fallback is
what makes an out-of-order declaration work, but the prerequisite can go on to
fail its own checks, and then the dependant is left passing on evidence that
never held. Repeat until nothing changes: demoting one step can invalidate
whatever depended on it, whichever order the two were declared in. Demotion only
ever removes passes, so the loop terminates.
"""
results = step_results
while True:
failed = {result.step_id for result in results if not result.detected}
demote = {
result.step_id
for result in results
if result.detected and after_steps.get(result.step_id) in failed
}
if not demote:
return results
results = tuple(
replace(
result,
detected=False,
evidence=(),
failure_reason=(
f"after_step '{after_steps[result.step_id]}' "
"did not pass its own checks"
),
)
if result.step_id in demote
else result
for result in results
)
def grade(
spec: ComplianceSpec,
trace: list[ObservationEvent],
@@ -80,6 +135,9 @@ def grade(
# Step 2: Check temporal ordering (deterministic)
resolved: dict[str, list[ObservationEvent]] = {}
# Steps already graded, pass or fail. `resolved` alone cannot tell "failed" from
# "not reached yet", and those need opposite answers in `_check_temporal_order`.
graded: set[str] = set()
step_results: list[StepResult] = []
for step in spec.steps:
@@ -88,7 +146,7 @@ def grade(
failure_reason: str | None = None
for event in candidates:
temporal_fail = _check_temporal_order(step, event, resolved, classified)
temporal_fail = _check_temporal_order(step, event, resolved, classified, graded)
if temporal_fail is None:
matched.append(event)
break
@@ -101,6 +159,7 @@ def grade(
elif failure_reason is None:
failure_reason = f"no matching event classified for step '{step.id}'"
graded = graded | {step.id}
step_results.append(StepResult(
step_id=step.id,
detected=detected,
@@ -108,8 +167,19 @@ def grade(
failure_reason=failure_reason if not detected else None,
))
# `graded` catches a prerequisite that had already failed when its dependant was
# graded. The other direction needs a second pass: a dependant declared first is
# graded against the classifier's raw events for a prerequisite that has not run
# yet, and only later does that prerequisite fail.
after_steps = {
step.id: step.detector.after_step
for step in spec.steps
if step.detector.after_step is not None
}
resolved_results = _demote_steps_resting_on_failures(tuple(step_results), after_steps)
required_ids = {s.id for s in spec.steps if s.required}
required_steps = [s for s in step_results if s.step_id in required_ids]
required_steps = [s for s in resolved_results if s.step_id in required_ids]
detected_required = sum(1 for s in required_steps if s.detected)
total_required = len(required_steps)
@@ -117,7 +187,7 @@ def grade(
return ComplianceResult(
spec_id=spec.id,
steps=tuple(step_results),
steps=resolved_results,
compliance_rate=compliance_rate,
recommend_hook_promotion=compliance_rate < spec.threshold_promote_to_hook,
classification=classification,
+247 -2
View File
@@ -1,7 +1,7 @@
"""Tests for grader module — compliance scoring with LLM classification."""
from pathlib import Path
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import pytest
@@ -137,7 +137,7 @@ class TestGradeEdgeCases:
assert result.spec_id == "tdd-workflow"
@patch("scripts.grader.classify_events")
def test_after_step_can_reference_later_declared_spec_step(self, mock_cls) -> None:
def test_after_step_can_reference_later_declared_spec_step(self, mock_cls: MagicMock) -> None:
spec = ComplianceSpec(
id="out-of-order-after-step",
name="Out of order after_step",
@@ -195,3 +195,248 @@ class TestGradeEdgeCases:
assert step_a.failure_reason is None
assert step_b.detected is True
assert result.compliance_rate == 1.0
@patch("scripts.grader.classify_events")
def test_after_step_does_not_reuse_a_step_that_failed_its_own_constraint(
self, mock_cls: MagicMock
) -> None:
"""A step that failed may not supply evidence to a step that depends on it (#3108).
`resolved` only receives a step once it passes, so a dependant fell back to the
raw classifier output and could pass on an event belonging to a failed parent.
The fallback exists for forward references, which the case above covers; a
parent that has already been graded and failed is a different thing.
"""
spec = ComplianceSpec(
id="failed-parent-evidence",
name="Failed parent evidence",
source_rule="rules/common/testing.md",
version="1.0",
steps=(
Step(
id="C",
description="Reference step with no constraint of its own",
required=True,
detector=Detector(description="Event C"),
),
Step(
id="A",
description="Must occur before C, and does not",
required=True,
detector=Detector(description="Event A", before_step="C"),
),
Step(
id="B",
description="Depends on A",
required=True,
detector=Detector(description="Event B", after_step="A"),
),
),
threshold_promote_to_hook=0.5,
)
trace = [
ObservationEvent(
timestamp=f"2026-03-20T10:00:0{index}Z",
event="tool_complete",
tool="Write",
session="sess-evidence",
input=f'{{"file_path":"src/{name}.py"}}',
output=f"step {name}",
)
for index, name in enumerate(("c", "a", "b"))
]
mock_cls.return_value = {"C": [0], "A": [1], "B": [2]}
result = grade(spec, trace)
detected = {step.step_id: step.detected for step in result.steps}
assert detected["C"] is True
assert detected["A"] is False
assert detected["B"] is False
assert result.compliance_rate == pytest.approx(1 / 3)
step_b = next(step for step in result.steps if step.step_id == "B")
assert "A" in (step_b.failure_reason or "")
@patch("scripts.grader.classify_events")
def test_a_failed_prerequisite_does_not_carry_a_chain_of_passes(
self, mock_cls: MagicMock
) -> None:
"""The overstatement compounds: B on A, C on B, D on C (#3108).
Each link used to pass on the classifier's raw output for the link above it, so
one failed prerequisite could leave a four-step workflow reading 3/4 compliant.
"""
steps = (
Step(
id="Z",
description="Reference step with no constraint of its own",
required=True,
detector=Detector(description="Event Z"),
),
Step(
id="A",
description="Must occur before Z, and does not",
required=True,
detector=Detector(description="Event A", before_step="Z"),
),
*(
Step(
id=later,
description=f"Depends on {earlier}",
required=True,
detector=Detector(description=f"Event {later}", after_step=earlier),
)
for earlier, later in (("A", "B"), ("B", "C"), ("C", "D"))
),
)
spec = ComplianceSpec(
id="failed-prerequisite-chain",
name="Failed prerequisite chain",
source_rule="rules/common/testing.md",
version="1.0",
steps=steps,
threshold_promote_to_hook=0.5,
)
names = ("z", "a", "b", "c", "d")
trace = [
ObservationEvent(
timestamp=f"2026-03-20T10:00:0{index}Z",
event="tool_complete",
tool="Write",
session="sess-chain",
input=f'{{"file_path":"src/{name}.py"}}',
output=f"step {name}",
)
for index, name in enumerate(names)
]
mock_cls.return_value = {"Z": [0], "A": [1], "B": [2], "C": [3], "D": [4]}
result = grade(spec, trace)
detected = {step.step_id: step.detected for step in result.steps}
assert detected == {"Z": True, "A": False, "B": False, "C": False, "D": False}
assert result.compliance_rate == pytest.approx(1 / 5)
@patch("scripts.grader.classify_events")
def test_forward_reference_is_revoked_when_the_prerequisite_later_fails(
self, mock_cls: MagicMock
) -> None:
"""The mirror image of the case above, raised in review of #3109.
`graded` only catches a prerequisite that had already failed. A step declared
*before* its prerequisite is graded against the classifier's raw events for a
step that has not run yet — the fallback that makes an out-of-order
declaration work — and nothing revisited it once that step failed.
"""
spec = ComplianceSpec(
id="forward-reference-revoked",
name="Forward reference revoked",
source_rule="rules/common/testing.md",
version="1.0",
steps=(
Step(
id="Z",
description="Reference step with no constraint of its own",
required=True,
detector=Detector(description="Event Z"),
),
Step(
id="B",
description="Depends on A, which is declared after it",
required=True,
detector=Detector(description="Event B", after_step="A"),
),
Step(
id="A",
description="Must occur before Z, and does not",
required=True,
detector=Detector(description="Event A", before_step="Z"),
),
),
threshold_promote_to_hook=0.5,
)
trace = [
ObservationEvent(
timestamp=f"2026-03-20T10:00:0{index}Z",
event="tool_complete",
tool="Write",
session="sess-forward",
input=f'{{"file_path":"src/{name}.py"}}',
output=f"step {name}",
)
for index, name in enumerate(("z", "a", "b"))
]
mock_cls.return_value = {"Z": [0], "A": [1], "B": [2]}
result = grade(spec, trace)
detected = {step.step_id: step.detected for step in result.steps}
assert detected == {"Z": True, "A": False, "B": False}
assert result.compliance_rate == pytest.approx(1 / 3)
step_b = next(step for step in result.steps if step.step_id == "B")
assert step_b.evidence == ()
assert "A" in (step_b.failure_reason or "")
@patch("scripts.grader.classify_events")
def test_revocation_reaches_a_step_that_referenced_the_dependant_backwards(
self, mock_cls: MagicMock
) -> None:
"""One demotion invalidates the next, in either declaration order.
C is declared after B and passes on `resolved`, the ordinary backward
reference — B was still detected at the time. B is the forward-reference case
above and comes down with A, so C has to follow, which takes a second pass.
"""
spec = ComplianceSpec(
id="revocation-propagates",
name="Revocation propagates",
source_rule="rules/common/testing.md",
version="1.0",
steps=(
Step(
id="Z",
description="Reference step with no constraint of its own",
required=True,
detector=Detector(description="Event Z"),
),
Step(
id="B",
description="Depends on A, which is declared after it",
required=True,
detector=Detector(description="Event B", after_step="A"),
),
Step(
id="A",
description="Must occur before Z, and does not",
required=True,
detector=Detector(description="Event A", before_step="Z"),
),
Step(
id="C",
description="Depends on B, which is declared before it",
required=True,
detector=Detector(description="Event C", after_step="B"),
),
),
threshold_promote_to_hook=0.5,
)
trace = [
ObservationEvent(
timestamp=f"2026-03-20T10:00:0{index}Z",
event="tool_complete",
tool="Write",
session="sess-propagate",
input=f'{{"file_path":"src/{name}.py"}}',
output=f"step {name}",
)
for index, name in enumerate(("z", "a", "b", "c"))
]
mock_cls.return_value = {"Z": [0], "A": [1], "B": [2], "C": [3]}
result = grade(spec, trace)
detected = {step.step_id: step.detected for step in result.steps}
assert detected == {"Z": True, "A": False, "B": False, "C": False}
assert result.compliance_rate == pytest.approx(1 / 4)
step_c = next(step for step in result.steps if step.step_id == "C")
assert "B" in (step_c.failure_reason or "")
+70
View File
@@ -1507,6 +1507,48 @@ function runTests() {
passed++;
else failed++;
if (
test('denies quoted destructive SQL passed to SQL clients (issue #3024)', () => {
expectDestructiveDeny('psql -c "drop table users"', 'psql quoted drop table');
expectDestructiveDeny("psql -c 'truncate audit_log'", 'psql quoted truncate');
expectDestructiveDeny('mysql -e "delete from sessions"', 'mysql quoted delete');
expectDestructiveDeny('sqlite3 app.db "DROP TABLE users"', 'sqlite3 quoted drop');
})
)
passed++;
else failed++;
if (
test('denies quoted destructive SQL through sudo/env wrappers', () => {
expectDestructiveDeny('sudo -u postgres psql -c "drop table users"', 'sudo -u psql');
expectDestructiveDeny('env PGUSER=postgres psql -c "drop table users"', 'env psql');
expectDestructiveDeny('env PGPASSWORD=value psql -c "drop table users"', 'env PGPASSWORD psql');
expectDestructiveDeny('env -C /tmp psql -c "drop table users"', 'env -C psql');
expectDestructiveDeny('env --chdir /tmp psql -c "drop table users"', 'env --chdir psql');
})
)
passed++;
else failed++;
if (
test('denies destructive SQL through wrapper sh -c chains', () => {
expectDestructiveDeny('sudo sh -c \'psql -c "drop table users"\'', 'sudo sh -c psql');
expectDestructiveDeny('env sh -c \'psql -c "drop table users"\'', 'env sh -c psql');
})
)
passed++;
else failed++;
if (
test('allows SQL string literals and non-SQL clients mentioning SQL', () => {
expectAllow('psql -c "SELECT \'drop table\' FROM audit_log"', 'SQL string literal');
expectAllow('psql -c "SELECT $tag$drop table users$tag$ FROM t"', 'tagged dollar-quote literal');
expectAllow('echo "drop table users"', 'echo SQL mention');
})
)
passed++;
else failed++;
if (
test('allows destructive SQL prose inside a quoted heredoc', () => {
expectAllow(
@@ -3327,6 +3369,34 @@ function runTests() {
console.error(` [cleanup] failed to remove ${stateDir}: ${err.message}`);
}
// --- sanitizePath dangerous invisible unicode regression ---
clearState();
if (
test('sanitizePath strips CI-defined dangerous invisible unicode from denial paths', () => {
const file_path =
'/src/eu2028\u2028eu2029\u2029app.js\u200bhidden\u2060name\ufefftail\u3164x\u0091c1.js';
const input = {
tool_name: 'Edit',
tool_input: { file_path, old_string: 'foo', new_string: 'bar' }
};
const result = runHook(input);
const output = parseOutput(result.stdout);
const reason = String(
output && output.hookSpecificOutput
? output.hookSpecificOutput.permissionDecisionReason
: ''
);
for (const bad of ['\u2028', '\u2029', '\u200b', '\u2060', '\ufeff', '\u3164', '\u0091']) {
assert.ok(!reason.includes(bad), `denial reason must not carry U+${bad.codePointAt(0).toString(16)} (${bad})`);
}
assert.ok(reason.includes('app.js'), 'visible path text must remain');
})
) {
passed++;
} else {
failed++;
}
console.log(`\n ${passed} passed, ${failed} failed\n`);
process.exit(failed > 0 ? 1 : 0);
}
+27 -1
View File
@@ -504,6 +504,32 @@ if (test('measures length of the full message past an apostrophe (not the trunca
assert.strictEqual(res.message, subject);
assert.ok(res.issues.some(i => i.type === 'length'), 'full (>72) message should trigger a length issue');
})) passed++; else failed++;
if (test('handles hash-prefixed python comments and string false-positives', () => {
inTempRepo(repoDir => {
writeAndStage(repoDir, 'script.py', [
'# console.log("commented out");',
'# debugger',
'# TODO: python unreferenced',
'# TODO: python issue #456',
'const str = "# TODO: string false positive";', // Greptile string limitation
''
].join('\n'));
const input = JSON.stringify({ tool_input: { command: 'git commit -m "fix(hooks): python hash comments"' } });
const origConsoleError = console.error;
let stderr = '';
console.error = msg => { stderr += msg + '\n'; };
const result = hook.evaluate(input);
console.error = origConsoleError;
assert.strictEqual(result.exitCode, 0, 'warning-only issues should not block');
assert.ok(stderr.includes('INFO Line 3:'), `expected python TODO warning`);
assert.ok(!stderr.includes('INFO Line 4'), 'referenced python TODO should not warn');
assert.ok(!stderr.includes('ERROR Line 2'), 'commented debugger should not error');
assert.ok(stderr.includes('INFO Line 5:'), `expected string limitation warning`);
});
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}, Skipped: ${skipped}`);
process.exit(failed > 0 ? 1 : 0);
+1 -5
View File
@@ -278,7 +278,7 @@ test('requesting another scope fails without the PR 2 move-scope operation', ()
});
});
test('fresh install follows the exact inventory, marketplace, install, and verification sequence', () => {
test('fresh install uses supported Claude arguments and follows the verification sequence', () => {
withFixture({}, fixture => {
const result = setupClaudePlugin(setupOptions(fixture, {
scope: 'project',
@@ -294,8 +294,6 @@ test('fresh install follows the exact inventory, marketplace, install, and verif
[
'plugin', 'install', 'ecc@ecc',
'--scope', 'project',
'--config', 'hooks_enabled=true',
'--config', 'hook_profile=strict',
],
['plugin', 'list', '--json'],
]);
@@ -724,8 +722,6 @@ test('provider failures stop later operations and leave settings untouched', ()
const installArgv = [
'plugin', 'install', 'ecc@ecc',
'--scope', 'user',
'--config', 'hooks_enabled=true',
'--config', 'hook_profile=standard',
];
withFixture({
failures: [{