merge: reconcile Polish locale with current main

This commit is contained in:
Dante
2026-09-22 12:23:52 +08:00
170 changed files with 8472 additions and 1263 deletions
+43 -10
View File
@@ -95,21 +95,54 @@ function askClaude(systemPrompt, history, userMessage, model) {
}
args.push('-p');
// On Windows the `claude` binary installed via npm is `claude.cmd`/`claude.ps1`,
// and Node's spawn() cannot resolve those wrappers via PATH without shell: true.
// But shell mode concatenates args *unescaped*, so a multi-line prompt passed as
// an arg gets mangled (newlines and the `===` section markers truncate it, and
// claude receives an empty prompt). Fix: send the prompt over stdin via `input`
// and keep only the short, safe flags (`--model`, `-p`) as args.
// 'claude' is a hardcoded literal here (not user input), so shell mode is safe.
const result = spawnSync('claude', args, {
// SECURITY: a model value like `x & calc &` breaks out when Node
// concatenates command+args unquoted under cmd.exe (DEP0190), so the model
// token is validated and only fixed flags reach the command line.
if (model && !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/.test(model)) {
return `[Error: invalid model name]`;
}
// On Windows the `claude` binary is usually a .cmd shim, which Node
// >=18.20/20.12 refuses to spawn directly (CVE-2024-27980 mitigation), and
// .ps1 shims are not directly executable at all. Resolve a natively
// executable target first; only .cmd/.bat go through cmd.exe, using the
// same quoted-command-line pattern as scripts/hooks/mcp-health-check.js so
// space-containing paths survive as single tokens. .ps1 is never executed
// directly — fall through to bare `claude` (pre-change behavior) instead.
// cmd.exe expands %NAME% even inside double-quoted strings, so reject
// percent-delimited executable paths rather than route them through the shell.
function quoteWinToken(token) {
if (/%/.test(token)) return null;
return /[\s"&|<>^();]/.test(token) ? '"' + token.replace(/"/g, '""') + '"' : token;
}
let bin = 'claude';
let useShell = false;
if (process.platform === 'win32') {
const { spawnSync: spawnWhere } = require('child_process');
for (const ext of ['.exe', '.cmd', '.bat']) {
let found = null;
try {
found = spawnWhere('where', [`claude${ext}`], { encoding: 'utf8' });
} catch { /* ignore */ }
if (found && found.status === 0 && found.stdout && found.stdout.trim()) {
bin = found.stdout.trim().split(/\r?\n/)[0];
useShell = /\.(cmd|bat)$/i.test(bin);
break;
}
}
if (useShell && quoteWinToken(bin) === null) {
useShell = false;
}
}
const spawnOpts = {
input: fullPrompt,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, CLAUDECODE: '' },
timeout: 300000,
shell: process.platform === 'win32'
});
};
const result = useShell
? spawnSync([bin, ...args].map(quoteWinToken).join(' '), { ...spawnOpts, shell: true })
: spawnSync(bin, args, { ...spawnOpts, shell: false });
if (result.error) {
return `[Error: ${result.error.message}]`;
+4 -3
View File
@@ -5,12 +5,13 @@ set -euo pipefail
# Blocks commits that add high-signal secrets.
if [[ "${ECC_SKIP_GIT_HOOKS:-0}" == "1" || "${ECC_SKIP_PRECOMMIT:-0}" == "1" ]]; then
printf '[ECC pre-commit] WARNING: hook bypassed via env (ECC_SKIP_*=1)\n' >&2
exit 0
fi
if [[ -f ".ecc-hooks-disable" || -f ".git/ecc-hooks-disable" ]]; then
exit 0
fi
# NOTE: file-based disables (.ecc-hooks-disable) were removed — a malicious
# repo could ship that file and silently turn off secret scanning exactly
# where it is most needed. Use the env bypass above (audible warning) instead.
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
exit 0
+181 -9
View File
@@ -5,12 +5,12 @@ set -euo pipefail
# Runs a lightweight verification flow before pushes.
if [[ "${ECC_SKIP_GIT_HOOKS:-0}" == "1" || "${ECC_SKIP_PREPUSH:-0}" == "1" ]]; then
printf '[ECC pre-push] WARNING: hook bypassed via env (ECC_SKIP_*=1)\n' >&2
exit 0
fi
if [[ -f ".ecc-hooks-disable" || -f ".git/ecc-hooks-disable" ]]; then
exit 0
fi
# NOTE: file-based disables (.ecc-hooks-disable) were removed — a malicious
# repo could ship that file and silently disable verification.
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
exit 0
@@ -85,8 +85,14 @@ run_node_script() {
}
if [[ -f "package.json" ]]; then
pm="$(detect_pm)"
log "Node project detected (package manager: $pm)"
# SECURITY: executing a cloned repo's lint/test/build scripts on push is
# arbitrary code execution (package.json scripts run as you). Opt-in only:
# set ECC_PREPUSH_RUN_CHECKS=1 for repos you trust.
if [[ "${ECC_PREPUSH_RUN_CHECKS:-0}" != "1" ]]; then
printf '[ECC pre-push] Node project detected but ECC_PREPUSH_RUN_CHECKS!=1; skipping repo script execution (set =1 to opt in).\n' >&2
else
pm="$(detect_pm)"
log "Node project detected (package manager: $pm)"
for script_name in lint typecheck test build; do
if has_node_script "$script_name"; then
@@ -98,7 +104,9 @@ if [[ -f "package.json" ]]; then
fi
done
fi
if [[ "${ECC_PREPUSH_AUDIT:-0}" == "1" ]]; then
pm="${pm:-$(detect_pm)}"
ran_any_check=1
log "Running dependency audit (ECC_PREPUSH_AUDIT=1)"
case "$pm" in
@@ -111,21 +119,185 @@ if [[ -f "package.json" ]]; then
fi
fi
# SECURITY: go test / pytest execute repo-controlled code (TestMain,
# conftest.py). Same opt-in gate as Node scripts above.
if [[ "${ECC_PREPUSH_RUN_CHECKS:-0}" == "1" ]]; then
if [[ -f "go.mod" ]] && command -v go >/dev/null 2>&1; then
ran_any_check=1
log "Go project detected. Running: go test ./..."
go test ./... || fail "go test failed"
fi
# Resolve how this project runs pytest, into PYTEST_CMD as an argv array.
#
# Looking only for `pytest` on PATH meant the hook skipped every project that keeps
# its tools in a virtualenv -- which is most of them -- and reported "pytest is not
# installed" while sitting next to a .venv with pytest in it. A gate that silently
# declines to gate is worse than no gate, because the skip line reads like a pass.
#
# An array rather than one string, because a virtualenv path may contain spaces:
# a scalar command splits `/home/me/my env/bin/python` into two paths that do not
# exist, and the hook then rejects the push for a reason that has nothing to do
# with the code being pushed.
#
# Echoes the command it will run, so the reason for a skip is always visible.
PYTEST_CMD=()
# Does this command actually run pytest? Accepting `--version` is not evidence --
# plenty of programs take it and exit 0 -- so the output has to name pytest. The
# version is captured rather than piped: under `set -o pipefail` a `| grep -q` can
# report the SIGPIPE of the program it just matched.
#
# Only ever called on a command this script composed itself. Probing an arbitrary
# operator-supplied command is not safe: a wrapper that ignores `--version` and
# execs pytest runs the entire suite during the probe, and is then rejected for
# not having printed a version.
is_pytest() {
local version
version="$("$@" --version 2>&1)" || return 1
grep -qiE 'pytest[[:space:]]+(version[[:space:]]+)?[0-9]' <<<"$version"
}
# Does the repository itself ship this interpreter?
#
# A virtualenv is never committed -- it is platform-specific binaries, and every
# Python project gitignores it. One that IS tracked is the repository handing this
# hook an executable and asking it to run. The hook is installed globally, so
# cloning a hostile repository and pushing it to your own fork would be enough,
# and on a machine with no pytest on PATH this arm is the only thing that would
# run at all. A developer's own venv is untracked, so nothing legitimate is lost.
#
# The path is resolved through symlinks before git is asked, because `git ls-files`
# reports paths as indexed and does not follow links. A repository that commits
# `.venv` as a symlink to `.` next to a tracked `bin/python` would otherwise be
# queried for `.venv/bin/python`, a path git has never heard of, and the answer
# would be "untracked". Measured: that shape ran the planted binary twice.
repo_ships_interpreter() {
local bindir real top
bindir="$(cd -P -- "$1" 2>/dev/null && pwd -P)" || return 1
[[ -n "$bindir" ]] || return 1
real="$bindir/python"
top="$(git rev-parse --show-toplevel 2>/dev/null)" || return 1
top="$(cd -P -- "$top" 2>/dev/null && pwd -P)" || return 1
[[ -n "$top" && "$real" == "$top/"* ]] || return 1
# `:(icase)` because git matches index pathspecs case-sensitively even where
# core.ignorecase is set, while the filesystem underneath does not. On macOS's
# APFS -- the platform this hook most often runs on -- a committed
# `.venv/bin/Python` is what `$venv/bin/python` opens and executes, but a
# case-sensitive query for the lowercase name finds nothing in the index and the
# guard waves it through. Measured: that spelling ran the planted binary twice.
git ls-files --error-unmatch -- ":(icase)${real#"$top"/}" >/dev/null 2>&1
}
# `-I` isolates the probe: without it Python puts the working directory first on
# sys.path, so a repository that commits a `pytest.py` in its root gets that file
# imported -- and executed -- by a check whose only job is to answer whether pytest
# exists. Measured: a committed pytest.py ran during the probe. Isolation does not
# hide a real pytest, which lives in the interpreter's own site-packages.
resolve_pytest() {
# `${VAR+set}` rather than `-n "${VAR:-}"`, so that a variable set to nothing is
# still an override: `ECC_PYTEST_CMD=` and `ECC_PYTEST_CMD=" "` now behave
# alike, where the first used to fall through to discovery and the second failed
# the push. Falling through is the wrong half of that pair -- an override that
# evaluated empty (a command substitution that found nothing, say) would silently
# run a different runner than the operator asked for, which is the substitution
# this resolver refuses to make anywhere else.
#
# Not `[[ -v ECC_PYTEST_CMD ]]`: that is bash 4.2, and a stock macOS `/bin/bash`
# is 3.2, where it is a syntax error rather than a false. This hook ships to
# whatever `env bash` finds.
if [[ -n "${ECC_PYTEST_CMD+set}" ]]; then
# Taken as given. This is a deliberate override, and the hook cannot inspect it
# without running it -- a wrapper script may ignore `--version` and run the
# suite, so probing costs a duplicate test run and then blocks the push anyway.
# Pointing this at something that is not pytest turns the gate off, and that is
# the operator's call to make, not a misconfiguration for the hook to second
# guess. Word-split, so the command names something on PATH or an interpreter
# whose path has no spaces; a venv with spaces is found by the loop below.
read -r -a PYTEST_CMD <<<"$ECC_PYTEST_CMD" || true
[[ ${#PYTEST_CMD[@]} -gt 0 ]] || fail "ECC_PYTEST_CMD is set but names no command.\
Point it at your test runner, or unset it to fall back to discovery."
return 0
fi
local venv
for venv in "${VIRTUAL_ENV:-}" .venv venv env; do
if [[ -n "$venv" && -x "$venv/bin/python" ]]; then
if repo_ships_interpreter "$venv/bin"; then
log "Ignoring $venv/bin/python: the repository ships it."
log " A committed virtualenv is an executable the repository controls, and"
log " this hook runs on every push in every repository."
continue
fi
if "$venv/bin/python" -I -c "import pytest" >/dev/null 2>&1; then
PYTEST_CMD=("$venv/bin/python" -m pytest)
return 0
fi
fi
done
if [[ -f "uv.lock" ]] && command -v uv >/dev/null 2>&1; then
if uv run --no-sync python -I -c "import pytest" >/dev/null 2>&1; then
PYTEST_CMD=(uv run --no-sync pytest)
return 0
fi
fi
if [[ -f "poetry.lock" ]] && command -v poetry >/dev/null 2>&1; then
if poetry run python -I -c "import pytest" >/dev/null 2>&1; then
PYTEST_CMD=(poetry run pytest)
return 0
fi
fi
# `command -v` proves only that a file of that name exists on PATH. This one the
# script composed itself, so confirming it costs a harmless `pytest --version`.
if command -v pytest >/dev/null 2>&1 && is_pytest pytest; then
PYTEST_CMD=(pytest)
return 0
fi
PYTEST_CMD=()
return 1
}
if [[ -f "pyproject.toml" || -f "requirements.txt" ]]; then
if command -v pytest >/dev/null 2>&1; then
if resolve_pytest; then
ran_any_check=1
log "Python project detected. Running: pytest -q"
pytest -q || fail "pytest failed"
log "Python project detected. Running: ${PYTEST_CMD[*]} -q"
if [[ -n "${ECC_PYTEST_CMD+set}" ]]; then
# resolve_pytest deliberately does not verify the override is pytest, because
# probing it can run the operator's suite. What this gate can honestly do
# about a stale override is refuse to be quiet about it: a bypass announced
# on every push is not the silent gate this resolver exists to prevent.
log " via ECC_PYTEST_CMD -- the hook runs what you pointed it at, and does"
log " not check that it is pytest. Unset it to gate on the real suite."
fi
pytest_status=0
"${PYTEST_CMD[@]}" -q || pytest_status=$?
case "$pytest_status" in
0) ;;
# pytest reserves 5 for NO_TESTS_COLLECTED, which is not a red suite. A
# pyproject.toml that only configures ruff or black is still a Python project
# by this hook's test, and blocking those pushes would make the gate something
# people switch off. Never silent, though: a bad rootdir, testpaths or a
# conftest that fails to import also collects nothing, and swallowing that is
# the same skip-reads-like-a-pass hole this resolver exists to close.
5)
log "pytest collected no tests (exit 5). Not gating this push."
log " If this repository is supposed to have tests, that is the bug:"
log " check rootdir, testpaths, and conftest.py import errors."
;;
# The code is in the message because 1 (tests failed) and 4 (usage error)
# need different responses, and "pytest failed" alone cannot tell them apart.
*) fail "pytest failed (exit $pytest_status)" ;;
esac
else
log "Python project detected but pytest is not installed. Skipping."
log "Python project detected but no pytest found (checked \$VIRTUAL_ENV, .venv,"
log " venv, env, uv, poetry, PATH). Set ECC_PYTEST_CMD to point at it."
fi
fi
else
if [[ -f "go.mod" || -f "pyproject.toml" || -f "requirements.txt" ]]; then
log "Go/Python project detected but ECC_PREPUSH_RUN_CHECKS!=1; skipping test execution."
fi
fi
if [[ "$ran_any_check" -eq 0 ]]; then
log "No supported checks found in this repository. Skipping."
+19 -9
View File
@@ -41,6 +41,23 @@ log "Mode: $MODE"
log "Source hooks: $SOURCE_DIR"
log "Global hooks destination: $DEST_DIR"
prev_hooks_path="$(git config --global core.hooksPath || true)"
if [[ -n "$prev_hooks_path" && "$prev_hooks_path" != "$DEST_DIR" ]]; then
# SECURITY: never silently displace another tool's global hooks — that
# turns every commit/push in every repo into ECC code execution and breaks
# the user's existing security controls. Require explicit opt-in to replace.
if [[ "${ECC_FORCE_GLOBAL_HOOKS:-0}" != "1" ]]; then
log "ERROR: global core.hooksPath already set to: $prev_hooks_path"
log "Refusing to overwrite. Options:"
log " 1) Per-repo install (recommended): git config core.hooksPath \"$DEST_DIR\""
log " 2) Force replace: ECC_FORCE_GLOBAL_HOOKS=1 $0"
log " 3) Restore afterwards: git config --global core.hooksPath \"$prev_hooks_path\""
exit 1
fi
log "WARNING: replacing previous global hooksPath: $prev_hooks_path (ECC_FORCE_GLOBAL_HOOKS=1)"
log "Restore with: git config --global core.hooksPath \"$prev_hooks_path\""
fi
if [[ -d "$DEST_DIR" ]]; then
log "Backing up existing hooks directory to $BACKUP_DIR"
run_or_echo mkdir -p "$BACKUP_DIR"
@@ -51,15 +68,8 @@ run_or_echo mkdir -p "$DEST_DIR"
run_or_echo cp "$SOURCE_DIR/pre-commit" "$DEST_DIR/pre-commit"
run_or_echo cp "$SOURCE_DIR/pre-push" "$DEST_DIR/pre-push"
run_or_echo chmod +x "$DEST_DIR/pre-commit" "$DEST_DIR/pre-push"
if [[ "$MODE" == "apply" ]]; then
prev_hooks_path="$(git config --global core.hooksPath || true)"
if [[ -n "$prev_hooks_path" ]]; then
log "Previous global hooksPath: $prev_hooks_path"
fi
fi
run_or_echo git config --global core.hooksPath "$DEST_DIR"
log "Installed ECC global git hooks."
log "Disable per repo by creating .ecc-hooks-disable in project root."
log "Temporary bypass: ECC_SKIP_PRECOMMIT=1 or ECC_SKIP_PREPUSH=1"
log "Per-repo alternative (recommended): git config core.hooksPath \"$DEST_DIR\""
log "Temporary bypass (audible): ECC_SKIP_GIT_HOOKS=1 (logs a warning to stderr)"
+7 -9
View File
@@ -10,16 +10,14 @@ const {
} = require('./lib/control-pane/server');
const { describeMissingDependencyError } = require('./lib/missing-dependency');
// openBrowser is now in scripts/lib/platform-launch.js — keep a thin wrapper
// for backwards compatibility, but surface the structured result.
const { openBrowser: launchOpenBrowser } = require('./lib/platform-launch');
function openBrowser(url) {
if (process.platform !== 'darwin') return;
const child = spawn('open', [url], {
stdio: 'ignore',
detached: true,
});
child.on('error', error => {
console.error(`[control-pane] failed to open browser: ${error.message}`);
});
child.unref();
const result = launchOpenBrowser(url);
if (!result.opened) {
console.error(`[control-pane] failed to open browser: ${result.reason}`);
}
}
async function main(argv = process.argv) {
+27 -5
View File
@@ -4,7 +4,9 @@
*
* Reads transcript_path from Stop hook stdin, sums usage across all
* assistant turns in the session JSONL, and appends one row to
* ~/.claude/metrics/costs.jsonl.
* ~/.claude/metrics/costs.jsonl. It also atomically publishes the latest
* cumulative row under metrics/cost-snapshots/ so frequent PostToolUse
* hooks do not need to rescan the unbounded history.
*
* Stop hook stdin payload: { session_id, transcript_path, cwd, hook_event_name, ... }
* The Stop payload does NOT include `usage` or `model` directly. The previous
@@ -40,8 +42,12 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const { ensureDir, appendFile, getClaudeDir } = require('../lib/utils');
const { ensureDir, getClaudeDir } = require('../lib/utils');
const { sanitizeSessionId } = require('../lib/session-bridge');
const {
appendSessionCostRow,
warnSessionCostSnapshotFailure
} = require('../lib/session-cost-snapshot');
const HARNESS_COST_MAX_AGE_SECONDS = 300;
@@ -103,7 +109,17 @@ function isSonnet5(model) {
function toNumber(v) {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
return Number.isFinite(n) && n >= 0 ? n : 0;
}
function normalizeUsageTotals(totals) {
return {
inputTokens: toNumber(totals.inputTokens),
outputTokens: toNumber(totals.outputTokens),
cacheWriteTokens: toNumber(totals.cacheWriteTokens),
cacheReadTokens: toNumber(totals.cacheReadTokens),
model: totals.model
};
}
/**
@@ -161,7 +177,9 @@ function sumUsageFromTranscript(transcriptPath) {
cacheReadTokens += toNumber(u.cache_read_input_tokens);
}
return { inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, model };
return normalizeUsageTotals({
inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, model
});
}
// 1MB, matching the other Stop hooks. The Stop payload carries
@@ -242,7 +260,11 @@ process.stdin.on('end', () => {
estimated_cost_usd: estimatedCostUsd
};
appendFile(path.join(metricsDir, 'costs.jsonl'), `${JSON.stringify(row)}\n`);
try {
appendSessionCostRow(metricsDir, sessionId, row);
} catch (error) {
warnSessionCostSnapshotFailure('publication', metricsDir, sessionId, error);
}
} catch {
// Non-blocking — never fail the Stop hook.
}
+39 -49
View File
@@ -14,6 +14,10 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const { sanitizeSessionId, readBridge, writeBridgeAtomic } = require('../lib/session-bridge');
const {
readSessionCostSnapshot,
warnSessionCostSnapshotFailure
} = require('../lib/session-cost-snapshot');
const { getClaudeDir } = require('../lib/utils');
const MAX_STDIN = 1024 * 1024;
@@ -22,11 +26,6 @@ const RECENT_TOOLS_SIZE = 5;
const HASH_INPUT_LIMIT = 2048;
const WARNING_CACHE_PREFIX = 'ecc-metrics-cost-warnings-';
function toNumber(value) {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
function stableStringify(value, depth = 0) {
if (depth > 4) return '[depth-limit]';
if (value === null || typeof value !== 'object') return JSON.stringify(value);
@@ -134,60 +133,51 @@ function writeCostWarningIfChanged(kind, costsPath, signature, message) {
}
/**
* Read cumulative cost for a session from costs.jsonl.
* Read cumulative cost for a session.
*
* Scans the full file because each row is a cumulative session total
* (see cost-tracker.js docblock) and the row we need is the last one
* matching `sessionId`. The previous implementation read only the
* trailing 8 KiB; any session whose latest cumulative row was pushed
* past that window by newer rows from other sessions silently dropped
* to zero — the opposite sign of the double-count bug fixed in the
* previous commit.
* The Stop hook publishes an atomic per-session cursor snapshot, so a stable
* PostToolUse path reads O(1) metadata and newly appended data is O(delta)
* instead of reparsing unbounded history.
* Older ECC installations and damaged/missing snapshots remain compatible:
* they fall back to scanning costs.jsonl for the last cumulative row.
*
* costs.jsonl is append-only and unbounded today (no rotation in
* cost-tracker.js). At a typical ~150 bytes per row, even 100k rows
* is ~15 MB and a single sync read on every PostToolUse hook is in
* the low milliseconds. If rotation lands later, this scan becomes
* even cheaper.
* The fallback deliberately scans the whole file. A fixed tail window loses
* sessions whose newest row has been pushed back by other sessions.
*/
function readSessionCost(sessionId) {
let costsPath = path.join('metrics', 'costs.jsonl');
try {
costsPath = path.join(getClaudeDir(), 'metrics', 'costs.jsonl');
const content = fs.readFileSync(costsPath, 'utf8');
const lines = content.split('\n').filter(Boolean);
let totalCost = 0;
let totalIn = 0;
let totalOut = 0;
let malformed = 0;
const malformedHasher = crypto.createHash('sha256');
for (const line of lines) {
try {
const row = JSON.parse(line);
if (row.session_id === sessionId) {
totalCost = toNumber(row.estimated_cost_usd);
totalIn = toNumber(row.input_tokens);
totalOut = toNumber(row.output_tokens);
}
} catch {
malformed += 1;
malformedHasher.update(line).update('\0');
}
}
// One aggregated breadcrumb per call rather than one per bad row, so a
// log-flooded costs.jsonl stays diagnosable without overwhelming stderr.
// Suppress repeats for the same malformed-line signature across hook
// subprocesses, so a persistent bad row should not spam stderr.
if (malformed > 0) {
const metricsDir = path.join(getClaudeDir(), 'metrics');
costsPath = path.join(metricsDir, 'costs.jsonl');
const snapshotResult = readSessionCostSnapshot(metricsDir, sessionId);
if (snapshotResult.malformed > 0) {
writeCostWarningIfChanged(
'malformed',
costsPath,
`${malformed}:${malformedHasher.digest('hex').slice(0, 16)}`,
`[ecc-metrics-bridge] skipped ${malformed} malformed line(s) in ${costsPath}\n`
`${snapshotResult.malformed}:${snapshotResult.malformedSignature}`,
`[ecc-metrics-bridge] skipped ${snapshotResult.malformed} malformed line(s) during the snapshot scan of ${costsPath}\n`
);
}
return { totalCost, totalIn, totalOut };
if (snapshotResult.invalid > 0) {
writeCostWarningIfChanged(
'invalid-row',
costsPath,
`${snapshotResult.invalid}:${snapshotResult.invalidSignature}`,
`[ecc-metrics-bridge] skipped ${snapshotResult.invalid} invalid cumulative row(s) for ${sessionId} during the snapshot scan of ${costsPath}\n`
);
}
if (snapshotResult.snapshotError) {
warnSessionCostSnapshotFailure(
'repair',
metricsDir,
sessionId,
snapshotResult.snapshotError
);
}
const row = snapshotResult.row;
return row
? { totalCost: row.estimated_cost_usd, totalIn: row.input_tokens, totalOut: row.output_tokens }
: { totalCost: 0, totalIn: 0, totalOut: 0 };
} catch (err) {
// ENOENT is the common case (no Stop event has fired yet this session)
// and is not actually a failure — stay silent on it. Anything else
@@ -259,7 +249,7 @@ function run(rawInput) {
if (recent.length > RECENT_TOOLS_SIZE) recent.shift();
bridge.recent_tools = recent;
// Update cost from costs.jsonl tail
// Use the O(1) session snapshot, with JSONL compatibility fallback.
const costs = readSessionCost(sessionId);
bridge.total_cost_usd = Math.round(costs.totalCost * 1e6) / 1e6;
bridge.total_input_tokens = costs.totalIn;
+331 -10
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;
}
@@ -462,10 +613,65 @@ function findGitSubcommand(tokens) {
return null;
}
/**
* Branch names treated as shared history: a forced update of one of
* these rewrites commits other clones build on, even when the push is
* lease-checked.
*/
const SHARED_GIT_BRANCHES = new Set(['main', 'master', 'develop', 'trunk']);
/**
* Decide whether the positional arguments of a `git push` name a shared
* branch as the destination of a refspec. The first positional token is
* the remote (unless the remote came from `--repo`); every later
* positional token is a refspec whose destination is the part after
* `:` (or the whole token when there is no `:`). A leading `+` force
* marker is stripped. When no refspec is given the target is the
* current branch, which the hook cannot know, so this returns false.
*
* @param {string[]} rest tokens after `push`
* @returns {boolean}
*/
function pushTargetsSharedBranch(rest) {
const valueConsuming = new Set(['-o', '--push-option', '--receive-pack', '--exec']);
const positional = [];
let remoteViaFlag = false;
for (let i = 0; i < rest.length; i++) {
const t = rest[i];
if (t === '--repo') {
remoteViaFlag = true;
i += 1;
continue;
}
if (t.startsWith('--repo=')) {
remoteViaFlag = true;
continue;
}
if (valueConsuming.has(t)) {
i += 1;
continue;
}
if (t.startsWith('-')) continue;
positional.push(t);
}
// Unless the remote came from --repo, positional[0] is the remote and
// the rest are refspecs.
const refspecs = remoteViaFlag ? positional : positional.slice(1);
for (const refspec of refspecs) {
const cleaned = refspec.startsWith('+') ? refspec.slice(1) : refspec;
const dst = cleaned.includes(':') ? cleaned.slice(cleaned.indexOf(':') + 1) : cleaned;
const branch = dst.startsWith('refs/heads/') ? dst.slice('refs/heads/'.length) : dst;
if (SHARED_GIT_BRANCHES.has(branch)) return true;
}
return false;
}
/**
* Detect destructive `git` invocations: `reset --hard`, `checkout --`,
* `clean -f...`, `push --force` (but not `--force-with-lease`),
* `commit --amend`, `rm -rf`.
* `clean -f...`, `push --force` (`--force-with-lease` only to a shared
* branch), `commit --amend`, `rm -rf`, `branch -D`, `stash drop` /
* `stash clear`, `reflog expire` / `reflog delete`, `update-ref -d`,
* and `restore` against the worktree.
*
* @param {string[]} tokens
* @returns {boolean}
@@ -532,7 +738,9 @@ function isDestructiveGit(tokens) {
plusRefspecForce = true;
}
}
return bareForce || (plusRefspecForce && !withLease);
if (bareForce || (plusRefspecForce && !withLease)) return true;
// A lease-checked force still rewrites a shared branch's history.
return withLease && pushTargetsSharedBranch(rest);
}
if (command === 'commit') {
@@ -563,6 +771,53 @@ function isDestructiveGit(tokens) {
});
}
if (command === 'branch') {
// `git branch -D` (long spelling: `--delete --force`) deletes a
// branch even when it is unmerged, orphaning its commits. Plain
// `-d` refuses when unmerged, so it is safe to leave ungated.
let del = false;
let force = false;
for (const t of rest) {
if (t === '--delete') { del = true; continue; }
if (t === '--force') { force = true; continue; }
if (!t.startsWith('-') || t.startsWith('--')) continue;
const body = t.slice(1);
if (body.includes('D')) return true;
if (body.includes('d')) del = true;
if (body.includes('f')) force = true;
}
return del && force;
}
if (command === 'stash') {
// `drop` destroys one stash entry, `clear` the entire stash.
// `list`, `show`, `pop` and `apply` keep the entries recoverable.
return rest[0] === 'drop' || rest[0] === 'clear';
}
if (command === 'reflog') {
// `expire` and `delete` remove the recovery net that makes every
// other gated git command recoverable.
return rest[0] === 'expire' || rest[0] === 'delete';
}
if (command === 'update-ref') {
// `git update-ref -d <ref>` deletes a ref directly.
return rest.includes('-d') || rest.includes('--delete');
}
if (command === 'restore') {
// `git restore <path>` overwrites the working tree from the index
// by default, the modern spelling of gated `git checkout -- <path>`.
// Only `--staged` alone is non-destructive (it leaves the file on
// disk untouched); `--worktree` (the default target) is destructive.
const has = (long, short) => rest.some(t =>
t === long || (t.startsWith('-') && !t.startsWith('--') && t.slice(1).includes(short)));
const staged = has('--staged', 'S');
const worktree = has('--worktree', 'W');
return worktree || !staged;
}
return false;
}
@@ -1007,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) {
@@ -1101,6 +1402,21 @@ function isReadOnlyGitIntrospection(command) {
// --- Gate messages ---
/**
* Batch-consistency warning (#3136). A first-touch denial marks the file
* checked so the retry passes; a parallel batch of edits to one
* not-yet-touched file therefore partially applies (first call denied,
* siblings allowed). Hooks see calls one at a time and cannot lock a
* batch, so the denial must say this out loud: name the file and tell
* the agent that siblings may already have been applied.
*/
function batchSiblingWarning(safePath) {
return (
`If this call was sent in a parallel batch, other edits to ${safePath} from that batch ` +
'may already have been applied. Re-read the file before building on them.'
);
}
function editGateMsg(filePath) {
const safe = sanitizePath(filePath);
return [
@@ -1113,6 +1429,8 @@ function editGateMsg(filePath) {
'3. If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)',
"4. Quote the user's current instruction verbatim",
'',
batchSiblingWarning(safe),
'',
'Present the facts, then retry the same operation.'
].join('\n');
}
@@ -1129,6 +1447,8 @@ function writeGateMsg(filePath) {
'3. If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)',
"4. Quote the user's current instruction verbatim",
'',
batchSiblingWarning(safe),
'',
'Present the facts, then retry the same operation.'
].join('\n');
}
@@ -1143,6 +1463,7 @@ function condensedGateMsg(action, filePath, ordinal) {
return (
`[Fact-Forcing Gate] (denial #${ordinal} this session) First ${action} of ${safe}: ` +
"briefly state importers/callers, affected API, data schemas if any, and the user's verbatim instruction, then retry. " +
`${batchSiblingWarning(safe)} ` +
'(Use GATEGUARD_EXEMPT_GLOBS for path-scoped exemptions; ECC_GATEGUARD=off disables this gate.)'
);
}
+11 -4
View File
@@ -3,16 +3,23 @@
const { extractCommandSubstitutions } = require('../lib/shell-substitution');
/**
* Recognize the deliberately narrow passive sink supported by this parser.
* Shell operators and substitutions make the payload's destination ambiguous,
* so every other form retains the original input for fail-closed checks.
* Recognize proven-passive sinks whose heredoc payload is data, not a command
* stream. `cat` and `tee` (optionally path-qualified, or wrapped in
* `command`/`builtin`/`env`) only write stdin; they do not execute the body.
* Shell operators or substitution markers make the destination ambiguous, so
* every other form retains the original input for fail-closed checks.
*
* @param {string} line
* @returns {boolean}
*/
function isProvenPassiveHeredocLine(line) {
const trimmed = line.trim();
return /^cat(?=\s|[<>])/.test(trimmed) && !/[;&|()`]/.test(trimmed);
// Fail closed on control operators / grouping / command substitutions.
if (/[;&|()`]/.test(trimmed)) return false;
// Optional wrapper + optional path prefix + cat|tee, then args or redirect.
return /^(?:(?:command|builtin|env)\s+)?(?:(?:\.\/|\/(?:[\w.+-]+\/)*)?(?:cat|tee))(?=\s|[<>])/.test(
trimmed
);
}
/**
+114 -3
View File
@@ -182,6 +182,12 @@ function extractMcpTargetFromRaw(raw) {
}
function resolveServerConfig(serverName) {
// SECURITY: serverName flows into env-var lookup and shell-adjacent paths.
// Reject anything outside a strict token so config-controlled names cannot
// inject shell metachars ($(..), backticks, ;) downstream.
if (!/^[A-Za-z0-9_-]{1,64}$/.test(String(serverName || ''))) {
return null;
}
for (const filePath of configPaths()) {
const data = readJsonFile(filePath);
const server = data?.mcpServers?.[serverName]
@@ -306,9 +312,21 @@ function probeCommandServer(serverName, config) {
const command = config.command;
const args = Array.isArray(config.args) ? config.args.map(arg => String(arg)) : [];
const timeoutMs = envNumber('ECC_MCP_HEALTH_TIMEOUT_MS', DEFAULT_TIMEOUT_MS);
// SECURITY: config.env comes from repo-committed MCP configs. Never let it
// override process-critical loader vars that turn into code execution
// (LD_PRELOAD, DYLD_*, NODE_OPTIONS, PATH tampering, etc.).
const BLOCKED_ENV_PREFIXES = ['LD_', 'DYLD_', 'NODE_OPTIONS', 'NODE_PATH', 'PATH', 'PYTHONPATH', 'RUBYLIB', 'PERL5LIB'];
const rawEnv = (config.env && typeof config.env === 'object' && !Array.isArray(config.env) ? config.env : {});
const safeConfigEnv = {};
for (const [k, v] of Object.entries(rawEnv)) {
if (BLOCKED_ENV_PREFIXES.some(p => String(k).toUpperCase().startsWith(p))) {
continue;
}
safeConfigEnv[k] = String(v);
}
const mergedEnv = {
...process.env,
...(config.env && typeof config.env === 'object' && !Array.isArray(config.env) ? config.env : {})
...safeConfigEnv
};
let done = false;
@@ -515,6 +533,38 @@ function probeCommandServer(serverName, config) {
async function probeServer(serverName, resolvedConfig) {
const config = resolvedConfig.config;
// SECURITY: cloning a malicious repo must not auto-execute its MCP servers.
// Workspace configs (cwd .claude.json / .claude/settings.json) are untrusted
// by default; only probe them with explicit operator opt-in.
// Home configs (~/.claude.json) and explicit ECC_MCP_CONFIG_PATH remain allowed.
try {
const src = String(resolvedConfig.source || '');
const cwd = process.cwd();
const home = require('os').homedir();
const pathMod = require('path');
// A config file in the user's home directory (~/.claude.json or
// ~/.claude/settings.json) is always trusted regardless of cwd.
const isHomeSource = src === pathMod.join(home, '.claude.json')
|| src === pathMod.join(home, '.claude', 'settings.json')
|| src.startsWith(pathMod.join(home, '.claude') + pathMod.sep);
if (!isHomeSource) {
const isWorkspaceSource = src === pathMod.join(cwd, '.claude.json')
|| src === pathMod.join(cwd, '.claude', 'settings.json')
|| src.startsWith(cwd + pathMod.sep + '.claude' + pathMod.sep);
if (isWorkspaceSource && !/^(1|true|yes)$/i.test(String(process.env.ECC_MCP_ALLOW_WORKSPACE_PROBE || ''))) {
return {
ok: false,
failureCode: null,
reason: 'untrusted workspace MCP config skipped (set ECC_MCP_ALLOW_WORKSPACE_PROBE=1 to probe)',
source: resolvedConfig.source
};
}
}
} catch {
// Fail closed on path errors for workspace sources is handled below;
// continue to normal probing for non-workspace sources.
}
if (config.type === 'http' || config.url) {
const result = await requestHttp(config.url, config.headers || {}, envNumber('ECC_MCP_HEALTH_TIMEOUT_MS', DEFAULT_TIMEOUT_MS));
@@ -546,6 +596,15 @@ async function probeServer(serverName, resolvedConfig) {
}
function reconnectCommand(serverName) {
// SECURITY: reconnect commands are shell strings from env. Disabled by
// default; require explicit opt-in so a malicious .env/direnv cannot gain
// shell execution through this hook.
if (!/^(1|true|yes)$/i.test(String(process.env.ECC_MCP_RECONNECT_ALLOW || ''))) {
return null;
}
if (!/^[A-Za-z0-9_-]{1,64}$/.test(String(serverName || ''))) {
return null;
}
const key = `ECC_MCP_RECONNECT_${String(serverName).toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
const command = process.env[key] || process.env.ECC_MCP_RECONNECT_COMMAND || '';
if (!command.trim()) {
@@ -563,8 +622,60 @@ function attemptReconnect(serverName) {
return { attempted: false, success: false, reason: 'no reconnect command configured' };
}
const result = spawnSync(command, {
shell: true,
// SECURITY: never run reconnect strings through a shell. Split on
// whitespace (no glob/expansion/substitution) and spawn directly.
// Supports single/double quotes for paths with spaces (e.g. node
// "/tmp/dir with space/reconnect.js"). No variable, command, tilde, or
// glob expansion is performed. {server} was already validated above.
function splitReconnectCommand(s) {
const parts = [];
let cur = '';
let quote = null;
let inToken = false;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (quote) {
if (ch === quote) {
quote = null;
} else if (ch === '\\' && quote === '"' && i + 1 < s.length && (s[i + 1] === '"' || s[i + 1] === '\\')) {
cur += s[i + 1];
i++;
} else {
cur += ch;
}
} else if (ch === '"' || ch === "'") {
quote = ch;
inToken = true;
} else if (/\s/.test(ch)) {
if (inToken) {
parts.push(cur);
cur = '';
inToken = false;
}
} else {
cur += ch;
inToken = true;
}
}
if (quote) {
return null; // unbalanced quote
}
if (inToken) {
parts.push(cur);
}
return parts;
}
const parts = splitReconnectCommand(String(command).trim());
if (!parts || parts.length === 0) {
return { attempted: false, success: false, reason: 'invalid reconnect command' };
}
const [bin, ...argv] = parts;
if (/[&|<>^%!`$();]/.test(bin) || argv.some(a => /[`$]/.test(a))) {
return { attempted: false, success: false, reason: 'reconnect command contains unsafe characters' };
}
const result = spawnSync(bin, argv, {
shell: false,
env: process.env,
cwd: process.cwd(),
encoding: 'utf8',
+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',
+26 -4
View File
@@ -22,9 +22,31 @@ if [[ "$ENABLED" != "yes" ]]; then
exit 0
fi
SCRIPT_PATH="${PLUGIN_ROOT}/${REL_SCRIPT_PATH}"
if [[ ! -f "$SCRIPT_PATH" ]]; then
echo "[Hook] Script not found for ${HOOK_ID}: ${SCRIPT_PATH}" >&2
# Reject traversal / absolute / env-escape paths before touching the filesystem.
# Mirrors the containment check in run-with-flags.js (resolvedRoot prefix).
case "$REL_SCRIPT_PATH" in
/*|\\*|~*|*..*|*\$*|*\`*|*\|*|*\;*|*\&*|*\<*|*\>*|*\"*|*\'*|*\ *|*" "*)
echo "[Hook] Path traversal rejected for ${HOOK_ID}: ${REL_SCRIPT_PATH}" >&2
printf '%s' "$INPUT"
exit 0
;;
esac
# Canonicalize PLUGIN_ROOT (CLAUDE_PLUGIN_ROOT is env-controlled) and the
# candidate script path, then enforce containment inside the plugin root.
PLUGIN_ROOT_CANON="$(realpath -m "$PLUGIN_ROOT" 2>/dev/null || readlink -f "$PLUGIN_ROOT" 2>/dev/null || printf '%s' "$PLUGIN_ROOT")"
SCRIPT_PATH="${PLUGIN_ROOT_CANON}/${REL_SCRIPT_PATH}"
SCRIPT_CANON="$(realpath -m "$SCRIPT_PATH" 2>/dev/null || readlink -f "$SCRIPT_PATH" 2>/dev/null || printf '%s' "$SCRIPT_PATH")"
case "$SCRIPT_CANON" in
"$PLUGIN_ROOT_CANON"/*) ;;
*)
echo "[Hook] Path traversal rejected for ${HOOK_ID}: ${REL_SCRIPT_PATH}" >&2
printf '%s' "$INPUT"
exit 0
;;
esac
if [[ ! -f "$SCRIPT_CANON" ]]; then
echo "[Hook] Script not found for ${HOOK_ID}: ${SCRIPT_CANON}" >&2
printf '%s' "$INPUT"
exit 0
fi
@@ -33,4 +55,4 @@ fi
# This is needed by scripts like observe.sh that behave differently for PreToolUse vs PostToolUse
HOOK_PHASE="${HOOK_ID%%:*}"
printf '%s' "$INPUT" | "$SCRIPT_PATH" "$HOOK_PHASE"
printf '%s' "$INPUT" | "$SCRIPT_CANON" "$HOOK_PHASE"
+12 -1
View File
@@ -227,7 +227,18 @@ async function main() {
// which would interfere with the parent process or cause double execution.
let hookModule;
const src = fs.readFileSync(scriptPath, 'utf8');
const hasRunExport = /\bmodule\.exports\b/.test(src) && /\brun\b/.test(src);
// Gate require() on concrete export syntax, not a bare word match: the old
// /\bmodule\.exports\b/ && /\brun\b/ test fired on comments, strings, and
// unrelated properties, causing require() — and its module-scope side
// effects — to run for hooks that export no run(). Still lexical (no parser
// dependency), but requires an actual export assignment form.
const RUN_EXPORT_PATTERNS = [
/module\.exports\s*\.\s*run\s*=/,
/exports\s*\.\s*run\s*=/,
/module\.exports\s*=\s*\{[^}]*\brun\b/,
/module\.exports\s*=\s*(async\s+)?function\s+run\b/,
];
const hasRunExport = RUN_EXPORT_PATTERNS.some(re => re.test(src));
if (hasRunExport) {
try {
+11 -6
View File
@@ -11,7 +11,7 @@
const path = require('path');
const fs = require('fs');
const { getSessionsDir, getDateString, getTimeString, getSessionIdShort, sanitizeSessionId, getProjectName, ensureDir, readFile, writeFile, runCommand, stripAnsi, log } = require('../lib/utils');
const { getSessionsDir, getDateString, getTimeString, getSessionIdShort, sanitizeSessionId, getProjectName, getRepoIdentity, ensureDir, readFile, writeFile, runCommand, stripAnsi, log } = require('../lib/utils');
const { generateSessionSummary, getContextRemainingPct, getContextThreshold } = require('../lib/llm-summary');
const SUMMARY_START_MARKER = '<!-- ECC:SUMMARY:START -->';
@@ -130,7 +130,8 @@ function getSessionMetadata() {
return {
project: getProjectName() || 'unknown',
branch: branchResult.success ? branchResult.output : 'unknown',
worktree: process.cwd()
worktree: process.cwd(),
repo: getRepoIdentity()
};
}
@@ -145,16 +146,20 @@ function buildSessionHeader(today, currentTime, metadata, existingContent = '')
const date = extractHeaderField(existingContent, 'Date') || today;
const started = extractHeaderField(existingContent, 'Started') || currentTime;
return [
const lines = [
heading,
`**Date:** ${date}`,
`**Started:** ${started}`,
`**Last Updated:** ${currentTime}`,
`**Project:** ${metadata.project}`,
`**Branch:** ${metadata.branch}`,
`**Worktree:** ${metadata.worktree}`,
''
].join('\n');
`**Worktree:** ${metadata.worktree}`
];
if (metadata.repo) {
lines.push(`**Repo:** ${metadata.repo}`);
}
lines.push('');
return lines.join('\n');
}
function mergeSessionHeader(content, today, currentTime, metadata) {
+48 -6
View File
@@ -14,6 +14,8 @@ const {
getSessionSearchDirs,
getLearnedSkillsDir,
getProjectName,
getRepoIdentity,
sameRepoIdentity,
findFiles,
ensureDir,
readFile,
@@ -254,6 +256,7 @@ function pruneExpiredSessions(searchDirs, retentionDays) {
* Session files written by session-end.js contain header fields like:
* **Project:** my-project
* **Worktree:** /path/to/project
* **Repo:** /path/to/main-worktree/.git
*
* This function reads each session file once, caching its content, and
* returns both the selected session object and its already-read content
@@ -261,11 +264,18 @@ function pruneExpiredSessions(searchDirs, retentionDays) {
*
* Priority (highest to lowest):
* 1. Exact worktree (cwd) match — most recent
* 2. Same project name match for legacy sessions without Worktree metadata
* 3. No injection when sessions belong to a different worktree/project
* 2. Repository identity match: the session was recorded in another
* worktree or subdirectory of the same repository. Identity is the
* main worktree's common git dir (issue #3160), taken from the
* recorded **Repo:** field or resolved from the recorded **Worktree:**
* path for older session files. Unrelated repositories never match.
* 3. Same project name match for legacy sessions without Worktree/Repo
* metadata
* 4. No injection when sessions belong to a different repository
*
* Sessions are already sorted newest-first, so the first match in each
* category wins.
* category wins; the scan continues past repository and project matches so
* an exact worktree match always takes precedence.
*
* @param {Array<Object>} sessions - Deduplicated session list, sorted newest-first.
* @param {string} cwd - Current working directory (process.cwd()).
@@ -279,7 +289,17 @@ function selectMatchingSession(sessions, cwd, currentProject) {
// Normalize cwd once outside the loop to avoid repeated syscalls
const normalizedCwd = normalizePath(cwd);
const currentRepoId = getRepoIdentity(cwd);
const repoIdByWorktree = new Map();
const repoIdOfRecordedWorktree = (recordedWorktree) => {
if (!repoIdByWorktree.has(recordedWorktree)) {
repoIdByWorktree.set(recordedWorktree, getRepoIdentity(recordedWorktree));
}
return repoIdByWorktree.get(recordedWorktree);
};
let repoMatch = null;
let repoMatchContent = null;
let projectMatch = null;
let projectMatchContent = null;
let readableSessions = 0;
@@ -289,9 +309,11 @@ function selectMatchingSession(sessions, cwd, currentProject) {
if (!content) continue;
readableSessions++;
// Extract **Worktree:** field
// Extract **Worktree:** and **Repo:** fields
const worktreeMatch = content.match(/\*\*Worktree:\*\*\s*(.+)$/m);
const sessionWorktree = worktreeMatch ? worktreeMatch[1].trim() : '';
const repoFieldMatch = content.match(/\*\*Repo:\*\*\s*(.+)$/m);
const sessionRepo = repoFieldMatch ? repoFieldMatch[1].trim() : '';
// Exact worktree match — best possible, return immediately
// Normalize both paths to handle symlinks and case-insensitive filesystems
@@ -299,9 +321,25 @@ function selectMatchingSession(sessions, cwd, currentProject) {
return { session, content, matchReason: 'worktree' };
}
// Repository identity match (#3160): the summary lookup is scoped to the
// repository, not the cwd path, so a session recorded in worktree A is
// eligible in worktree B only when both resolve to the same common git
// dir. Unrelated repositories never share.
if (!repoMatch && currentRepoId && (sessionRepo || sessionWorktree)) {
// The recorded Repo field may carry a different path form than the
// live lookup (8.3 short names on Windows runners, case, separators),
// so compare with filesystem-identity fallback rather than ===.
const sessionRepoId = sessionRepo || repoIdOfRecordedWorktree(sessionWorktree);
if (sessionRepoId && sameRepoIdentity(sessionRepoId, currentRepoId)) {
repoMatch = session;
repoMatchContent = content;
}
}
// Project name match is only safe for legacy session files written before
// Worktree metadata existed. A different explicit Worktree is not a match.
if (!projectMatch && currentProject && !sessionWorktree) {
// Worktree/Repo metadata existed. A different explicit Worktree or Repo
// is not a match.
if (!projectMatch && currentProject && !sessionWorktree && !sessionRepo) {
const projectFieldMatch = content.match(/\*\*Project:\*\*\s*(.+)$/m);
const sessionProject = projectFieldMatch ? projectFieldMatch[1].trim() : '';
if (sessionProject && sessionProject === currentProject) {
@@ -311,6 +349,10 @@ function selectMatchingSession(sessions, cwd, currentProject) {
}
}
if (repoMatch) {
return { session: repoMatch, content: repoMatchContent, matchReason: 'repo' };
}
if (projectMatch) {
return { session: projectMatch, content: projectMatchContent, matchReason: 'project' };
}
+7
View File
@@ -132,6 +132,13 @@ function printHumanPlan(plan, dryRun) {
}
}
if (Array.isArray(plan.reconciledExcludedPaths) && plan.reconciledExcludedPaths.length > 0) {
console.log('\nReconciled excluded paths:');
for (const removedPath of plan.reconciledExcludedPaths) {
console.log(`- removed ${removedPath}`);
}
}
if (!dryRun) {
console.log(`\nDone. Install-state written to ${plan.installStatePath}`);
}
-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' }
);
+9 -2
View File
@@ -131,8 +131,15 @@ function removeOpenedRegularFile(filePath, opened) {
function atomicWriteJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
fs.renameSync(tempPath, filePath);
try {
fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
fs.renameSync(tempPath, filePath);
} catch (error) {
// A failed write/rename must not leave a .tmp-<pid>-<timestamp> file
// beside the canonical state file; repeated failures would accumulate them.
fs.rmSync(tempPath, { force: true });
throw error;
}
}
function readState(statePath) {
@@ -26,8 +26,8 @@ function renderControlPlaneViewHtml() {
header nav { margin-left: auto; font-size: 12px; }
header nav a { color: #8b949e; margin-left: 12px; text-decoration: none; }
header nav a:hover { color: #e6edf3; }
#wrap { display: grid; grid-template-columns: 1fr 360px; height: calc(100vh - 49px); }
#stage { position: relative; border-right: 1px solid #1f2630; }
#wrap { display: grid; grid-template-columns: 1fr 360px; grid-template-rows: minmax(0, 1fr); height: calc(100vh - 49px); }
#stage { position: relative; height: 100%; min-height: 0; border-right: 1px solid #1f2630; }
canvas { width: 100%; height: 100%; display: block; }
#side { padding: 12px 14px; overflow-y: auto; }
#side h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: #8b949e; margin: 14px 0 8px; }
+64 -10
View File
@@ -27,11 +27,12 @@ function renderProximityVizHtml() {
header { display: flex; align-items: baseline; gap: 12px; padding: 12px 16px; border-bottom: 1px solid #1f2630; }
header h1 { font-size: 15px; margin: 0; }
header .sub { color: #8b949e; font-size: 12px; }
#wrap { display: grid; grid-template-columns: 1fr 320px; height: calc(100vh - 49px); }
#stage { position: relative; }
#wrap { display: grid; grid-template-columns: 1fr 320px; grid-template-rows: minmax(0, 1fr); height: calc(100vh - 49px); }
#stage { position: relative; height: 100%; min-height: 0; }
canvas { width: 100%; height: 100%; display: block; }
#side { border-left: 1px solid #1f2630; padding: 12px 14px; overflow-y: auto; }
#side h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: #8b949e; margin: 0 0 8px; }
#side h2:not(:first-child) { margin-top: 16px; }
.adv { border: 1px solid #1f2630; border-radius: 8px; padding: 8px 10px; margin-bottom: 8px; }
.adv.resolution { border-color: #b3402f; }
.adv.advisory { border-color: #9a6700; }
@@ -41,8 +42,11 @@ function renderProximityVizHtml() {
.adv .who { color: #c9d1d9; }
.adv .act { color: #8b949e; font-size: 12px; margin-top: 3px; }
.empty { color: #6e7681; }
.agent-row { display: flex; gap: 8px; align-items: baseline; padding: 3px 0; font-size: 12px; }
.agent-row .who { color: #c9d1d9; overflow-wrap: anywhere; }
.agent-row .risk { margin-left: auto; color: #8b949e; white-space: nowrap; }
#legend { position: absolute; left: 12px; bottom: 12px; font-size: 11px; color: #8b949e; background: rgba(11,14,20,.7); padding: 6px 8px; border-radius: 6px; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; vertical-align: middle; }
.shape { display: inline-block; width: 12px; margin-right: 5px; text-align: center; font-weight: 700; }
</style>
</head>
<body>
@@ -53,16 +57,18 @@ function renderProximityVizHtml() {
</header>
<div id="wrap">
<div id="stage">
<canvas id="c"></canvas>
<canvas id="c" role="img" aria-label="Agent airspace visualization. See the Agents panel for a text alternative.">Agent airspace visualization; see the Agents panel for per-agent risk.</canvas>
<div id="legend">
<div><span class="dot" style="background:#3fb950"></span>clear</div>
<div><span class="dot" style="background:#e3b341"></span>traffic advisory (transmit)</div>
<div><span class="dot" style="background:#ff7b72"></span>resolution (steer)</div>
<div><span class="shape" style="color:#3fb950">●</span>clear</div>
<div><span class="shape" style="color:#e3b341">■</span>traffic advisory (transmit)</div>
<div><span class="shape" style="color:#ff7b72">▲</span>resolution (steer)</div>
</div>
</div>
<div id="side">
<h2>Advisories</h2>
<div id="advisories"><div class="empty">No advisories - airspace clear.</div></div>
<h2>Agents</h2>
<div id="agents"><div class="empty">No agents.</div></div>
</div>
</div>
<script>
@@ -81,12 +87,33 @@ function renderProximityVizHtml() {
}
window.addEventListener('resize', resize);
function riskLevel(risk) {
if (risk >= 0.7) return 'resolution';
if (risk >= 0.35) return 'advisory';
return 'clear';
}
function riskColor(risk) {
if (risk >= 0.7) return '#ff7b72';
if (risk >= 0.35) return '#e3b341';
return '#3fb950';
}
function drawRiskMarker(x, y, radius, level) {
ctx.beginPath();
if (level === 'resolution') {
ctx.moveTo(x, y - radius);
ctx.lineTo(x + radius, y + radius);
ctx.lineTo(x - radius, y + radius);
ctx.closePath();
} else if (level === 'advisory') {
ctx.rect(x - radius, y - radius, radius * 2, radius * 2);
} else {
ctx.arc(x, y, radius, 0, Math.PI * 2);
}
ctx.fill();
}
// 3D to 2D: rotate around Y, simple perspective.
function project(p, w, h) {
var x = p[0], y = p[1] || 0, z = p[2] || 0;
@@ -122,8 +149,9 @@ function renderProximityVizHtml() {
var ag = state.positions[k];
var p = pos[ag.agentId];
var radius = (6 + Math.sqrt(ag.fileCount || 1) * 3) * p[2];
ctx.fillStyle = riskColor(state.riskByAgent[ag.agentId] || 0);
ctx.beginPath(); ctx.arc(p[0], p[1], radius, 0, Math.PI * 2); ctx.fill();
var agentRisk = state.riskByAgent[ag.agentId] || 0;
ctx.fillStyle = riskColor(agentRisk);
drawRiskMarker(p[0], p[1], radius, riskLevel(agentRisk));
ctx.fillStyle = '#c9d1d9';
ctx.font = '11px -apple-system, system-ui, sans-serif';
ctx.fillText(String(ag.agentId).slice(0, 18), p[0] + radius + 4, p[1] + 3);
@@ -155,6 +183,25 @@ function renderProximityVizHtml() {
});
}
function renderAgents() {
var box = document.getElementById('agents');
box.textContent = '';
if (!state.positions.length) {
var empty = document.createElement('div'); empty.className = 'empty';
empty.textContent = 'No agents.'; box.appendChild(empty); return;
}
state.positions.forEach(function (agent) {
var risk = state.riskByAgent[agent.agentId] || 0;
var row = document.createElement('div'); row.className = 'agent-row';
var who = document.createElement('span'); who.className = 'who';
who.textContent = String(agent.agentId); row.appendChild(who);
var riskText = document.createElement('span'); riskText.className = 'risk';
riskText.textContent = Math.round(risk * 100) + '% - ' + riskLevel(risk);
row.appendChild(riskText);
box.appendChild(row);
});
}
function applySnapshot(prox) {
state.positions = prox.positions || [];
state.links = prox.links || [];
@@ -166,13 +213,20 @@ function renderProximityVizHtml() {
});
state.riskByAgent = risk;
renderAdvisories();
renderAgents();
var c = prox.counts || {};
canvas.setAttribute('aria-label',
(c.agents || state.positions.length) + ' agents in airspace, ' +
(c.advisories || state.advisories.length) + ' advisories. See the Agents panel for per-agent risk.');
document.getElementById('status').textContent =
(c.agents || 0) + ' agents - ' + (c.advisories || 0) + ' advisories - ' + (c.resolutions || 0) + ' steering';
}
function poll() {
fetch('/api/proximity').then(function (r) { return r.json(); }).then(function (data) {
fetch('/api/proximity').then(function (r) {
if (!r.ok) throw new Error('http ' + r.status);
return r.json();
}).then(function (data) {
applySnapshot(data && data.enabled ? data : (data || {}));
}).catch(function () {
document.getElementById('status').textContent = 'offline';
+24 -3
View File
@@ -9,6 +9,8 @@ const { loadInstallManifests } = require('./install-manifests');
const { readInstallState, validateInstallState } = require('./install-state');
const { assertWithinTrustedRoot } = require('./path-safety');
const { createInstallPlanFromRequest } = require('./install/runtime');
const { assertNoNewUserOwnedFile, prepareUserOwnedFileGuard } = require('./install/ownership-guard');
const { isCodexUserConfig } = require('./install/codex-user-config');
const { getRecordedHookConsent } = require('./install/hook-consent');
const {
prepareClaudeSkillMigration,
@@ -1871,7 +1873,7 @@ function assertValidInstallStateForWrite(state, label) {
throw new Error(`Invalid install-state (${label}): ${details}`);
}
function writeRefreshedInstallState(record, statePreview) {
function writeRefreshedInstallState(record, statePreview, writtenPaths = []) {
const trustedStatePreview = buildAdapterDerivedStatePreview(statePreview, record);
const stateWithCurrentDigests = {
...trustedStatePreview,
@@ -1879,6 +1881,19 @@ function writeRefreshedInstallState(record, statePreview) {
if (!operation.destinationPath) {
return { ...operation };
}
// Refreshing a ledger is not a file write. Keep the last installed digest
// for untouched shared configs so a concurrent user edit is never claimed.
if (isCodexUserConfig(record, operation)
&& !writtenPaths.some(writtenPath => path.relative(writtenPath, operation.destinationPath) === '')) {
const previousOperation = (record.state.operations || []).find(previous => (
previous.destinationPath
&& path.relative(previous.destinationPath, operation.destinationPath) === ''
));
const { contentSha256: _plannedDigest, ...operationWithoutDigest } = operation;
return previousOperation && previousOperation.contentSha256
? { ...operationWithoutDigest, contentSha256: previousOperation.contentSha256 }
: operationWithoutDigest;
}
try {
const contentSha256 = crypto.createHash('sha256')
.update(readFileNoFollow(operation.destinationPath))
@@ -1908,7 +1923,10 @@ function prepareRepairMigration(plan, record) {
installStatePath: record.installStatePath,
statePreview: buildAdapterDerivedStatePreview(plan.statePreview, record),
};
const migration = prepareClaudeSkillMigration(trustedPlan);
const skillMigration = prepareClaudeSkillMigration(trustedPlan);
const migration = record.adapter.id === 'codex-home'
? prepareUserOwnedFileGuard(trustedPlan, skillMigration)
: skillMigration;
return {
migration,
plan: {
@@ -2157,6 +2175,9 @@ function repairInstalledStates(options = {}) {
}
for (const operation of repairOperations) {
if (record.adapter.id === 'codex-home') {
assertNoNewUserOwnedFile(migration, operation, desiredPlan);
}
const repairedPath = executeRepairOperation(
context.repoRoot,
operation,
@@ -2192,7 +2213,7 @@ function repairInstalledStates(options = {}) {
installedAt: record.state.installedAt,
source: { ...record.state.source },
};
writeRefreshedInstallState(record, statePreviewToWrite);
writeRefreshedInstallState(record, statePreviewToWrite, repairedPaths);
return {
adapter: record.adapter,
+3 -1
View File
@@ -1,6 +1,7 @@
const path = require('path');
const {
HOME_INSTALL_EXCLUDED_SOURCE_PATHS,
createInstallTargetAdapter,
createRemappedOperation,
isForeignPlatformPath,
@@ -52,6 +53,7 @@ module.exports = createInstallTargetAdapter({
kind: 'home',
rootSegments: ['.claude'],
installStatePathSegments: ['ecc', 'install-state.json'],
excludedSourcePaths: HOME_INSTALL_EXCLUDED_SOURCE_PATHS,
nativeRootRelativePath: '.claude-plugin',
planOperations(input, adapter) {
const modules = Array.isArray(input.modules)
@@ -66,7 +68,7 @@ module.exports = createInstallTargetAdapter({
return modules.flatMap(module => {
const paths = Array.isArray(module.paths) ? module.paths : [];
return paths
.filter(p => !isForeignPlatformPath(p, adapter.target))
.filter(p => !isForeignPlatformPath(p, adapter.target) && !adapter.excludesSourcePath(p))
.flatMap(sourceRelativePath => {
if (
module.id === 'hooks-runtime'
+17 -1
View File
@@ -9,6 +9,7 @@ const {
} = require('./helpers');
const CLAUDE_ECC_NAMESPACE = 'ecc';
const CLAUDE_PROJECT_COMMONJS_PACKAGE = 'manifests/install-assets/claude-project-scripts-package.json';
function getClaudeManagedDestinationPath(adapter, sourceRelativePath, input) {
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
@@ -65,7 +66,7 @@ module.exports = createInstallTargetAdapter({
return modules.flatMap(module => {
const paths = Array.isArray(module.paths) ? module.paths : [];
return paths
const operations = paths
.filter(p => !isForeignPlatformPath(p, 'claude'))
.flatMap(sourceRelativePath => {
if (
@@ -93,6 +94,21 @@ module.exports = createInstallTargetAdapter({
return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)];
});
if (module.id !== 'hooks-runtime') {
return operations;
}
return [
...['hooks', 'lib'].map(directory => createRemappedOperation(
adapter,
module.id,
CLAUDE_PROJECT_COMMONJS_PACKAGE,
path.join(adapter.resolveRoot(planningInput), 'scripts', directory, 'package.json'),
{ strategy: 'preserve-relative-path' }
)),
...operations,
];
});
},
});
+2 -1
View File
@@ -1,4 +1,4 @@
const { createInstallTargetAdapter } = require('./helpers');
const { HOME_INSTALL_EXCLUDED_SOURCE_PATHS, createInstallTargetAdapter } = require('./helpers');
module.exports = createInstallTargetAdapter({
id: 'codex-home',
@@ -7,4 +7,5 @@ module.exports = createInstallTargetAdapter({
rootSegments: ['.codex'],
installStatePathSegments: ['ecc-install-state.json'],
nativeRootRelativePath: '.codex',
excludedSourcePaths: HOME_INSTALL_EXCLUDED_SOURCE_PATHS,
});
+23 -2
View File
@@ -24,6 +24,14 @@ const PLATFORM_SOURCE_PATH_OWNERS = Object.freeze({
'.adal': 'adal',
});
// Source paths that home installs must never copy into a harness home
// directory. `.agents` is ECC's repo-local skills/plugins staging area:
// project targets such as kimi and antigravity consume it, but neither
// Claude Code nor Codex reads a `.agents` directory under ~/.claude or
// ~/.codex, so copying it there produces unread files that doctor flags as
// drift and repair keeps restoring.
const HOME_INSTALL_EXCLUDED_SOURCE_PATHS = Object.freeze(['.agents']);
function normalizeRelativePath(relativePath) {
return String(relativePath || '')
.replace(/\\/g, '/')
@@ -43,6 +51,14 @@ function isForeignPlatformPath(sourceRelativePath, adapterTarget) {
return false;
}
function isExcludedSourcePath(sourceRelativePath, excludedSourcePaths = []) {
const normalizedPath = normalizeRelativePath(sourceRelativePath);
return excludedSourcePaths.some(excluded => {
const prefix = normalizeRelativePath(excluded);
return prefix !== '' && (normalizedPath === prefix || normalizedPath.startsWith(`${prefix}/`));
});
}
function resolveBaseRoot(scope, input = {}) {
if (scope === 'home') {
return input.homeDir || os.homedir();
@@ -351,6 +367,9 @@ function createInstallTargetAdapter(config) {
strategy: adapter.determineStrategy(normalizedSourcePath),
});
},
excludesSourcePath(sourceRelativePath) {
return isExcludedSourcePath(sourceRelativePath, config.excludedSourcePaths);
},
planOperations(input = {}) {
if (typeof config.planOperations === 'function') {
return config.planOperations(input, adapter);
@@ -360,7 +379,7 @@ function createInstallTargetAdapter(config) {
return input.modules.flatMap(module => {
const paths = Array.isArray(module.paths) ? module.paths : [];
return paths
.filter(p => !isForeignPlatformPath(p, config.target))
.filter(p => !isForeignPlatformPath(p, config.target) && !adapter.excludesSourcePath(p))
.map(sourceRelativePath => adapter.createScaffoldOperation(
module.id,
sourceRelativePath,
@@ -372,7 +391,7 @@ function createInstallTargetAdapter(config) {
const module = input.module || {};
const paths = Array.isArray(module.paths) ? module.paths : [];
return paths
.filter(p => !isForeignPlatformPath(p, config.target))
.filter(p => !isForeignPlatformPath(p, config.target) && !adapter.excludesSourcePath(p))
.map(sourceRelativePath => adapter.createScaffoldOperation(
module.id,
sourceRelativePath,
@@ -399,6 +418,8 @@ function createInstallTargetAdapter(config) {
}
module.exports = {
HOME_INSTALL_EXCLUDED_SOURCE_PATHS,
isExcludedSourcePath,
buildValidationIssue,
createFlatFileOperations,
createFlatRuleOperations,
+24 -3
View File
@@ -34,6 +34,10 @@ const {
preserveUnwrittenFiles,
} = require('./ownership-guard');
const { cleanupLegacyOpencodeInstall } = require('./opencode-legacy-migration');
const {
completeExcludedPathsReconciliation,
prepareExcludedPathsReconciliation,
} = require('./excluded-paths-reconciliation');
const { buildInstallIndex, rewriteRelativeLinks } = require('./link-rewrite');
const { adaptAntigravityAgent } = require('./antigravity-agent');
@@ -449,9 +453,12 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
if (typeof beforeInstallStateRead === 'function') {
beforeInstallStateRead({ plan });
}
const migration = prepareHookConsentMigration(
const migration = prepareExcludedPathsReconciliation(
plan,
prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan))
prepareHookConsentMigration(
plan,
prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan))
)
);
const appliedPlan = {
...plan,
@@ -491,7 +498,7 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
if (typeof beforeOperationWrite === 'function') {
beforeOperationWrite({ plan: appliedPlan, operation });
}
assertNoNewUserOwnedFile(migration, operation);
assertNoNewUserOwnedFile(migration, operation, appliedPlan);
if (
operation.kind === 'update-claude-settings'
@@ -666,17 +673,31 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
];
}
let excludedPathsRemoved = [];
let excludedPathsWarnings = [];
try {
const excludedReconciliation = completeExcludedPathsReconciliation(migration, appliedPlan);
excludedPathsRemoved = excludedReconciliation.removedPaths;
excludedPathsWarnings = excludedReconciliation.warnings;
} catch (error) {
excludedPathsWarnings = [
`Excluded-paths reconciliation did not finish: ${error.message}. Previously managed files under excluded source paths were preserved; remove them manually or rerun the install.`,
];
}
return {
...plan,
statePreview: finalState,
plannedOperations: [...plan.operations],
operations: migration.appliedOperations,
skippedOperations: migration.skippedOperations,
reconciledExcludedPaths: excludedPathsRemoved,
warnings: [
...(Array.isArray(plan.warnings) ? plan.warnings : []),
...migration.warnings,
...antigravityMigrationWarnings,
...opencodeMigrationWarnings,
...excludedPathsWarnings,
],
applied: true,
};
+54
View File
@@ -0,0 +1,54 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { assertWithinTrustedRoot } = require('../path-safety');
function isCodexUserConfig(plan, operation) {
if (plan.adapter.id !== 'codex-home' || operation.kind !== 'copy-file') {
return false;
}
const relativePath = path.relative(plan.targetRoot, operation.destinationPath);
const name = process.platform === 'win32' ? relativePath.toLowerCase() : relativePath;
return name === 'config.toml' || name === (process.platform === 'win32' ? 'agents.md' : 'AGENTS.md');
}
function readConfigDigest(plan, destinationPath) {
assertWithinTrustedRoot(destinationPath, plan.targetRoot, 'inspect Codex user configuration');
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
const descriptor = fs.openSync(destinationPath, flags);
try {
const opened = fs.fstatSync(descriptor, { bigint: true });
const current = fs.lstatSync(destinationPath, { bigint: true });
if (!opened.isFile() || !current.isFile() || current.isSymbolicLink()
|| opened.ino !== current.ino || opened.dev !== current.dev) {
throw new Error(`Refusing to inspect changed Codex configuration: ${destinationPath}`);
}
assertWithinTrustedRoot(destinationPath, plan.targetRoot, 'inspect Codex user configuration');
return crypto.createHash('sha256').update(fs.readFileSync(descriptor)).digest('hex');
} finally {
fs.closeSync(descriptor);
}
}
function hasEditedCodexUserConfig(plan, operation, previousOperation) {
if (!isCodexUserConfig(plan, operation)) {
return false;
}
let digest;
try {
digest = readConfigDigest(plan, operation.destinationPath);
} catch (error) {
if (error.code === 'ENOENT') {
return false; // A missing scaffold can still be restored.
}
throw error;
}
// Compare with the bytes ECC actually installed, never the newest template.
// Old ledgers without a digest cannot prove that an existing file is unchanged.
const recorded = previousOperation && previousOperation.contentSha256;
return !/^[a-f0-9]{64}$/i.test(recorded || '') || digest !== recorded.toLowerCase();
}
module.exports = { hasEditedCodexUserConfig, isCodexUserConfig };
@@ -0,0 +1,230 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { readInstallState } = require('../install-state');
const { assertWithinTrustedRoot } = require('../path-safety');
const { getInstallTargetAdapter } = require('../install-targets/registry');
/**
* Upgrade reconciliation for excluded source paths (issue #3116).
*
* Adapters can declare `excludedSourcePaths` (today: `.agents` for the Claude
* and Codex home targets). The exclusion stops new copy operations from being
* planned, but a home install created before the exclusion still has the
* copied files on disk and the copy operations recorded in install-state, so
* doctor keeps reporting drift and repair keeps restoring files the target
* never reads.
*
* prepareExcludedPathsReconciliation runs before the new state is written: it
* reads the previous install-state and drops the recorded managed operations
* whose source path is now excluded. completeExcludedPathsReconciliation runs
* after a successful apply: it removes the files those operations recorded,
* but only when the recorded content digest still matches, and prunes the
* emptied directories. Files the state does not own, modified files,
* symlinks, and anything outside the target root are preserved with a
* warning.
*/
function comparablePath(filePath) {
const resolvedPath = path.resolve(filePath);
return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath;
}
function getReconcilingAdapter(plan) {
if (!plan || typeof plan.target !== 'string') {
return null;
}
let adapter;
try {
adapter = getInstallTargetAdapter(plan.target);
} catch {
return null;
}
return adapter && typeof adapter.excludesSourcePath === 'function' ? adapter : null;
}
function isRecordedExcludedManagedOperation(adapter, operation) {
return Boolean(
operation
&& operation.ownership === 'managed'
&& typeof operation.destinationPath === 'string'
&& typeof operation.sourceRelativePath === 'string'
&& adapter.excludesSourcePath(operation.sourceRelativePath)
);
}
function filterStateOperations(state, shouldDrop) {
if (!state || !Array.isArray(state.operations)) {
return state;
}
return {
...state,
operations: state.operations.filter(operation => !shouldDrop(operation)),
};
}
function prepareExcludedPathsReconciliation(plan, migration) {
const adapter = getReconcilingAdapter(plan);
if (!adapter || !fs.existsSync(plan.installStatePath)) {
return { ...migration, excludedPathCandidates: [] };
}
const previousState = readInstallState(plan.installStatePath);
const candidates = ((previousState && previousState.operations) || [])
.filter(operation => isRecordedExcludedManagedOperation(adapter, operation));
if (candidates.length === 0) {
return { ...migration, excludedPathCandidates: [] };
}
const droppedDestinations = new Set(
candidates.map(operation => comparablePath(operation.destinationPath))
);
const shouldDrop = operation => Boolean(
operation
&& typeof operation.destinationPath === 'string'
&& droppedDestinations.has(comparablePath(operation.destinationPath))
&& typeof operation.sourceRelativePath === 'string'
&& adapter.excludesSourcePath(operation.sourceRelativePath)
);
return {
...migration,
bridgeState: filterStateOperations(migration.bridgeState, shouldDrop),
finalState: filterStateOperations(migration.finalState, shouldDrop),
excludedPathCandidates: candidates,
};
}
function pathExists(filePath) {
try {
fs.lstatSync(filePath);
return true;
} catch (error) {
if (error && error.code === 'ENOENT') {
return false;
}
throw error;
}
}
function hashFileNoFollow(filePath) {
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
const descriptor = fs.openSync(filePath, flags);
try {
const before = fs.fstatSync(descriptor, { bigint: true });
if (!before.isFile()) {
throw new Error(`Refusing to read a non-file at ${filePath}`);
}
const content = fs.readFileSync(descriptor);
const after = fs.fstatSync(descriptor, { bigint: true });
const finalPathStat = fs.lstatSync(filePath, { bigint: true });
const unchanged = before.dev === after.dev
&& before.ino === after.ino
&& before.size === after.size
&& after.dev === finalPathStat.dev
&& after.ino === finalPathStat.ino
&& after.size === finalPathStat.size;
if (finalPathStat.isSymbolicLink() || !finalPathStat.isFile() || !unchanged) {
throw new Error(`Refusing to read a file that changed during validation: ${filePath}`);
}
return crypto.createHash('sha256').update(content).digest('hex');
} finally {
fs.closeSync(descriptor);
}
}
function removeEmptyParents(startPath, targetRoot) {
let currentPath = path.dirname(startPath);
while (comparablePath(currentPath) !== comparablePath(targetRoot)) {
const safePath = assertWithinTrustedRoot(
currentPath,
targetRoot,
'reconcile excluded install paths'
);
if (!pathExists(safePath)) {
currentPath = path.dirname(safePath);
continue;
}
const stat = fs.lstatSync(safePath);
if (!stat.isDirectory() || stat.isSymbolicLink() || fs.readdirSync(safePath).length > 0) {
return;
}
fs.rmdirSync(safePath);
currentPath = path.dirname(safePath);
}
}
function completeExcludedPathsReconciliation(migration, plan) {
const candidates = (migration && migration.excludedPathCandidates) || [];
const removedPaths = [];
const warnings = [];
for (const candidate of candidates) {
if (candidate.kind !== 'copy-file') {
continue;
}
let safePath;
try {
safePath = assertWithinTrustedRoot(
candidate.destinationPath,
plan.targetRoot,
'reconcile excluded install paths'
);
} catch (error) {
warnings.push(
`Preserved previously managed file ${candidate.destinationPath}: ${error.message}`
);
continue;
}
if (!pathExists(safePath)) {
continue;
}
const stat = fs.lstatSync(safePath);
if (stat.isSymbolicLink() || !stat.isFile()) {
warnings.push(
`Preserved previously managed file ${safePath}: it is not a regular file; remove it manually if unwanted.`
);
continue;
}
if (typeof candidate.contentSha256 !== 'string') {
warnings.push(
`Preserved previously managed file ${safePath}: the recorded operation has no content digest, so the file cannot be verified unchanged; remove it manually if unwanted.`
);
continue;
}
let currentDigest;
try {
currentDigest = hashFileNoFollow(safePath);
} catch (error) {
warnings.push(`Preserved previously managed file ${safePath}: ${error.message}`);
continue;
}
if (currentDigest !== candidate.contentSha256.toLowerCase()) {
warnings.push(
`Preserved previously managed file ${safePath}: content changed after install; remove it manually if unwanted.`
);
continue;
}
fs.unlinkSync(safePath);
removedPaths.push(safePath);
removeEmptyParents(safePath, plan.targetRoot);
}
return { removedPaths, warnings };
}
module.exports = {
completeExcludedPathsReconciliation,
prepareExcludedPathsReconciliation,
};
+24 -8
View File
@@ -4,6 +4,7 @@ const fs = require('fs');
const path = require('path');
const { readInstallState } = require('../install-state');
const { hasEditedCodexUserConfig } = require('./codex-user-config');
function pathExists(filePath) {
try {
@@ -60,24 +61,32 @@ function prepareUserOwnedFileGuard(plan, migration) {
);
const managedDestinations = new Set(previousManagedOperations.keys());
const appliedOperations = [];
const plannedOperations = (migration && migration.appliedOperations) || [];
const plannedDestinations = new Set(plannedOperations.map(operation => comparablePath(operation.destinationPath)));
// Selective reinstalls retain earlier modules in the ledger. Inspect those
// entries too, without turning them into additional writes in this install.
const retainedOperations = ((migration.finalState && migration.finalState.operations) || [])
.filter(operation => !plannedDestinations.has(comparablePath(operation.destinationPath))
&& previousManagedOperations.has(comparablePath(operation.destinationPath)));
const skippedOperations = [];
const warnings = [];
for (const operation of (migration && migration.appliedOperations) || []) {
for (const operation of [...plannedOperations, ...retainedOperations]) {
const previousOperation = previousManagedOperations.get(comparablePath(operation.destinationPath));
const editedConfig = previousOperation
&& hasEditedCodexUserConfig(plan, operation, previousOperation);
if (
operation
&& operation.kind === 'copy-file'
&& operation.destinationPath
&& pathExists(operation.destinationPath)
&& !managedDestinations.has(comparablePath(operation.destinationPath))
&& (!managedDestinations.has(comparablePath(operation.destinationPath)) || editedConfig)
) {
skippedOperations.push(operation);
warnings.push(
`Skipped user-owned file ${operation.destinationPath}: the existing file is not recorded in ECC install-state.`
);
warnings.push(editedConfig
? `Preserved user configuration ${operation.destinationPath}: changed or unverifiable since installation. ECC no longer manages this file; apply future configuration updates manually.`
: `Skipped user-owned file ${operation.destinationPath}: the existing file is not recorded in ECC install-state.`);
continue;
}
appliedOperations.push(operation);
}
if (skippedOperations.length === 0) {
@@ -87,6 +96,9 @@ function prepareUserOwnedFileGuard(plan, migration) {
const skippedDestinations = new Set(
skippedOperations.map(operation => comparablePath(operation.destinationPath))
);
const appliedOperations = plannedOperations.filter(operation => (
!skippedDestinations.has(comparablePath(operation.destinationPath))
));
const filterStateOperations = operations => (operations || [])
.filter(operation => !skippedDestinations.has(comparablePath(operation.destinationPath)));
@@ -125,7 +137,11 @@ function prepareUserOwnedFileGuard(plan, migration) {
};
}
function assertNoNewUserOwnedFile(migration, operation) {
function assertNoNewUserOwnedFile(migration, operation, plan) {
const previousOperation = migration.previousManagedOperations.get(comparablePath(operation.destinationPath));
if (plan && hasEditedCodexUserConfig(plan, operation, previousOperation)) {
throw new Error(`Refusing to overwrite user configuration changed after planning: ${operation.destinationPath}. Rerun to preserve it.`);
}
if (operation.kind !== 'copy-file'
|| migration.managedDestinations.has(comparablePath(operation.destinationPath))
|| !pathExists(operation.destinationPath)) {
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env node
'use strict';
/**
* Shared cross-platform browser launcher.
*
* Extracted from scripts/plan-canvas.js (which had a working but error-silent
* tri-platform branch) and scripts/control-pane.js (which had a darwin-only
* branch that silently no-op'd on Windows/Linux). This helper:
*
* 1. Dispatches `open` / `cmd /c start` / `xdg-open` based on process.platform
* 2. Wires the child's 'error' event so ENOENT / EACCES propagate to the caller
* instead of being swallowed by detached spawns
* 3. Returns a structured { opened, reason } result so CLI consumers can
* surface the truth (browser did/did not open) instead of a lying true/false
*
* The signature is intentionally small (single function, no class) so callers
* can import without picking up the rest of scripts/lib.
*
* Tests live at tests/lib/platform-launch.test.js.
*/
const { spawn } = require('child_process');
/**
* Pick the platform-appropriate opener command + args.
* Returns [cmd, args] suitable for child_process.spawn.
*
* @param {NodeJS.Platform} platform
* @param {string} url
* @returns {[string, string[]]}
*/
function openerCommandFor(platform, url) {
if (platform === 'darwin') return ['open', [url]];
if (platform === 'win32') return ['cmd', ['/c', 'start', '', url]];
return ['xdg-open', [url]];
}
/**
* Open a URL in the user's default browser, dispatching per-platform.
*
* Always returns a structured result so callers can:
* - show a clear error to the agent (no silent failures)
* - keep JSON CLI output truthful when browsers cannot launch
*
* @param {string} url
* @param {NodeJS.Platform} [platform] - injectable for tests; defaults to process.platform
* @returns {{ opened: boolean, reason: string }}
*/
function openBrowser(url, platform = process.platform) {
if (typeof url !== 'string' || url.length === 0) {
return { opened: false, reason: 'invalid-url' };
}
const [cmd, args] = openerCommandFor(platform, url);
let child;
try {
child = spawn(cmd, args, {
detached: true,
stdio: 'ignore',
});
} catch (err) {
return {
opened: false,
reason: `spawn-threw:${err && err.code ? err.code : 'unknown'}`,
};
}
// Listen for ENOENT/EACCES/etc that would otherwise be silently swallowed
// when the user has no `open` / `xdg-open` / `start` available.
let capturedError = null;
child.on('error', (err) => {
capturedError = err && err.code ? err.code : 'spawn-error';
});
// Best-effort: detach so we don't keep the parent alive on the launcher.
try {
child.unref();
} catch {
/* unref may throw on some platforms; safe to ignore */
}
if (capturedError) {
return { opened: false, reason: `child-error:${capturedError}` };
}
return { opened: true, reason: 'spawned' };
}
module.exports = {
openBrowser,
openerCommandFor,
};
+513
View File
@@ -0,0 +1,513 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { writeFileAtomic } = require('./atomic-write');
const { sanitizeSessionId } = require('./session-bridge');
const COST_SNAPSHOT_SCHEMA_VERSION = 'ecc.cost-snapshot.v1';
const COST_SNAPSHOT_DIRECTORY = 'cost-snapshots';
const COST_LOG_FILENAME = 'costs.jsonl';
const READ_CHUNK_BYTES = 64 * 1024;
const MAX_JSONL_LINE_BYTES = 1024 * 1024;
const MAX_SCAN_BYTES = 16 * 1024 * 1024;
const FINGERPRINT_WINDOW_BYTES = 256;
const PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000;
const SNAPSHOT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
const MAX_SNAPSHOTS = 512;
const WARNING_CACHE_PREFIX = 'ecc-cost-snapshot-warnings-';
function assertSafeSessionId(sessionId) {
if (sanitizeSessionId(sessionId) !== sessionId) {
throw new Error('Cost snapshot requires a safe session ID');
}
}
function getSnapshotDirectory(metricsDir) {
return path.join(metricsDir, COST_SNAPSHOT_DIRECTORY);
}
function getCostSnapshotPath(metricsDir, sessionId) {
assertSafeSessionId(sessionId);
return path.join(getSnapshotDirectory(metricsDir), `session-${sessionId}.json`);
}
function isValidCostRow(row, sessionId) {
return row?.session_id === sessionId
&& typeof row.estimated_cost_usd === 'number'
&& Number.isFinite(row.estimated_cost_usd)
&& row.estimated_cost_usd >= 0
&& typeof row.input_tokens === 'number'
&& Number.isFinite(row.input_tokens)
&& row.input_tokens >= 0
&& typeof row.output_tokens === 'number'
&& Number.isFinite(row.output_tokens)
&& row.output_tokens >= 0;
}
function readJsonFile(filePath) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch {
return null;
}
}
function chooseNewerCumulativeRow(currentRow, nextRow) {
if (!currentRow) return nextRow;
const nextDominates = nextRow.input_tokens >= currentRow.input_tokens
&& nextRow.output_tokens >= currentRow.output_tokens
&& nextRow.estimated_cost_usd >= currentRow.estimated_cost_usd;
const currentDominates = currentRow.input_tokens >= nextRow.input_tokens
&& currentRow.output_tokens >= nextRow.output_tokens
&& currentRow.estimated_cost_usd >= nextRow.estimated_cost_usd;
if (nextDominates && !currentDominates) return nextRow;
if (currentDominates && !nextDominates) return currentRow;
const nextTimestamp = Date.parse(nextRow.timestamp);
const currentTimestamp = Date.parse(currentRow.timestamp);
if (Number.isFinite(nextTimestamp) && Number.isFinite(currentTimestamp)) {
return nextTimestamp >= currentTimestamp ? nextRow : currentRow;
}
return nextRow;
}
function sourceIdentity(stat) {
return `${stat.dev}:${stat.ino}`;
}
function hashWindow(descriptor, position, length) {
const buffer = Buffer.alloc(length);
if (length > 0) fs.readSync(descriptor, buffer, 0, length, position);
return crypto.createHash('sha256').update(buffer).digest('hex');
}
function fingerprintProcessedPrefix(descriptor, offset) {
const windowLength = Math.min(FINGERPRINT_WINDOW_BYTES, offset);
const middleStart = Math.max(0, Math.floor((offset - windowLength) / 2));
return {
start: hashWindow(descriptor, 0, windowLength),
middle: hashWindow(descriptor, middleStart, windowLength),
end: hashWindow(descriptor, offset - windowLength, windowLength)
};
}
function fingerprintsMatch(left, right) {
return left?.start === right?.start
&& left?.middle === right?.middle
&& left?.end === right?.end;
}
function validSnapshotBase(snapshot, descriptor, stat, sessionId) {
if (snapshot?.schema_version !== COST_SNAPSHOT_SCHEMA_VERSION) return null;
if (snapshot.row !== null && !isValidCostRow(snapshot.row, sessionId)) return null;
const source = snapshot.source;
if (source?.identity !== sourceIdentity(stat)) return null;
if (!Number.isSafeInteger(source.offset_bytes) || source.offset_bytes < 0) return null;
if (source.offset_bytes > stat.size) return null;
if (source.offset_bytes === stat.size && source.mtime_ms !== stat.mtimeMs) return null;
if (!fingerprintsMatch(
source.fingerprint,
fingerprintProcessedPrefix(descriptor, source.offset_bytes)
)) return null;
return {
row: snapshot.row,
offset: source.offset_bytes,
discardingLine: source.discarding_line === true
};
}
function createScanState(initialRow) {
return {
latestRow: initialRow,
committedRow: initialRow,
malformed: 0,
invalid: 0,
malformedHasher: crypto.createHash('sha256'),
invalidHasher: crypto.createHash('sha256')
};
}
function processCostLine(state, line, sessionId, committed = true) {
if (!line.trim()) return state;
try {
const row = JSON.parse(line);
if (row.session_id !== sessionId) return state;
if (!isValidCostRow(row, sessionId)) {
if (!committed) return state;
return {
...state,
invalid: state.invalid + 1,
invalidHasher: state.invalidHasher.copy().update(line).update('\0')
};
}
return {
...state,
latestRow: chooseNewerCumulativeRow(state.latestRow, row),
committedRow: committed
? chooseNewerCumulativeRow(state.committedRow, row)
: state.committedRow
};
} catch {
if (!committed) return state;
return {
...state,
malformed: state.malformed + 1,
malformedHasher: state.malformedHasher.copy().update(line).update('\0')
};
}
}
function markOversizedLine(state, pendingChunks, segment) {
const hasher = state.malformedHasher.copy();
for (const chunk of pendingChunks) hasher.update(chunk);
const remaining = Math.max(0, MAX_JSONL_LINE_BYTES - pendingChunks.reduce(
(total, chunk) => total + chunk.length,
0
));
hasher.update(segment.subarray(0, remaining)).update('\0<oversized>');
return { ...state, malformed: state.malformed + 1, malformedHasher: hasher };
}
function consumeLineSegment(scan, segment, terminated, sessionId) {
if (scan.discardingLine) {
return { ...scan, discardingLine: !terminated };
}
if (scan.pendingBytes + segment.length > MAX_JSONL_LINE_BYTES) {
return {
state: markOversizedLine(scan.state, scan.pendingChunks, segment),
pendingChunks: [],
pendingBytes: 0,
discardingLine: !terminated
};
}
const pendingChunks = segment.length > 0
? [...scan.pendingChunks, Buffer.from(segment)]
: scan.pendingChunks;
const pendingBytes = scan.pendingBytes + segment.length;
if (!terminated) return { ...scan, pendingChunks, pendingBytes };
const line = Buffer.concat(pendingChunks, pendingBytes).toString('utf8');
return {
state: processCostLine(scan.state, line, sessionId),
pendingChunks: [],
pendingBytes: 0,
discardingLine: false
};
}
function consumeJsonlChunk(scan, chunk, sessionId, absoluteStart, processedOffset) {
let nextScan = scan;
let nextOffset = processedOffset;
let segmentStart = 0;
for (;;) {
const newlineIndex = chunk.indexOf(0x0a, segmentStart);
if (newlineIndex < 0) break;
nextScan = consumeLineSegment(
nextScan, chunk.subarray(segmentStart, newlineIndex), true, sessionId
);
nextOffset = absoluteStart + newlineIndex + 1;
segmentStart = newlineIndex + 1;
}
nextScan = consumeLineSegment(
nextScan, chunk.subarray(segmentStart), false, sessionId
);
if (nextScan.discardingLine) nextOffset = absoluteStart + chunk.length;
return { scan: nextScan, processedOffset: nextOffset };
}
function scanJsonlRange(descriptor, start, end, sessionId, initialRow, initialDiscard = false) {
const buffer = Buffer.allocUnsafe(READ_CHUNK_BYTES);
let lineScan = {
state: createScanState(initialRow),
pendingChunks: [],
pendingBytes: 0,
discardingLine: initialDiscard
};
let position = start;
let processedOffset = start;
while (position < end) {
const bytesRead = fs.readSync(
descriptor,
buffer,
0,
Math.min(buffer.length, end - position),
position
);
if (bytesRead === 0) break;
const consumed = consumeJsonlChunk(
lineScan, buffer.subarray(0, bytesRead), sessionId, position, processedOffset
);
lineScan = consumed.scan;
processedOffset = consumed.processedOffset;
position += bytesRead;
}
if (lineScan.pendingBytes > 0) {
const line = Buffer.concat(lineScan.pendingChunks, lineScan.pendingBytes).toString('utf8');
lineScan = {
...lineScan,
state: processCostLine(lineScan.state, line, sessionId, false)
};
}
const { state } = lineScan;
return {
row: state.latestRow,
committedRow: state.committedRow,
processedOffset,
malformed: state.malformed,
invalid: state.invalid,
malformedSignature: state.malformed > 0
? state.malformedHasher.digest('hex').slice(0, 16)
: null,
invalidSignature: state.invalid > 0
? state.invalidHasher.digest('hex').slice(0, 16)
: null,
discardingLine: lineScan.discardingLine
};
}
function writeSnapshotAtOffset(
metricsDir, sessionId, row, descriptor, stat, offset, discardingLine = false
) {
if (row !== null && !isValidCostRow(row, sessionId)) return false;
const snapshot = {
schema_version: COST_SNAPSHOT_SCHEMA_VERSION,
source: {
identity: sourceIdentity(stat),
offset_bytes: offset,
mtime_ms: stat.mtimeMs,
discarding_line: discardingLine,
fingerprint: fingerprintProcessedPrefix(descriptor, offset)
},
row
};
writeFileAtomic(
getCostSnapshotPath(metricsDir, sessionId),
JSON.stringify(snapshot),
{
beforeRename() {
const current = fs.fstatSync(descriptor);
if (current.size < offset) {
throw new Error('Cost log was truncated during snapshot publication');
}
if (current.size === offset && current.mtimeMs !== snapshot.source.mtime_ms) {
throw new Error('Cost log changed during snapshot publication');
}
if (!fingerprintsMatch(
fingerprintProcessedPrefix(descriptor, offset),
snapshot.source.fingerprint
)) {
throw new Error('Cost log prefix changed during snapshot publication');
}
}
}
);
return true;
}
function emptySnapshotResult(row) {
return {
row,
scannedBytes: 0,
malformed: 0,
invalid: 0,
malformedSignature: null,
invalidSignature: null,
snapshotError: null
};
}
function publishScanSnapshot(metricsDir, sessionId, scan, descriptor, stat) {
if (!scan.committedRow && scan.processedOffset === 0) return null;
try {
writeSnapshotAtOffset(
metricsDir,
sessionId,
scan.committedRow,
descriptor,
stat,
scan.processedOffset,
scan.discardingLine
);
return null;
} catch (error) {
return error;
}
}
function refreshSessionCostSnapshot(metricsDir, sessionId) {
assertSafeSessionId(sessionId);
const costsPath = path.join(metricsDir, COST_LOG_FILENAME);
const descriptor = fs.openSync(costsPath, 'r');
try {
const stat = fs.fstatSync(descriptor);
const snapshot = readJsonFile(getCostSnapshotPath(metricsDir, sessionId));
const base = validSnapshotBase(snapshot, descriptor, stat, sessionId);
if (base?.offset === stat.size) return emptySnapshotResult(base.row);
const scanEnd = Math.min(stat.size, (base?.offset || 0) + MAX_SCAN_BYTES);
const scan = scanJsonlRange(
descriptor,
base?.offset || 0,
scanEnd,
sessionId,
base?.row || null,
base?.discardingLine || false
);
const snapshotError = publishScanSnapshot(
metricsDir, sessionId, scan, descriptor, stat
);
return {
row: scan.row,
scannedBytes: scan.processedOffset - (base?.offset || 0),
malformed: scan.malformed,
invalid: scan.invalid,
malformedSignature: scan.malformedSignature,
invalidSignature: scan.invalidSignature,
snapshotError
};
} finally {
fs.closeSync(descriptor);
}
}
function costLogNeedsSeparator(metricsDir) {
const costsPath = path.join(metricsDir, COST_LOG_FILENAME);
let descriptor;
try {
descriptor = fs.openSync(costsPath, 'r');
const stat = fs.fstatSync(descriptor);
if (stat.size === 0) return false;
const lastByte = Buffer.alloc(1);
return fs.readSync(descriptor, lastByte, 0, 1, stat.size - 1) === 1
&& lastByte[0] !== 0x0a;
} catch (error) {
if (error.code === 'ENOENT') return false;
throw error;
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
}
}
function appendSessionCostRow(metricsDir, sessionId, row) {
assertSafeSessionId(sessionId);
if (!isValidCostRow(row, sessionId)) {
throw new Error('Cost snapshot requires valid non-negative numeric totals for its session');
}
const prefix = costLogNeedsSeparator(metricsDir) ? '\n' : '';
fs.appendFileSync(
path.join(metricsDir, COST_LOG_FILENAME),
`${prefix}${JSON.stringify(row)}\n`,
'utf8'
);
const result = refreshSessionCostSnapshot(metricsDir, sessionId);
if (result.snapshotError) throw result.snapshotError;
try {
maybePruneSessionCostSnapshots(metricsDir);
} catch (error) {
// Retention is opportunistic and retried by a later update, but a
// persistent failure remains visible without rolling back the log append.
warnSessionCostSnapshotFailure('retention', metricsDir, sessionId, error);
}
return JSON.stringify(result.row) === JSON.stringify(row);
}
function readSessionCostSnapshot(metricsDir, sessionId) {
try {
return refreshSessionCostSnapshot(metricsDir, sessionId);
} catch (error) {
if (error.code === 'ENOENT') {
return { row: null, scannedBytes: 0, malformed: 0, invalid: 0, snapshotError: null };
}
throw error;
}
}
function maybePruneSessionCostSnapshots(metricsDir, options = {}) {
const snapshotDir = getSnapshotDirectory(metricsDir);
const now = Number.isFinite(options.now) ? options.now : Date.now();
const maxAgeMs = Number.isFinite(options.maxAgeMs) ? options.maxAgeMs : SNAPSHOT_MAX_AGE_MS;
const maxSnapshots = Number.isSafeInteger(options.maxSnapshots)
? Math.max(0, options.maxSnapshots)
: MAX_SNAPSHOTS;
const markerPath = path.join(snapshotDir, '.last-prune');
fs.mkdirSync(snapshotDir, { recursive: true });
const snapshotEntries = fs.readdirSync(snapshotDir, { withFileTypes: true })
.filter(entry => entry.isFile() && /^session-.+\.json$/.test(entry.name));
if (!options.force) {
try {
const intervalIsFresh = now - fs.statSync(markerPath).mtimeMs < PRUNE_INTERVAL_MS;
if (intervalIsFresh && snapshotEntries.length <= maxSnapshots) return 0;
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
}
const snapshots = snapshotEntries
.map(entry => {
const filePath = path.join(snapshotDir, entry.name);
return { filePath, mtimeMs: fs.statSync(filePath).mtimeMs };
})
.sort((left, right) => right.mtimeMs - left.mtimeMs);
const removals = snapshots.filter((entry, index) => (
index >= maxSnapshots || now - entry.mtimeMs > maxAgeMs
));
let removed = 0;
for (const entry of removals) {
try {
if (fs.statSync(entry.filePath).mtimeMs <= entry.mtimeMs) {
fs.rmSync(entry.filePath, { force: true });
removed += 1;
}
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
}
fs.writeFileSync(markerPath, String(now), { encoding: 'utf8', mode: 0o600 });
return removed;
}
function warningClaimPath(kind, metricsDir, sessionId, signature) {
const key = crypto.createHash('sha256')
.update(`${path.resolve(metricsDir)}\0${sessionId}\0${kind}\0${signature}`)
.digest('hex')
.slice(0, 16);
return path.join(os.tmpdir(), `${WARNING_CACHE_PREFIX}${key}.claim`);
}
function warnSessionCostSnapshotFailure(kind, metricsDir, sessionId, error) {
const targetPath = getCostSnapshotPath(metricsDir, sessionId);
const errorCode = error?.code || error?.name || 'error';
const signature = `${kind}:${targetPath}:${errorCode}`;
const claimPath = warningClaimPath(kind, metricsDir, sessionId, signature);
let claimDescriptor;
try {
claimDescriptor = fs.openSync(claimPath, 'wx', 0o600);
fs.closeSync(claimDescriptor);
claimDescriptor = undefined;
} catch (claimError) {
if (claimDescriptor !== undefined) fs.closeSync(claimDescriptor);
if (claimError.code === 'EEXIST') return;
// Warning persistence is best effort. If the claim cannot be created,
// still surface the underlying snapshot failure.
}
process.stderr.write(
`[cost-snapshot] ${kind} failed for session ${sessionId}: ${error?.message || String(error)}\n`
);
}
module.exports = {
COST_SNAPSHOT_SCHEMA_VERSION,
COST_SNAPSHOT_DIRECTORY,
COST_LOG_FILENAME,
MAX_SCAN_BYTES,
getCostSnapshotPath,
isValidCostRow,
chooseNewerCumulativeRow,
appendSessionCostRow,
readSessionCostSnapshot,
refreshSessionCostSnapshot,
costLogNeedsSeparator,
maybePruneSessionCostSnapshots,
warnSessionCostSnapshotFailure
};
+71
View File
@@ -135,6 +135,74 @@ function getGitRepoName() {
return path.basename(result.output);
}
/**
* Get the repository identity for a directory: the canonical (real) path of
* the repository's common git dir, which is the main worktree's .git
* directory. Every linked worktree of one repository resolves to the same
* identity, while unrelated repositories never share one.
*
* @param {string} [dir] - Directory to resolve from (defaults to process.cwd()).
* @returns {string|null} The canonical common git dir, or null when dir is
* not inside a git repository or does not exist.
*/
function getRepoIdentity(dir, runCmd = runCommand) {
const target = dir || process.cwd();
const result = runCmd('git rev-parse --git-common-dir', { cwd: target });
if (!result.success || !result.output) return null;
const commonDir = path.resolve(target, result.output);
try {
return fs.realpathSync(commonDir);
} catch {
return commonDir;
}
}
/**
* Normalize a repository identity path for comparison: canonical (real) form
* when it exists, forward slashes, no trailing slash, and lowercase on
* Windows where the filesystem is case-insensitive. The platform argument
* exists so Windows-shaped git output can be tested on any OS.
*
* @param {string} p - Path to normalize.
* @param {string} [platform] - Platform override (defaults to process.platform).
* @returns {string} The normalized path, or '' for empty input.
*/
function normalizeRepoPath(p, platform = process.platform) {
if (!p) return '';
let resolved;
try {
resolved = fs.realpathSync(p);
} catch {
resolved = path.resolve(p);
}
const slashed = resolved.replace(/\\/g, '/').replace(/\/+$/, '');
return platform === 'win32' ? slashed.toLowerCase() : slashed;
}
/**
* Compare two repository identity paths. String normalization alone is not
* enough on Windows CI runners, where TEMP commonly uses an 8.3 short name
* (RUNNER~1): Node's realpath keeps the short form while git reports the
* long form for the same directory. When the strings differ, fall back to
* filesystem identity (device + inode), which is immune to 8.3 names, case
* and separators. Fails closed when either path cannot be statted.
*
* @param {string} a - First identity path.
* @param {string} b - Second identity path.
* @returns {boolean} True when both paths name the same directory.
*/
function sameRepoIdentity(a, b) {
if (!a || !b) return false;
if (normalizeRepoPath(a) === normalizeRepoPath(b)) return true;
try {
const sa = fs.statSync(a);
const sb = fs.statSync(b);
return sa.ino !== 0 && sa.dev === sb.dev && sa.ino === sb.ino;
} catch {
return false;
}
}
/**
* Get project name from git repo or current directory
*/
@@ -642,6 +710,9 @@ module.exports = {
sanitizeSessionId,
getSessionIdShort,
getGitRepoName,
getRepoIdentity,
normalizeRepoPath,
sameRepoIdentity,
getProjectName,
// File operations
Executable → Regular
+3 -1
View File
@@ -383,10 +383,12 @@ function createMemoryMcpService(options = {}) {
const isNotification = !hasId;
if (isNotification) {
const params = message.params ?? {};
if (
message.method === 'notifications/initialized'
&& initializationRequested
&& Object.keys(message.params || {}).length === 0
&& (!Object.prototype.hasOwnProperty.call(params, '_meta') || isRecord(params._meta))
&& Object.keys(params).every(key => key === '_meta')
) {
initialized = true;
}
+33 -1
View File
@@ -48,6 +48,38 @@ fi
write_status "running" "- Task file: \`$task_file\`"
# SECURITY: never auto-approve agent tool execution. The worker prompt is built
# from a task file that may contain LLM-generated or third-party content
# (indirect prompt injection). `codex exec -p yolo` would execute
# rm -rf / exfiltration commands without confirmation.
# Default to the most restrictive approval mode; allow an explicit operator
# override only via env (e.g. ECC_CODEX_APPROVAL_MODE=on-request for trusted runs).
# Codex profiles (-p) and approval policies (--ask-for-approval) are
# independent concepts. SECURITY: default to never approving untrusted
# tool execution; operators can override via env.
APPROVAL_POLICY="${ECC_CODEX_APPROVAL_POLICY:-never}"
case "$APPROVAL_POLICY" in
never|on-request|on-failure) ;;
*)
echo "[ECC worker] Refusing to run: unsupported ECC_CODEX_APPROVAL_POLICY='$APPROVAL_POLICY' (expected never|on-request|on-failure)" >&2
write_status "failed" "- Error: unsupported approval policy"
exit 1
;;
esac
# Contain the task file to the current worktree so a malicious launcher cannot
# point the worker at /etc/passwd or a sibling checkout.
task_real="$(realpath -m "$task_file" 2>/dev/null || readlink -f "$task_file" 2>/dev/null || printf '%s' "$task_file")"
work_real="$(pwd -P 2>/dev/null || pwd)"
case "$task_real" in
"$work_real"/*) ;;
*)
echo "[ECC worker] Refusing to run: task file outside worktree: $task_file" >&2
write_status "failed" "- Error: task file outside worktree"
exit 1
;;
esac
prompt_file="$(mktemp)"
output_file="$(mktemp)"
cleanup() {
@@ -77,7 +109,7 @@ Task file: $task_file
$(cat "$task_file")
EOF
if codex exec -p yolo -m gpt-5.4 --color never -C "$(pwd)" -o "$output_file" - < "$prompt_file"; then
if codex exec --ask-for-approval "$APPROVAL_POLICY" -m gpt-5.4 --color never -C "$(pwd)" -o "$output_file" - < "$prompt_file"; then
{
echo "# Handoff"
echo
+5 -16
View File
@@ -20,14 +20,13 @@
const fs = require('fs');
const http = require('http');
const path = require('path');
const { spawn } = require('child_process');
const {
canonicalizeArtifactPath,
createSessionStore,
resolveStateDir,
sessionKeyFor
} = require('./lib/plan-canvas/sessions');
const { openBrowser } = require('./lib/platform-launch');
const {
DEFAULT_HOST,
createPlanCanvasServer,
@@ -194,19 +193,7 @@ async function ensureServer({ stateDir, port }) {
throw new Error(`plan-canvas server did not become healthy on port ${port}; check ${path.join(stateDir, 'server.log')}`);
}
function openBrowser(url) {
const platform = process.platform;
const [cmd, args] =
platform === 'darwin' ? ['open', [url]]
: platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
: ['xdg-open', [url]];
try {
spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
return true;
} catch {
return false;
}
}
function output(payload) {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
@@ -232,11 +219,13 @@ async function cmdOpen(file, args, { stateDir, port }) {
if (res.statusCode === 409) return res.body;
if (res.statusCode !== 200) throw new Error(res.body.error || `open failed (HTTP ${res.statusCode})`);
const url = `http://${DEFAULT_HOST}:${port}${res.body.url}`;
const launched = args.includes('--no-open') ? false : openBrowser(url);
const launchResult = args.includes('--no-open') ? { opened: false, reason: 'no-open-flag' } : openBrowser(url);
const launched = launchResult.opened;
return {
status: 'open',
url,
browser: launched ? 'opened' : 'not opened',
browserReason: launchResult.reason,
next_step:
'Run `ecc-plan-canvas await <file>` and leave it running; it returns when the human sends feedback, a verdict, or ends the session.'
};