mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-15 12:45:29 +02:00
Merge remote-tracking branch 'upstream/main' into codex/docs-governance-audit-foundation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' }
|
||||
);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 "")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [{
|
||||
|
||||
Reference in New Issue
Block a user