/hook/record and /hook/approve accepted anything that reached the port. That is not "loopback only": `tailscale serve` proxies tailnet traffic to 127.0.0.1, so anyone who could reach the tunnel could forge timeline events and answer approval prompts. Both endpoints now require a 32-byte secret from $GLANCE_HOME/hook.secret (0600, created once, never rotated so nothing in flight is 403'd mid-session), compared in constant time before the body is read, as an x-glance-hook header or a ?k= parameter. Requests carrying x-forwarded-* are refused outright: a local hook process never sends them and a tunnelled caller always does. The check applies to /hook/* only, so the dashboard is unaffected. While wiring that up: the 13 passive `type: "http"` hooks could never have worked. Grok Build's http runner rejects every scheme but https, then resolves the host and blocks private/link-local/CGNAT addresses (validate_hook_url + is_blocked_ip), so neither loopback-over-http nor *.ts.net (100.64/10) can be a hook target - and it sends no header but Content-Type, so such a hook could not authenticate anyway. They were failing validation silently on every event. All of them are now command hooks running bin/glance-record.mjs, which costs a Node start and can present the secret. hooks.json is generated from hooks/hooks.template.json by scripts/gen-hooks.mjs (npm run build, glance sync-hooks). It creates the secret, derives the approval hook's timeout from approval.timeoutMs instead of hand-copying 125, and refuses to write a hook that cannot fire: bad type, non-positive timeout, non-https http URL, missing bin/ script, or a leftover placeholder. A template that embeds the token makes the output 0600 with a warning. Fail-open is unchanged: a missing, stale or rejected secret degrades to "no telemetry", and glance-approve.mjs still allows on every error path. glance status warns when the on-disk secret no longer matches the daemon's. Validated with the e2e suite (190 checks, including no-token/wrong-token/ same-length-token 403s, ?k= acceptance, x-forwarded-* refusal, and the recorder's fail-open paths) and a clean npm run build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
157 lines
5.5 KiB
JSON
157 lines
5.5 KiB
JSON
{
|
|
"_comment": [
|
|
"TEMPLATE. Do not edit hooks/hooks.json by hand - it is generated from this file by",
|
|
"`scripts/gen-hooks.mjs`, which runs as part of `npm run build` and on `glance sync-hooks`.",
|
|
"",
|
|
"Everything is a `command` hook, including the 13 passive recorders. That is not a style",
|
|
"choice: an `http` hook cannot reach this daemon by any route. Grok Build's http runner",
|
|
"(xai-grok-hooks/src/runner/http.rs, `validate_hook_url`) rejects every scheme but https,",
|
|
"then resolves the host and refuses the resolved address if it is private, link-local or",
|
|
"CGNAT - so plain http on loopback is out, and so is the tailnet, because *.ts.net resolves",
|
|
"into 100.64/10 (and fd7a::/48, inside the blocked fc00::/7). Pointing a hook at the public",
|
|
"https origin therefore fails upstream, before a request is ever sent. The runner also sends",
|
|
"no request header but Content-Type, so such a hook could not authenticate itself even if it",
|
|
"could connect.",
|
|
"",
|
|
"A command hook has none of those problems: it is a local process, so there is no URL to",
|
|
"validate, no proxy in the path, and it can present the shared secret. It costs one Node",
|
|
"start (~40ms) per event.",
|
|
"",
|
|
"SessionStart boots the daemon. PreToolUse is wired twice on purpose: one entry records",
|
|
"every call for the timeline, and a second, narrowly matched entry runs the approval gate,",
|
|
"because PreToolUse is the only blocking event and only a command hook can return a deny.",
|
|
"",
|
|
"Placeholders, written in the template as a name wrapped in double braces:",
|
|
" APPROVAL_TIMEOUT_SECS derived from approval.timeoutMs in ~/.grok/glance/config.json.",
|
|
" HOOK_TOKEN the secret from ~/.grok/glance/hook.secret, for a caller that",
|
|
" cannot set a header: the daemon accepts it as a ?k= query",
|
|
" parameter too. Nothing below uses it and no http hook can (see",
|
|
" above); it stays because the daemon's ?k= path is real. Note the",
|
|
" daemon also refuses any /hook/* request carrying x-forwarded-*,",
|
|
" so a reverse-proxied transport is out as well. Using this",
|
|
" placeholder makes the generated hooks.json secret-bearing, so",
|
|
" gen-hooks writes it 0600 and it must not be committed.",
|
|
"",
|
|
"Do not spell those names with their braces anywhere in this comment block: the comment is",
|
|
"copied verbatim into hooks.json, and substitution would happily expand it there too."
|
|
],
|
|
"hooks": {
|
|
"SessionStart": [
|
|
{
|
|
"hooks": [
|
|
{
|
|
"type": "command",
|
|
"command": "node \"$GROK_PLUGIN_ROOT/bin/glance-up.mjs\"",
|
|
"timeout": 20
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"PreToolUse": [
|
|
{
|
|
"hooks": [
|
|
{
|
|
"type": "command",
|
|
"command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"",
|
|
"timeout": 5
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"matcher": "^(Bash|Write|Edit|MultiEdit|NotebookEdit)$",
|
|
"hooks": [
|
|
{
|
|
"type": "command",
|
|
"command": "node \"$GROK_PLUGIN_ROOT/bin/glance-approve.mjs\"",
|
|
"timeout": "{{APPROVAL_TIMEOUT_SECS}}"
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"PostToolUse": [
|
|
{
|
|
"hooks": [
|
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
|
]
|
|
}
|
|
],
|
|
"PostToolUseFailure": [
|
|
{
|
|
"hooks": [
|
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
|
]
|
|
}
|
|
],
|
|
"UserPromptSubmit": [
|
|
{
|
|
"hooks": [
|
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
|
]
|
|
}
|
|
],
|
|
"PermissionDenied": [
|
|
{
|
|
"hooks": [
|
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
|
]
|
|
}
|
|
],
|
|
"Notification": [
|
|
{
|
|
"hooks": [
|
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
|
]
|
|
}
|
|
],
|
|
"Stop": [
|
|
{
|
|
"hooks": [
|
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
|
]
|
|
}
|
|
],
|
|
"StopFailure": [
|
|
{
|
|
"hooks": [
|
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
|
]
|
|
}
|
|
],
|
|
"SubagentStart": [
|
|
{
|
|
"hooks": [
|
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
|
]
|
|
}
|
|
],
|
|
"SubagentStop": [
|
|
{
|
|
"hooks": [
|
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
|
]
|
|
}
|
|
],
|
|
"PreCompact": [
|
|
{
|
|
"hooks": [
|
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
|
]
|
|
}
|
|
],
|
|
"PostCompact": [
|
|
{
|
|
"hooks": [
|
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
|
]
|
|
}
|
|
],
|
|
"SessionEnd": [
|
|
{
|
|
"hooks": [
|
|
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}
|