Skip to content
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ not merely age the downstream numbers, it can invert their conclusion.
- Use the PR template (`.github/pull_request_template.md`).
- **Merge strategy:** Merge commits only. No squash, no rebase. Branch history is preserved.
- Manually verify the MCP server starts cleanly and `reindex` runs without error before requesting review.
"`reindex` runs without error" means against a scratch index or a test target, **never the live
one**: MCP Inspector's fixed 60 s request timeout kills any full rebuild partway (ADR-049, B-057).
- Update the ADR Implementation Log before merging (Major changes only).

## 6. Commit Messages
Expand Down
284 changes: 284 additions & 0 deletions docs/adr/ADR-049-safe-reindex-and-tool-output.md

Large diffs are not rendered by default.

164 changes: 94 additions & 70 deletions src/MCPServer.py

Large diffs are not rendered by default.

92 changes: 92 additions & 0 deletions src/redact.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Secret redaction for text leaving the MCP server (ADR-049 §5, B-018).

Applied once, in MCPServer._tool's wrapper, to every tool's returned text. The
index stays faithful and the plain tool functions stay unredacted; only what an
agent receives is scrubbed. Each match becomes a visible ``[REDACTED:<kind>]``
marker, so a redaction fails loud instead of silently dropping text.

Patterns require each token family's real prefix, length and alphabet, with no
word character in front, so ordinary code (``sk_`` names, ``sk-`` class names,
short hex, ``AKIA`` in prose) passes through untouched.
Families with no prefix (an AWS secret key, a password) are matched by the name
they are assigned to, and only the value is replaced.
"""
import re

# Not preceded by a character that would make the prefix part of a longer word.
_START = r"(?<![A-Za-z0-9_])"
# Not followed by one that would make the token part of a longer one.
_END = r"(?![A-Za-z0-9_\-])"

# Order matters: the JSON field first, so a service-account key is reported as
# the field it sits in; the PEM block next, before any token pattern can match
# inside a key body; sk-ant- before the general sk- pattern.
_PATTERNS: list[tuple[str, re.Pattern]] = [
("private-key-field", re.compile(
r"""(?P<head>(?P<q>["'])private_key(?P=q)\s*:\s*)(?P<vq>["'])(?:(?!(?P=vq))[^\\]|\\.)+(?P=vq)""")),
("private-key", re.compile(
# The body is base64, line breaks (real, or a JSON string's literal \n) and
# the Proc-Type/DEK-Info headers, so code between two header constants
# is never taken for a key.
r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----"
r"(?:[A-Za-z0-9+/=\s\\:,\-]*?-----END [A-Z0-9 ]*PRIVATE KEY-----"
# No END line: the chunk was cut off inside the key; take its base64 lines.
r"|(?:(?:\s|\\n)*[A-Za-z0-9+/=]{16,})+)")),
("github-token", re.compile(_START + r"(?:gh[pousr]_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{50,})" + _END)),
("slack-token", re.compile(_START + r"xox[abprs]-[A-Za-z0-9-]{10,}" + _END)),
("aws-access-key", re.compile(_START + r"(?:AKIA|ASIA)[0-9A-Z]{16}" + _END)),
("google-api-key", re.compile(_START + r"AIza[0-9A-Za-z_\-]{35}" + _END)),
("anthropic-key", re.compile(_START + r"sk-ant-[A-Za-z0-9_\-]{20,}" + _END)),
("openai-key", re.compile(
_START + r"sk-(?:(?:proj|svcacct|admin)-[A-Za-z0-9_\-]{20,}|[A-Za-z0-9]{20,})" + _END)),
# Stripe secret and restricted keys, and webhook signing secrets. The 24-character
# tail keeps `sk_live = ...` and `sk_test_mode` names untouched; publishable pk_ keys
# are public by design.
("stripe-key", re.compile(_START + r"(?:(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{24,}|whsec_[A-Za-z0-9]{32,})" + _END)),
# The patterns below have no prefix of their own, so they key on the name the value is
# assigned to and replace only the value (the "val" group), skipping a value an earlier
# pattern already replaced. They accept some false positives: a long literal assigned to
# a name like `secret_name` is redacted too.
# An AWS secret key is 40 characters of base64; only a name saying so separates it
# from a git SHA. Before the generic assignment, which would also match the name.
("aws-secret-key", re.compile(
r"(?i)(?<![A-Za-z0-9])(?:aws_?)?secret_?access_?key[\"']?\s*[:=]\s*[\"']?"
r"(?P<val>[A-Za-z0-9/+]{40})(?![A-Za-z0-9/+=])")),
# user:password@ in a URL (database URLs, git remotes with a token).
("url-credential", re.compile(
r"(?i)\b[a-z][a-z0-9+.\-]*://[^\s:/@\"'<>]+:(?!\[REDACTED:)(?P<val>[^\s@/\"'<>]+)@")),
# Password=...; in a connection string. The trailing ; keeps `pwd = get()` out.
("connection-string-password", re.compile(
r"(?i)(?<![A-Za-z0-9_])(?:password|pwd)\s*=\s*(?!\[REDACTED:)(?P<val>[^;\"'\s]+)(?=\s*;)")),
# A quoted literal of 8 or more non-space characters assigned to (or keyed by) a name
# containing password, passwd, secret, api_key, apikey, access_token or auth_token.
# A value with a space is prose (a label or prompt), not a credential.
("secret-assignment", re.compile(
r"(?i)(?<![A-Za-z0-9])[A-Za-z0-9_\-]*(?:passw(?:or)?d|secret|api_?key|access_?token|auth_?token)"
r"[A-Za-z0-9_\-]*[\"']?\s*(?::=|=>|[:=])\s*(?P<q>[\"'`])(?!\[REDACTED:)"
r"(?P<val>[^\s\"'`\\]{8,})(?P=q)")),
]


def _marker(kind: str) -> str:
return f"[REDACTED:{kind}]"


def _replace_val(kind: str):
def sub(m: re.Match) -> str:
whole = m.group(0)
start, end = m.start("val") - m.start(), m.end("val") - m.start()
return whole[:start] + _marker(kind) + whole[end:]
return sub


def redact(text: str) -> str:
"""``text`` with every recognized secret replaced by ``[REDACTED:<kind>]``."""
for kind, pattern in _PATTERNS:
if kind == "private-key-field":
text = pattern.sub(lambda m: f"{m['head']}{m['vq']}{_marker(kind)}{m['vq']}", text)
elif "val" in pattern.groupindex:
text = pattern.sub(_replace_val(kind), text)
else:
text = pattern.sub(_marker(kind), text)
return text
3 changes: 2 additions & 1 deletion src/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -506,8 +506,9 @@ def _collect_params(self) -> dict:
else:
params[p.name] = val
except Exception:
# ADR-049: an unreadable Mode field means incremental, never a full rebuild.
params[p.name] = (
False if p.name in ("deep", "changed_files_only") else ""
False if p.name == "deep" else True if p.name == "changed_files_only" else ""
)
return params

Expand Down
144 changes: 144 additions & 0 deletions tests/test_safety_redact.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""ADR-049 §5 (B-018): secrets are redacted from tool output, with a marker naming
the kind, and ordinary code passes through untouched.

Every fixture here is obviously fake: the right prefix and length, filled with x.
"""
import pytest

from redact import redact

X = "x"

FAKE_PEM = "-----BEGIN PRIVATE KEY-----\nFAKE\n-----END PRIVATE KEY-----"

SECRETS = [
("private-key", FAKE_PEM),
("private-key", "-----BEGIN RSA PRIVATE KEY-----\nFAKE\n-----END RSA PRIVATE KEY-----"),
("private-key", "-----BEGIN OPENSSH PRIVATE KEY-----\nFAKE\n-----END OPENSSH PRIVATE KEY-----"),
("github-token", "ghp_" + X * 36),
("github-token", "gho_" + X * 36),
("github-token", "ghs_" + X * 36),
("github-token", "github_pat_" + X * 22 + "_" + X * 59),
("slack-token", "xoxb-0000000000-" + X * 12),
("slack-token", "xoxp-" + X * 20),
("slack-token", "xoxa-" + X * 20),
("aws-access-key", "AKIA" + "X" * 16),
("google-api-key", "AIza" + X * 35),
("openai-key", "sk-" + X * 48),
("openai-key", "sk-proj-" + X * 40),
("anthropic-key", "sk-ant-api03-" + X * 40),
]


@pytest.mark.parametrize("kind,secret", SECRETS, ids=[f"{k}-{i}" for i, (k, _) in enumerate(SECRETS)])
def test_each_secret_kind_is_replaced_by_a_marker_naming_it(kind, secret):
text = f'TOKEN = "{secret}"\nprint(TOKEN)\n'
out = redact(text)
assert secret not in out
assert out == f'TOKEN = "[REDACTED:{kind}]"\nprint(TOKEN)\n'


def test_a_service_account_private_key_field_is_redacted_as_the_field():
text = ('{"type": "service_account", "private_key_id": "abc123",\n'
' "private_key": "-----BEGIN PRIVATE KEY-----\\nFAKE\\n-----END PRIVATE KEY-----\\n",\n'
' "client_email": "svc@example.invalid"}')
out = redact(text)
assert "FAKE" not in out and "BEGIN" not in out
assert '"private_key": "[REDACTED:private-key-field]"' in out
assert '"private_key_id": "abc123"' in out and "svc@example.invalid" in out


def test_a_python_dict_private_key_field_is_redacted():
out = redact("creds = {'private_key': 'FAKEFAKEFAKE', 'project': 'demo'}")
assert out == "creds = {'private_key': '[REDACTED:private-key-field]', 'project': 'demo'}"


def test_a_key_cut_off_by_the_chunk_boundary_is_still_redacted():
"""A chunk can end inside a key, so there is no END line to match up to."""
text = "KEY = '''\n-----BEGIN PRIVATE KEY-----\n" + "FAKE" * 16 + "\n" + "FAKE" * 16 + "\n"
out = redact(text)
assert "FAKE" not in out and out == "KEY = '''\n[REDACTED:private-key]\n"


def test_text_after_a_cut_off_key_survives():
text = "-----BEGIN PRIVATE KEY-----\n" + "FAKE" * 16 + "\ndef after():\n pass\n"
assert redact(text) == "[REDACTED:private-key]\ndef after():\n pass\n"


@pytest.mark.parametrize("text", [
"sk_live = load_key()\nsk_test_mode = True\nmask_sk_value = sk_[0]",
"class='sk-button-primary-large-variant'",
"risk-assessment-and-mitigation-plan-for-q3",
"digest = '0xdeadbeef'; short = 'deadbeefcafebabe0123'",
"The AKIA prefix marks a long-term AWS access key; ASIA marks a temporary one.",
"Google keys start with AIza, so check the prefix.",
"ghp_token = os.environ['GITHUB_TOKEN']",
"if token.startswith('xoxb-'): send()",
"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA\n-----END PUBLIC KEY-----",
'PEM_HEADER = "-----BEGIN PRIVATE KEY-----"\nPEM_FOOTER = "-----END PRIVATE KEY-----"',
'cfg = {"private_key": key_from_vault(), "private_key_id": "abc"}',
"task-" + "a" * 30,
])
def test_ordinary_code_passes_through_untouched(text):
assert redact(text) == text


def test_a_secret_inside_a_longer_token_is_not_split():
"""A prefix in the middle of a longer identifier is not the start of a token."""
text = "my_ghp_" + X * 36 + " xAKIA" + "X" * 16
assert redact(text) == text


# The families with no prefix of their own (@edb, 2026-10-20: add them and accept the false
# positives). Only the value is replaced; the name it is assigned to stays readable.
NAMED_SECRETS = [
("stripe-key", 'STRIPE = "sk_live_' + X * 24 + '"', 'STRIPE = "[REDACTED:stripe-key]"'),
("stripe-key", 'k = "rk_test_' + X * 30 + '"', 'k = "[REDACTED:stripe-key]"'),
("stripe-key", 'hook = "whsec_' + X * 32 + '"', 'hook = "[REDACTED:stripe-key]"'),
("aws-secret-key", "aws_secret_access_key = " + X * 40,
"aws_secret_access_key = [REDACTED:aws-secret-key]"),
("aws-secret-key", '"SecretAccessKey": "' + X * 38 + '/+"',
'"SecretAccessKey": "[REDACTED:aws-secret-key]"'),
("url-credential", 'DB = "postgres://app:' + X * 12 + '@db.example.invalid/prod"',
'DB = "postgres://app:[REDACTED:url-credential]@db.example.invalid/prod"'),
("connection-string-password",
'"Server=db;Database=erp;User Id=sa;Password=' + X * 10 + ';"',
'"Server=db;Database=erp;User Id=sa;Password=[REDACTED:connection-string-password];"'),
("secret-assignment", 'password = "' + X * 12 + '"', 'password = "[REDACTED:secret-assignment]"'),
("secret-assignment", "DB_PASSWD: '" + X * 8 + "'", "DB_PASSWD: '[REDACTED:secret-assignment]'"),
("secret-assignment", '{"client_secret": "' + X * 20 + '"}',
'{"client_secret": "[REDACTED:secret-assignment]"}'),
("secret-assignment", "const apiKey = `" + X * 16 + "`;", "const apiKey = `[REDACTED:secret-assignment]`;"),
("secret-assignment", 'AuthToken := "' + X * 16 + '"', 'AuthToken := "[REDACTED:secret-assignment]"'),
]


@pytest.mark.parametrize("kind,text,expected", NAMED_SECRETS,
ids=[f"{k}-{i}" for i, (k, _, _) in enumerate(NAMED_SECRETS)])
def test_a_named_secret_has_only_its_value_replaced(kind, text, expected):
assert redact(text) == expected


@pytest.mark.parametrize("text", [
'password = get_password()',
'password = os.environ["DB_PASSWORD"]',
'label = "Enter your password"',
'PASSWORD_PROMPT = "Your password:"',
'password = ""',
'pwd = "short"',
'pwd = os.getcwd()',
'commit = "' + "a" * 40 + '"',
'sk_live = load_key()\nsk_test_mode = True',
'pk_live_' + X * 24,
'url = "https://example.invalid/path:8080@v2"',
'url = "https://example.invalid:8443/api"',
])
def test_code_near_the_named_families_passes_through(text):
assert redact(text) == text


def test_a_token_marker_is_not_redacted_again_by_a_named_pattern():
"""The token pattern names the kind; the assignment around it must not rename it."""
assert redact('API_KEY = "sk-ant-api03-' + X * 40 + '"') == 'API_KEY = "[REDACTED:anthropic-key]"'
assert (redact("https://user:ghp_" + X * 36 + "@github.com/o/r.git")
== "https://user:[REDACTED:github-token]@github.com/o/r.git")
Loading
Loading