Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions .claude/hooks/session-journal.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
except ImportError: # not POSIX: run without the lock
fcntl = None

VERSION = '2.0.0'
VERSION = '2.0.1'
SKIP_OK = '<!-- journal-secrets-ok -->'
NAME = re.compile(r'^(\d{4}-\d\d-\d\d-(?:\d{4}-)?[A-Za-z0-9._-]+\.md)$') # HHMM is optional: older journals lack it
CLOSED = {'DONE', 'COMPLETE', 'COMPLETED', 'DELIVERED', 'CLOSED', 'SUPERSEDED', 'ABANDONED', '?'} # anything else is open
Expand Down Expand Up @@ -1005,8 +1005,21 @@ def on_post_tool(ctx, inp):
lease['size_warned'] = free[-1]
notes.append(f"{ctx.show(free[-1])} is {c['bytes'] // 1024} KB. Finish this update, then continue the "
f"work in a new journal whose header says '**Continues:** {ctx.show(free[-1])}'.")
leaks = []
if cmd is not None and ctx.secrets != 'off':
# A shell write can carry a credential PreToolUse never saw (`cat secret > "$F"`): scan the journal itself.
for jn in names:
hits = scan_secrets(read(ctx.jpath(jn)))
if hits:
leaks.append((jn, hits))
if ctx.secrets == 'deny':
lease['leaked'] = sorted(set(lease.get('leaked') or []) | {jn for jn, _ in leaks})
ctx.save_lease(lease)
if names and ctx.secrets == 'warn':
for jn, hits in leaks:
ctx.trace(ev='secret-after-write', sid=lease['sid'], journal=jn, kinds=[h[1] for h in hits])
notes.append(f"{ctx.show(jn)} now contains credential-shaped content ({fmt_hits(hits)}). Remove the value "
"now and write where the secret lives instead; journals are committed and shared.")
if names and cmd is None and ctx.secrets == 'warn':
hits = scan_secrets(tool_text(tool, ti))
if hits:
ctx.trace(ev='secret-warning', sid=lease['sid'], journal=names[-1], kinds=[h[1] for h in hits])
Expand Down Expand Up @@ -1047,6 +1060,17 @@ def on_stop(ctx, inp):
nudge = None
with ctx.lock():
lease = get_lease(ctx, inp)
leaks = [(jn, scan_secrets(read(ctx.jpath(jn)))) for jn in lease.get('leaked') or []]
leaks = [(jn, h) for jn, h in leaks if h]
if lease.get('leaked'):
lease['leaked'] = [jn for jn, _ in leaks]
if leaks and not inp.get('stop_hook_active'):
ctx.save_lease(lease)
ctx.trace(ev='stop', sid=sid, result='secret', journal=leaks[-1][0])
return {'decision': 'block', 'reason': 'SESSION-JOURNAL-SECRETS: ' + '; '.join(
f'{ctx.show(jn)} still contains credential-shaped content ({fmt_hits(h)})' for jn, h in leaks) +
'. Remove the values and write where each secret lives (a vault entry, an env var name, or '
'<redacted>). Mark a line <!-- journal-secrets-ok --> only after a human has checked it.'}
key = turn_key(inp, lease, fallback=f['turn_start_ts'])
t0 = iso_epoch(f['turn_start_ts'] or '') - 2
turn = turn_record(lease, key)
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ All notable changes to this project are recorded here. Versions follow
[semantic versioning](https://semver.org/): the public surface is the journal
shape, the hook contract, the installer flags, and the environment variables.

## 2.0.1

- **A credential written by an opaque shell command is caught.** `F=<journal>; cat notes > "$F"` hides the target
and the value from `PreToolUse`. After a shell command changes a journal, `PostToolUse` now scans the journal file
itself and tells the agent to remove what matched; in `deny` mode, Stop blocks until the journal is clean. Values
are never echoed. Found by an automated review of the 2.0.0 pull request.

## 2.0.0

Session-bound journals. 1.x asked "which journal is newest?" at every session start and every turn end. With more
Expand Down
7 changes: 5 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,11 @@ The installer never fetches code. It only copies from the clone you run it from.

`PreToolUse` scans the **new text** of each journal write: an editing tool, `apply_patch`, or a recognisable shell
write. It looks for credential shapes and denies the write when one matches. The denial names line numbers and rule
names, never the matched value, so the hook cannot leak a secret into a transcript. `scan` and `./doctor.sh` apply
the same rules to every journal on disk.
names, never the matched value, so the hook cannot leak a secret into a transcript. A shell command can hide both
its target and its content (`cat notes > "$F"`), so after any shell command that changed a journal, `PostToolUse`
scans the journal file itself. A match cannot be denied after the fact: the agent is told to remove it, and in
`deny` mode Stop blocks until the journal is clean. `scan` and `./doctor.sh` apply the same rules to every journal on
disk.

It is a continuity aid, not a credential scanner:

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.0.0
2.0.1
2 changes: 1 addition & 1 deletion docs/verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ bats tests

| Gate | Claim |
| --- | --- |
| Python scenario tests | <!-- unittest-count -->60<!-- /unittest-count --> scenario methods in `tests/test_journal.py` (the session-binding class runs twice, once under `docs/sessions`), all passing |
| Python scenario tests | <!-- unittest-count -->61<!-- /unittest-count --> scenario methods in `tests/test_journal.py` (the session-binding class runs twice, once under `docs/sessions`), all passing |
| bats-core | <!-- bats-count -->60<!-- /bats-count --> checks, all passing |
| Shellcheck and `bash -n` | clean for every shell entrypoint |
| Self-check | `./doctor.sh .` passes against this repository's own installation |
Expand Down
28 changes: 26 additions & 2 deletions hooks/session-journal.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
except ImportError: # not POSIX: run without the lock
fcntl = None

VERSION = '2.0.0'
VERSION = '2.0.1'
SKIP_OK = '<!-- journal-secrets-ok -->'
NAME = re.compile(r'^(\d{4}-\d\d-\d\d-(?:\d{4}-)?[A-Za-z0-9._-]+\.md)$') # HHMM is optional: older journals lack it
CLOSED = {'DONE', 'COMPLETE', 'COMPLETED', 'DELIVERED', 'CLOSED', 'SUPERSEDED', 'ABANDONED', '?'} # anything else is open
Expand Down Expand Up @@ -1005,8 +1005,21 @@ def on_post_tool(ctx, inp):
lease['size_warned'] = free[-1]
notes.append(f"{ctx.show(free[-1])} is {c['bytes'] // 1024} KB. Finish this update, then continue the "
f"work in a new journal whose header says '**Continues:** {ctx.show(free[-1])}'.")
leaks = []
if cmd is not None and ctx.secrets != 'off':
# A shell write can carry a credential PreToolUse never saw (`cat secret > "$F"`): scan the journal itself.
for jn in names:
hits = scan_secrets(read(ctx.jpath(jn)))
Comment on lines +1009 to +1012

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scan Bash calls whose command text hides the journal path

With the installed Bash hooks, a command such as ./update-journal.sh or . /tmp/vars; cat /tmp/notes > "$F" can modify a journal without containing the literal journal-directory name or matching the landing regex. run_hook() then exits through tool_may_matter() for both PreToolUse and PostToolUse, so no pending timestamp is recorded and this new scan never runs; the credential remains and Stop has no leaked entry. Route opaque Bash calls through the before/after detection path as well.

Useful? React with 👍 / 👎.

if hits:
leaks.append((jn, hits))
Comment on lines +1011 to +1014

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve bulk changes as credential-scan candidates

When an opaque shell command modifies three or more journals, journals_changed_since() returns more than two extras and the existing bulk guard discards the entire set before this loop. Thus names is empty, none of the modified journals is scanned, and credentials written by the bulk command produce neither a warning nor a Stop block. Keep the candidates used for security scanning separate from the smaller set allowed to drive session binding.

Useful? React with 👍 / 👎.

if ctx.secrets == 'deny':
lease['leaked'] = sorted(set(lease.get('leaked') or []) | {jn for jn, _ in leaks})
ctx.save_lease(lease)
if names and ctx.secrets == 'warn':
for jn, hits in leaks:
ctx.trace(ev='secret-after-write', sid=lease['sid'], journal=jn, kinds=[h[1] for h in hits])
notes.append(f"{ctx.show(jn)} now contains credential-shaped content ({fmt_hits(hits)}). Remove the value "
"now and write where the secret lives instead; journals are committed and shared.")
if names and cmd is None and ctx.secrets == 'warn':
hits = scan_secrets(tool_text(tool, ti))
if hits:
ctx.trace(ev='secret-warning', sid=lease['sid'], journal=names[-1], kinds=[h[1] for h in hits])
Expand Down Expand Up @@ -1047,6 +1060,17 @@ def on_stop(ctx, inp):
nudge = None
with ctx.lock():
lease = get_lease(ctx, inp)
leaks = [(jn, scan_secrets(read(ctx.jpath(jn)))) for jn in lease.get('leaked') or []]
leaks = [(jn, h) for jn, h in leaks if h]
if lease.get('leaked'):
lease['leaked'] = [jn for jn, _ in leaks]
if leaks and not inp.get('stop_hook_active'):
ctx.save_lease(lease)
ctx.trace(ev='stop', sid=sid, result='secret', journal=leaks[-1][0])
return {'decision': 'block', 'reason': 'SESSION-JOURNAL-SECRETS: ' + '; '.join(
Comment on lines +1067 to +1070

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep blocking while a recorded leak remains

After the first Stop block, the runtime's next Stop invocation has stop_hook_active=true; if the agent failed, refused, or only partially completed the requested cleanup, this condition skips the secret block and the session can finish with the credential still present. The newly added scenario reproduces this by leaving the file untouched after the first block: the active Stop returns no decision while lease['leaked'] remains populated, contradicting deny mode's promise to block until clean.

Useful? React with 👍 / 👎.

f'{ctx.show(jn)} still contains credential-shaped content ({fmt_hits(h)})' for jn, h in leaks) +
'. Remove the values and write where each secret lives (a vault entry, an env var name, or '
'<redacted>). Mark a line <!-- journal-secrets-ok --> only after a human has checked it.'}
key = turn_key(inp, lease, fallback=f['turn_start_ts'])
t0 = iso_epoch(f['turn_start_ts'] or '') - 2
turn = turn_record(lease, key)
Expand Down
25 changes: 25 additions & 0 deletions tests/test_journal.py
Original file line number Diff line number Diff line change
Expand Up @@ -925,6 +925,31 @@ def test_bash_heredoc_with_a_credential_is_denied(self):
d = self.hook('PreToolUse', 'f1f1f1f1-1', tool_name='Bash', tool_input={'command': cmd}, tool_use_id='u')
self.assertEqual(d['hookSpecificOutput']['permissionDecision'], 'deny')

def test_a_credential_written_by_an_opaque_shell_command_is_caught_after_the_fact(self):
"""Review finding: `F=...; cat /tmp/credential > "$F"` hides both the target and the value from PreToolUse."""
t = Transcript(os.path.join(self.tmp, 'o.jsonl'))
self.hook('SessionStart', 'f3f3f3f3-1', source='startup', transcript_path=t.path)
t.human('record the deploy')
cmd = f'F=$M/{self.jrel}/$(date +%Y)-deploy.md; cat /tmp/notes > "$F"'
self.assertIsNone(self.hook('PreToolUse', 'f3f3f3f3-1', tool_name='Bash', tool_input={'command': cmd},
tool_use_id='o1', transcript_path=t.path))
p = self.jfile('2026-01-08-2100-deploy.md', title='deploy')
open(p, 'a').write(self.LEAK + '\n')
t.tool('Bash', command=cmd)
d = self.hook('PostToolUse', 'f3f3f3f3-1', tool_name='Bash', tool_input={'command': cmd}, tool_use_id='o1',
transcript_path=t.path)
note = d['hookSpecificOutput']['additionalContext']
self.assertIn('credential-shaped', note)
self.assertIn('api-key', note)
self.assertNotIn('live4eC', note)
stop = self.stop('f3f3f3f3-1', t)
self.assertEqual(stop['decision'], 'block')
self.assertIn('SESSION-JOURNAL-SECRETS', stop['reason'])
self.assertNotIn('live4eC', stop['reason'])
open(p, 'w').write(open(p).read().replace(self.LEAK, 'api_key: <redacted>'))
self.assertNotIn('SESSION-JOURNAL-SECRETS', json.dumps(self.stop('f3f3f3f3-1', t)))
self.assertEqual(self.lease('f3f3f3f3-1').get('leaked'), [])

def test_warn_mode_is_advisory(self):
env = {'JOURNAL_SECRETS': 'warn'}
t = Transcript(os.path.join(self.tmp, 'w.jsonl'))
Expand Down
Loading