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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Security

- **The session token no longer shows in a command line.** Every hook script
sent its report with the token in the URL it handed `curl`, and a process's
command line can be read by every user on the machine, so the token was there
to read each time a hook fired. The scripts now send it in an
`Authorization: Bearer` header fed to `curl` through its standard input. A
lich released before it accepts that header refuses the report, and only then
is it sent again the old way, so cards keep working under any lich; the token
stays out of the command line once lich is updated. Claude Code's mods and the
opencode and omp modules never ran a process to report and are unchanged.

## [0.20.9] - 2026-11-01

### Added
Expand Down
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,10 @@ node --test tests/*.test.mjs
[tests/hooks.test.mjs](tests/hooks.test.mjs) drives every hook script as a real
subprocess — the command line taken from the registration each harness reads —
against a stub HTTP server, and asserts the body it POSTs against lich's
contract fixtures: an accepted shape, no rejected one, the right endpoint and
`?token=`, the `X-Lich-Plugin` header naming the manifest's version, no
contract fixtures: an accepted shape, no rejected one, the right endpoint, the
token in an `Authorization: Bearer` header and in no process's command line
(with the `?token=` retry a lich older than the header needs), the
`X-Lich-Plugin` header naming the manifest's version, no
deprecated `claude_session_id`, plus the client rules (no lich
environment → no report, exit 0 on a 500 or a refused connection).
[tests/opencode.test.mjs](tests/opencode.test.mjs) does the same for the
Expand Down
14 changes: 10 additions & 4 deletions hooks/report-session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,18 @@ provider_session_id=$(printf '%s' "$payload" | conversation_id)
# escaping needed — same trust model as report-state.sh.
body="{\"session_id\":\"${LICH_SESSION_ID}\",\"provider_session_id\":\"${provider_session_id}\",\"provider\":\"${provider}\"}"

curl -s -o /dev/null --max-time 1 \
-X POST "http://127.0.0.1:${LICH_PORT}/session-start?token=${LICH_TOKEN}" \
# The token rides a header fed through stdin (printf is a builtin): a query
# string or -H would put it in curl's command line, which every user on the
# machine can read. A lich older than the header answers 401, and only then is
# the report sent the old way.
url="http://127.0.0.1:${LICH_PORT}/session-start"
set -- -s -o /dev/null --max-time 1 -X POST \
-H 'Content-Type: application/json' \
-H "X-Lich-Plugin: ${plugin_version}" \
-d "$body" \
|| true
-d "$body"
answer=$(printf 'header = "Authorization: Bearer %s"\n' "$LICH_TOKEN" |
curl "$@" -w '%{http_code}' -K - "$url")
[ "$answer" = 401 ] && curl "$@" "$url?token=${LICH_TOKEN}"

# Never blocks or fails the turn.
exit 0
14 changes: 10 additions & 4 deletions hooks/report-state.sh
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,18 @@ fi
provider_session_id=$(printf '%s' "$payload" | conversation_id)
[ -n "$provider_session_id" ] && body="${body%\}},\"provider_session_id\":\"${provider_session_id}\"}"

curl -s -o /dev/null --max-time 1 \
-X POST "http://127.0.0.1:${LICH_PORT}/hook?token=${LICH_TOKEN}" \
# The token rides a header fed through stdin (printf is a builtin): a query
# string or -H would put it in curl's command line, which every user on the
# machine can read. A lich older than the header answers 401, and only then is
# the report sent the old way.
url="http://127.0.0.1:${LICH_PORT}/hook"
set -- -s -o /dev/null --max-time 1 -X POST \
-H 'Content-Type: application/json' \
-H "X-Lich-Plugin: ${plugin_version}" \
-d "$body" \
|| true
-d "$body"
answer=$(printf 'header = "Authorization: Bearer %s"\n' "$LICH_TOKEN" |
curl "$@" -w '%{http_code}' -K - "$url")
[ "$answer" = 401 ] && curl "$@" "$url?token=${LICH_TOKEN}"

# Never blocks or fails the turn.
exit 0
18 changes: 14 additions & 4 deletions hooks/report-title.sh
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,24 @@ body=$(jq -cn --arg sid "$LICH_SESSION_ID" --arg title "$title" \

# -f so a refusal counts as one: a title lich did not take is one worth looking
# for again on the next tool call, where latching on it would wait for Stop.
if curl -sf -o /dev/null --max-time 1 \
-X POST "http://127.0.0.1:${LICH_PORT}/session-title?token=${LICH_TOKEN}" \
#
# The token rides a header fed through stdin (printf is a builtin): a query
# string or -H would put it in curl's command line, which every user on the
# machine can read. A lich older than the header answers 401, and only then is
# the report sent the old way.
url="http://127.0.0.1:${LICH_PORT}/session-title"
set -- -sf -o /dev/null --max-time 1 -X POST \
-H 'Content-Type: application/json' \
-H "X-Lich-Plugin: ${plugin_version}" \
-d "$body"
then
[ -n "$latch" ] && : >"$latch" 2>/dev/null
answer=$(printf 'header = "Authorization: Bearer %s"\n' "$LICH_TOKEN" |
curl "$@" -w '%{http_code}' -K - "$url")
took=$?
if [ "$answer" = 401 ]; then
curl "$@" "$url?token=${LICH_TOKEN}"
took=$?
fi
[ "$took" -eq 0 ] && [ -n "$latch" ] && : >"$latch" 2>/dev/null

# Never blocks or fails the turn.
exit 0
14 changes: 10 additions & 4 deletions hooks/report-tool.sh
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,18 @@ fi
provider_session_id=$(printf '%s' "$payload" | conversation_id)
[ -n "$provider_session_id" ] && body="${body%\}},\"provider_session_id\":\"${provider_session_id}\"}"

curl -s -o /dev/null --max-time 1 \
-X POST "http://127.0.0.1:${LICH_PORT}/hook?token=${LICH_TOKEN}" \
# The token rides a header fed through stdin (printf is a builtin): a query
# string or -H would put it in curl's command line, which every user on the
# machine can read. A lich older than the header answers 401, and only then is
# the report sent the old way.
url="http://127.0.0.1:${LICH_PORT}/hook"
set -- -s -o /dev/null --max-time 1 -X POST \
-H 'Content-Type: application/json' \
-H "X-Lich-Plugin: ${plugin_version}" \
-d "$body" \
|| true
-d "$body"
answer=$(printf 'header = "Authorization: Bearer %s"\n' "$LICH_TOKEN" |
curl "$@" -w '%{http_code}' -K - "$url")
[ "$answer" = 401 ] && curl "$@" "$url?token=${LICH_TOKEN}"

# Never blocks or fails the turn.
exit 0
14 changes: 10 additions & 4 deletions hooks/report-touched.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,18 @@
# Sent as X-Lich-Plugin on every report; bumped at release (CLAUDE.md, Release).
plugin_version=0.20.9

curl -s -o /dev/null --max-time 1 \
-X POST "http://127.0.0.1:${LICH_PORT}/session-touched?token=${LICH_TOKEN}" \
# The token rides a header fed through stdin (printf is a builtin): a query
# string or -H would put it in curl's command line, which every user on the
# machine can read. A lich older than the header answers 401, and only then is
# the report sent the old way.
url="http://127.0.0.1:${LICH_PORT}/session-touched"
set -- -s -o /dev/null --max-time 1 -X POST \
-H 'Content-Type: application/json' \
-H "X-Lich-Plugin: ${plugin_version}" \
-d "{\"session_id\":\"${LICH_SESSION_ID}\"}" \
|| true
-d "{\"session_id\":\"${LICH_SESSION_ID}\"}"
answer=$(printf 'header = "Authorization: Bearer %s"\n' "$LICH_TOKEN" |
curl "$@" -w '%{http_code}' -K - "$url")
[ "$answer" = 401 ] && curl "$@" "$url?token=${LICH_TOKEN}"

# Never blocks or fails the turn.
exit 0
10 changes: 8 additions & 2 deletions tests/contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -195,12 +195,17 @@ export function matchingAcceptCase(endpoint, body) {
const ANY_SHAPE = { '/hook': ['provider_session_id'] }

export function assertContractHonoured(endpoint, request) {
assert.equal(request.method, 'POST')
assert.equal(
request.url,
`${endpoint}?token=${TOKEN}`,
`posted to ${request.url}, expected ${endpoint}?token=${TOKEN}`,
)
return assertReportHonoured(endpoint, request)
}

/** Everything a report owes the contract except how its token travels. */
export function assertReportHonoured(endpoint, request) {
assert.equal(request.method, 'POST')
assert.match(request.headers['content-type'] ?? '', /application\/json/)
assert.equal(
request.headers['x-lich-plugin'],
Expand Down Expand Up @@ -240,6 +245,7 @@ const liveStubs = new Set()
*/
export const settleStubs = () => Promise.all([...liveStubs].map((stub) => stub.settle()))

/** `status` is the answer to every request, or a function of the request. */
export async function startStub({ status = 204 } = {}) {
const requests = []
const sockets = new Set()
Expand All @@ -251,7 +257,7 @@ export async function startStub({ status = 204 } = {}) {
})
req.on('end', () => {
requests.push({ method: req.method, url: req.url, headers: req.headers, raw })
res.writeHead(status).end()
res.writeHead(typeof status === 'function' ? status(req) : status).end()
})
})
server.on('connection', (socket) => {
Expand Down
Loading
Loading