Compare commits

...

3 Commits

Author SHA1 Message Date
Patrick Buckley 52d59cf7b7 chore: bump version to 0.8.2 2026-03-17 02:20:44 -07:00
Patrick Buckley 2dc885ab4d fix: output guard detects single secret-bearing env lines (#115)
* fix: output guard detects single secret-bearing env lines

The credential leak check required 3+ env-style lines before flagging.
A single AWS_SECRET_ACCESS_KEY=... line was missed. Now flags whenever
any env line has a secret-bearing key name (SECRET, KEY, TOKEN,
PASSWORD, CREDENTIAL), regardless of how many total env lines exist.

* fix: tighten env secret key matching, add tests

Tighten _RE_ENV_SECRET_KEY to word-boundary segments so MONKEY/TURKEY
don't false-positive. Use any() for short-circuit. Add test for single
secret line detection and substring false-positive prevention.
2026-03-17 02:19:22 -07:00
Patrick Buckley 14488f43e0 feat: metacognitive nudge on tool error — search memories for guidance
Add tool_error nudge type that fires when a tool returns an error,
prompting the model to search memories for prior feedback about the
tool or error pattern before retrying.

- Gated on nudges config (respects nudges=false)
- Only fires when memories exist (no noise on fresh workstreams)
- Broad error detection: Error*, *error:*, Command timed out, Unknown tool
- Nudge wording aligned to memory(action='search') convention
- Respects existing cooldown (5 min) and rate limiting
- 4 new tests
2026-03-17 02:06:10 -07:00
8 changed files with 75 additions and 7 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "turnstone"
version = "0.8.1"
version = "0.8.2"
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
readme = "README.md"
license = "BUSL-1.1"
+23
View File
@@ -6,6 +6,7 @@ from turnstone.core.metacognition import (
NUDGE_DENIAL,
NUDGE_RESUME,
NUDGE_START,
NUDGE_TOOL_ERROR,
detect_completion,
detect_correction,
format_nudge,
@@ -263,5 +264,27 @@ class TestFormatNudge:
def test_start(self):
assert format_nudge("start") == NUDGE_START
def test_tool_error(self):
assert format_nudge("tool_error") == NUDGE_TOOL_ERROR
def test_invalid(self):
assert format_nudge("invalid") == ""
class TestToolErrorNudge:
def test_fires(self):
state: dict[str, float] = {}
assert should_nudge("tool_error", state, message_count=5, memory_count=3) is True
def test_cooldown(self):
state: dict[str, float] = {}
assert should_nudge("tool_error", state, message_count=5, memory_count=3) is True
assert should_nudge("tool_error", state, message_count=6, memory_count=3) is False
def test_not_on_first_message(self):
state: dict[str, float] = {}
assert should_nudge("tool_error", state, message_count=1, memory_count=3) is False
def test_not_with_zero_memories(self):
state: dict[str, float] = {}
assert should_nudge("tool_error", state, message_count=5, memory_count=0) is False
+11
View File
@@ -184,6 +184,17 @@ class TestEnvSecretFalsePositives:
r = evaluate_output("APP_NAME=myapp\nSECRET_KEY=abc123\nAPI_TOKEN=xyz789\nDEBUG=true")
assert "env_file_leak" in r.flags
def test_single_secret_env_line(self) -> None:
"""A single AWS_SECRET_ACCESS_KEY=... line should trigger."""
r = evaluate_output("AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
assert "env_file_leak" in r.flags
assert r.risk_level == "high"
def test_substring_key_no_false_positive(self) -> None:
"""MONKEY=banana should not trigger (KEY is a substring, not a segment)."""
r = evaluate_output("MONKEY=banana\nTURKEY=gobble\nDONKEY=hee-haw")
assert "env_file_leak" not in r.flags
class TestOutputAssessment:
"""Verify OutputAssessment structure."""
+1 -1
View File
@@ -1,3 +1,3 @@
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
__version__ = "0.8.1"
__version__ = "0.8.2"
+10
View File
@@ -44,12 +44,19 @@ NUDGE_START = (
"user's request to find applicable context, preferences, or guidance."
)
NUDGE_TOOL_ERROR = (
"A tool just returned an error. Before retrying, check your memories — "
"the user may have given feedback about this tool or error pattern in a "
"previous session. Use memory(action='search') to find relevant guidance."
)
_NUDGE_MAP: dict[str, str] = {
"correction": NUDGE_CORRECTION,
"denial": NUDGE_DENIAL,
"resume": NUDGE_RESUME,
"completion": NUDGE_COMPLETION,
"start": NUDGE_START,
"tool_error": NUDGE_TOOL_ERROR,
}
# ---------------------------------------------------------------------------
@@ -153,6 +160,9 @@ def should_nudge(
# Start nudge only on first message
if nudge_type == "start" and message_count != 1:
return False
# Tool error nudge only if there are memories to search
if nudge_type == "tool_error" and memory_count == 0:
return False
# Resume/start nudge only if there are memories to recall
if nudge_type in ("resume", "start") and memory_count == 0:
return False
+5 -4
View File
@@ -54,7 +54,10 @@ _RE_CONNECTION_STRING = re.compile(
r"(?:postgresql|mysql|mongodb|redis|amqp)://[^:@\s]+:[^@\s]+@",
)
_RE_ENV_SECRET_LINE = re.compile(r"[A-Z][A-Z_0-9]+=\S+")
_RE_ENV_SECRET_KEY = re.compile(r"SECRET|KEY|TOKEN|PASSWORD|CREDENTIAL", re.IGNORECASE)
_RE_ENV_SECRET_KEY = re.compile(
r"(?:^|_)(?:SECRET|TOKEN|PASSWORD|CREDENTIAL)(?:_|$)|(?:^|_)KEY(?:_|$)",
re.IGNORECASE,
)
# (pattern, redact_label) — ordered most-specific first for redaction.
_CREDENTIAL_PATTERNS: list[tuple[re.Pattern[str], str]] = [
@@ -218,9 +221,7 @@ def _check_credentials(
risk = "high"
env_lines = _RE_ENV_SECRET_LINE.findall(text)
if len(env_lines) >= 3 and any(
_RE_ENV_SECRET_KEY.search(ln.split("=", 1)[0]) for ln in env_lines
):
if any(_RE_ENV_SECRET_KEY.search(ln.split("=", 1)[0]) for ln in env_lines):
_add_flag(flags, "credential_leak")
flags.append("env_file_leak")
ann.append("Output contains .env-style assignments with secret-bearing keys.")
+23
View File
@@ -1212,6 +1212,29 @@ class ChatSession:
_tname,
tool_call_id=tc_id,
)
# Metacognitive nudge: check memories on tool error
if (
self._memory_config.nudges
and any(
isinstance(out, str)
and (
out.startswith("Error")
or " error: " in out[:50]
or out.startswith("Command timed out")
or out.startswith("Unknown tool:")
)
for _, out in results
)
and should_nudge(
"tool_error",
self._metacog_state,
message_count=len(self.messages),
memory_count=self._visible_memory_count(),
cooldown_secs=self._memory_config.nudge_cooldown,
)
):
self._pending_nudge.append(format_nudge("tool_error"))
self._init_system_messages()
# Inject user feedback from approval prompt (e.g. "y, use full path")
if user_feedback:
self.messages.append({"role": "user", "content": user_feedback})
Generated
+1 -1
View File
@@ -2168,7 +2168,7 @@ wheels = [
[[package]]
name = "turnstone"
version = "0.8.1"
version = "0.8.2"
source = { editable = "." }
dependencies = [
{ name = "alembic" },