fix: prevent zombie processes, redact cron stderr, skip symlinks in skill enumeration
process_registry.py: _reader_loop() has process.wait() after the try-except
block (line 380). If the reader thread crashes with an unexpected exception
(e.g. MemoryError, KeyboardInterrupt), control exits the except handler but
skips wait() — leaving the child as a zombie process. Move wait() and the
cleanup into a finally block so the child is always reaped.
cron/scheduler.py: _run_job_script() only redacts secrets in stdout on the
SUCCESS path (line 417-421). When a cron script fails (non-zero exit), both
stdout and stderr are returned WITHOUT redaction (lines 407-413). A script
that accidentally prints an API key to stderr during a failure would leak it
into the LLM context. Move redaction before the success/failure branch so
both paths benefit.
skill_commands.py: _build_skill_message() enumerates supporting files using
rglob("*") but only checks is_file() (line 171) without filtering symlinks.
PR #6693 added symlink protection to scan_skill_commands() but missed this
function. A malicious skill can create symlinks in references/ pointing to
arbitrary files, exposing their paths (and potentially content via skill_view)
to the LLM. Add is_symlink() check to match the guard in scan_skill_commands.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -168,7 +168,7 @@ def _build_skill_message(
|
|||||||
subdir_path = skill_dir / subdir
|
subdir_path = skill_dir / subdir
|
||||||
if subdir_path.exists():
|
if subdir_path.exists():
|
||||||
for f in sorted(subdir_path.rglob("*")):
|
for f in sorted(subdir_path.rglob("*")):
|
||||||
if f.is_file():
|
if f.is_file() and not f.is_symlink():
|
||||||
rel = str(f.relative_to(skill_dir))
|
rel = str(f.relative_to(skill_dir))
|
||||||
supporting.append(rel)
|
supporting.append(rel)
|
||||||
|
|
||||||
|
|||||||
@@ -442,6 +442,14 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
|
|||||||
stdout = (result.stdout or "").strip()
|
stdout = (result.stdout or "").strip()
|
||||||
stderr = (result.stderr or "").strip()
|
stderr = (result.stderr or "").strip()
|
||||||
|
|
||||||
|
# Redact secrets from both stdout and stderr before any return path.
|
||||||
|
try:
|
||||||
|
from agent.redact import redact_sensitive_text
|
||||||
|
stdout = redact_sensitive_text(stdout)
|
||||||
|
stderr = redact_sensitive_text(stderr)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
parts = [f"Script exited with code {result.returncode}"]
|
parts = [f"Script exited with code {result.returncode}"]
|
||||||
if stderr:
|
if stderr:
|
||||||
@@ -450,13 +458,6 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
|
|||||||
parts.append(f"stdout:\n{stdout}")
|
parts.append(f"stdout:\n{stdout}")
|
||||||
return False, "\n".join(parts)
|
return False, "\n".join(parts)
|
||||||
|
|
||||||
# Redact any secrets that may appear in script output before
|
|
||||||
# they are injected into the LLM prompt context.
|
|
||||||
try:
|
|
||||||
from agent.redact import redact_sensitive_text
|
|
||||||
stdout = redact_sensitive_text(stdout)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return True, stdout
|
return True, stdout
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
|
|||||||
@@ -396,15 +396,15 @@ class ProcessRegistry:
|
|||||||
session.output_buffer = session.output_buffer[-session.max_output_chars:]
|
session.output_buffer = session.output_buffer[-session.max_output_chars:]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Process stdout reader ended: %s", e)
|
logger.debug("Process stdout reader ended: %s", e)
|
||||||
|
finally:
|
||||||
# Process exited
|
# Always reap the child to prevent zombie processes.
|
||||||
try:
|
try:
|
||||||
session.process.wait(timeout=5)
|
session.process.wait(timeout=5)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Process wait timed out or failed: %s", e)
|
logger.debug("Process wait timed out or failed: %s", e)
|
||||||
session.exited = True
|
session.exited = True
|
||||||
session.exit_code = session.process.returncode
|
session.exit_code = session.process.returncode
|
||||||
self._move_to_finished(session)
|
self._move_to_finished(session)
|
||||||
|
|
||||||
def _env_poller_loop(
|
def _env_poller_loop(
|
||||||
self, session: ProcessSession, env: Any, log_path: str, pid_path: str, exit_path: str
|
self, session: ProcessSession, env: Any, log_path: str, pid_path: str, exit_path: str
|
||||||
|
|||||||
Reference in New Issue
Block a user