diff --git a/.claude/hooks/session-journal.py b/.claude/hooks/session-journal.py index c7a6f6f..c8d0897 100755 --- a/.claude/hooks/session-journal.py +++ b/.claude/hooks/session-journal.py @@ -57,7 +57,7 @@ except ImportError: # not POSIX: run without the lock fcntl = None -VERSION = '2.0.0' +VERSION = '2.0.1' SKIP_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 @@ -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]) @@ -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 ' + '). Mark a line 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) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49d9a4a..9ce3cd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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=; 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 diff --git a/SECURITY.md b/SECURITY.md index d144662..dcbcc26 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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: diff --git a/VERSION b/VERSION index 227cea2..38f77a6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.0 +2.0.1 diff --git a/docs/verification.md b/docs/verification.md index 5963748..34581b8 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -20,7 +20,7 @@ bats tests | Gate | Claim | | --- | --- | -| Python scenario tests | 60 scenario methods in `tests/test_journal.py` (the session-binding class runs twice, once under `docs/sessions`), all passing | +| Python scenario tests | 61 scenario methods in `tests/test_journal.py` (the session-binding class runs twice, once under `docs/sessions`), all passing | | bats-core | 60 checks, all passing | | Shellcheck and `bash -n` | clean for every shell entrypoint | | Self-check | `./doctor.sh .` passes against this repository's own installation | diff --git a/hooks/session-journal.py b/hooks/session-journal.py index c7a6f6f..c8d0897 100644 --- a/hooks/session-journal.py +++ b/hooks/session-journal.py @@ -57,7 +57,7 @@ except ImportError: # not POSIX: run without the lock fcntl = None -VERSION = '2.0.0' +VERSION = '2.0.1' SKIP_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 @@ -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]) @@ -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 ' + '). Mark a line 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) diff --git a/tests/test_journal.py b/tests/test_journal.py index 0691062..aa9c7bc 100644 --- a/tests/test_journal.py +++ b/tests/test_journal.py @@ -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: ')) + 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'))