Home / Articles / Can a key leak without breaking the policy? Told, checked, never given / Versions / v1.1.0
Can a key leak without breaking the policy? Told, checked, never given: what changed in v1.1.0
From v1.0.0 (2026-10-20, 79a26dda3) to v1.1.0 (2026-10-20, this release), paragraph by paragraph.
20 paragraphs added, 3 removed, 5 changed in place, 51 unchanged. About 889 words added and 91 removed. Insertions are marked like this, deletions like this; unchanged runs are folded to one line; figures appear as their file names.
1 unchanged paragraph
Summary: One of our first customers asked a good question: could an API key, or someone's personal data, leak without breaking any Agent Behaviour Policy? The answer is yes, if the policy only lists what the agent can do. Sending an email is a capability; putting a key in it is content. So a policy that covers secrets needs rows about content too, such as disclosing a secret, and each row needs a barrier like any other. There are three levels. Told: a sentence in the prompt, kept by the model. Checked: a hook that scans every tool call, every tool result and the final answer. Never given: the agent asks for the action, and something out of its reach holds the key. We wrote the hook and tested it against nine ways a secret tries to leave. It denied three, redacted one, blocked one, let an ordinary command through, and missed three: a key encoded in base64, a key split in two, and a file of secrets piped straight out. ThoseThen threewe areadded whyfour experimental rules: look inside base64 and hex, match any ten characters of the bestsession's answerown secrets, refuse long base64 in outbound calls as a matter of policy, and refuse a command that sends a secrets file to the network. With them, all nine were caught. Run over the 3,451 real commands of the session that wrote this article, the hook refused 22, and none was ordinary work. The third level is still the thirdbest level:answer: an agent that never sees the key cannot leak it.
!shot lk-levels.webp | images/ | Nine ways a secret tries to leave, at threeeach levels.level. Told leaves every case to the model. Checked is the measured result of the hook below:hook: three denied, one redacted, one blocked, three missed. With four experimental rules, the three are refused. Never given leaves nothing to leak. | comparison
7 unchanged paragraphs, under In short {#in-short}
• Four experimental rules close the three. Base64 refused as policy, among them.
• Run on 3,451 real commands, it refused 22. None was ordinary work.
14 unchanged paragraphs, under The question {#question}, Capabilities and content {#content}, Three levels, and better names {#levels}…
It matches common key formats with a regular expression, card numbers with the Luhn check, and, most usefully, the exact values of the session's own secrets, read from its environment, so a match on those is not a guess. The experimental rules, described below, are on by default and can be turned off with SECRET_GUARD_EXPERIMENTAL=0.
```python #!/usr/bin/env python3 """secret_guard: a Claude Code hook that keeps secrets and personal data out of tool calls. PreToolUse: deny a call whose input carries a secret, or names a secret's variable. PostToolUse: redact secrets from a tool's result before the model sees it. Stop: block a final answer that carries a secret. EXPERIMENTAL rules (on by default; set SECRET_GUARD_EXPERIMENTAL=0 to turn them off): look inside base64 and hex, match pieces of the session's own secrets, refuse long base64 in outbound calls, and refuse a command that sends a secrets file to the network. CC BY 4.0, newsroom.sgit.ai.""" import base64, binascii, json, os, re, sys
3 unchanged paragraphs
EXPERIMENTAL = os.environ.get("SECRET_GUARD_EXPERIMENTAL", "1") != "0" B64 = re.compile(r"[A-Za-z0-9+/_-]{24,}={0,2}") NET = re.compile(r"\b(curl|wget|nc|ncat|scp|rsync|ftp|Invoke-WebRequest)\b") SECRET_FILE = re.compile(r"(?:^|[\s/'\"=<@])(\.env(?:\.[\w-]+)?|[\w-]+\.pem|id_rsa|id_ed25519|\.netrc|\.npmrc|\.pypirc|\.aws/credentials)\b")
def decoded(text): # what base64 and hex runs in the text say, decoded out = [] for m in B64.finditer(text): t = m.group() for dec in (lambda x: base64.b64decode(x + "=" * (-len(x) % 4)), lambda x: base64.urlsafe_b64decode(x + "=" * (-len(x) % 4))): try: out.append(dec(t).decode("utf-8")); break except (binascii.Error, UnicodeDecodeError, ValueError): pass for m in re.finditer(r"\b(?:[0-9a-fA-F]{2}){12,}\b", text): try: out.append(bytes.fromhex(m.group()).decode("utf-8")) except (ValueError, UnicodeDecodeError): pass return out
def pieces(text, n=10): # a known secret split into parts: any 10 characters of it in a row return ["a piece of $" + k for k, v in KNOWN.items() if any(v[i:i + n] in text for i in range(0, len(v) - n + 1))]
def blob(t): # long base64: not plain hex (a commit hash), not a path or an id in a URL (mostly short parts) parts = re.split(r"[/_-]", t) return (len(t) >= 40 and sum(len(x) < 8 for x in parts) <= 1 and not re.fullmatch(r"[0-9a-fA-F]+", t) and all(re.search(c, t) for c in ("[a-z]", "[A-Z]", "[0-9]")))
def experimental(text): f = [] for d in decoded(text): f += ["encoded " + x for x in findings(d)] f += pieces(text) if any(blob(m.group()) for m in B64.finditer(text)): f.append("a long base64 string (refused as policy)") if NET.search(text) and SECRET_FILE.search(text): f.append("a secrets file sent to the network") return sorted(set(f))
2 unchanged paragraphs
e = json.load(sys.stdin); ev = e.get("hook_event_name") if ev == "PreToolUse": t = json.dumps(e.get("tool_input", {})); f = findings(t) + references(t) if EXPERIMENTAL and not f: f = experimental(t) if f: print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "secret_guard: this call carries " + ", ".join(f) + ". Ask for the action, not the secret."}})) elif ev == "PostToolUse": r = e.get("tool_response") if findings(json.dumps(r)): print(json.dumps({"hookSpecificOutput": {"hookEventName": "PostToolUse", "updatedToolOutput": redact(r), "additionalContext": "secret_guard redacted a secret from this result."}})) elif ev == "Stop": f = findings(e.get("last_assistant_message") or "") if f and not e.get("stop_hook_active"): print("secret_guard: your answer carries " + ", ".join(f) + ". Rewrite it without them.", file=sys.stderr); sys.exit(2) ```
8 unchanged paragraphs, under Nine leaks, tested {#tested}
The three misses are not bugs in this hook. They are what a check on the text of a command cannot see: an encoding, an assembly, or a file the hook never reads. More patterns would catch the first two some of the time. The third needs the hook to know which files hold secrets, and the agent can always find another way to read one.
The three misses are not bugs in the first version of this hook. They are what a check on the text of a command cannot see: an encoding, an assembly, or a file the hook never reads.
Experimental rules {#experimental}
Each of the three has a recognisable shape, so we added four rules and marked them experimental, to try before making them official.
• Look inside encodings. Decode every base64 and hex run in a call, and scan what it says. A key in base64 is still a key.
• Match pieces of known secrets. Any ten characters in a row of the session's own secrets is a match, so a key split into parts is caught as long as one part is ten characters long. Ten random characters do not appear by chance.
• Refuse long base64 outright. An agent rarely has a reason to send a long base64 string anywhere, so the policy is simply no: forty characters or more, mixing cases and digits, not a commit hash, and not a path or an identifier inside a URL.
• Refuse a secrets file sent to the network. A command that names a file such as .env, a .pem key or .aws/credentials and also runs curl, wget, nc or scp is refused.
With them on, all nine cases were caught: the base64 key was refused twice over (decoded, and as a long base64 string), the split key as a piece of the known secret, and cat .env | curl as a secrets file sent to the network. We also tested random data in base64, a known key format inside a base64 blob, and the key in hex: all refused. And we tested what should pass: a commit hash, long file paths with version numbers, a GitHub URL, a commit with a co-author line, reading .env without sending it. All allowed.
The real test is ordinary work, so we ran the hook over every shell command of the session that wrote this article: 3,451 of them. The first version of the base64 rule refused 183, almost all because of the random session identifier in every commit message; the second, which ignores identifiers inside URLs, refused 22. Thirteen were real: the session passing its own vault token to a tool on the command line, which is exactly what the policy says not to do, even though the use was legitimate. Seven were the test data for this hook, and two were the example key from AWS's own documentation. None was ordinary work.
Those thirteen are the best argument for the next section. The rule was right; the fix is not to allow it, but to stop giving the token to the session.
11 unchanged paragraphs, under Never given {#never}, What to write in the policy {#policy}, What this does not claim {#limits}
• The hook is an example. Tested on nine cases, not on every way out.
• Regular expressions miss things. Encodings, splits and unknown formats.
• The hook is an example. Tested on seventeen cases and one session, not on every way out.
• The experimental rules are experimental. Expect false positives in other kinds of work.
• Regular expressions miss things. Unknown formats, short pieces, other encodings.
5 unchanged paragraphs, under Where this comes from {#sources}