Compare commits

...

5 Commits

Author SHA1 Message Date
66582b498c fix(neogaia): replace guessed hardware detection with a real scan (task 0017)
The detection file was written before the machine ran NixOS, as an educated
guess at what a Dell XPS 13 9380 needs, and said so. Its contents now come
from nixos-generate-config on the running machine.

The guess was wider than the measurement rather than narrower: it named
thunderbolt, usb_storage and sd_mod, none of which the scan reports, and
omitted rtsx_pci_sdmmc. Nothing needed to reach the root device was missing.

Filesystem, swap, LUKS and microcode declarations from the scan are dropped.
The disk layout derives the first three and the hardware profile supplies the
last, so carrying them here would duplicate definitions that are produced on
every evaluation.
2026-07-19 22:28:02 -04:00
54c191f801 feat(claude-code): share sudo's credential cache across sessions (task 0018)
Key sudo's credential cache per user rather than per terminal, holding it
for 60 minutes. An authentication made in the operator's own terminal then
covers commands issued by processes holding no terminal of their own, which
previously failed with a bare non-zero exit and no output.

No command is made passwordless. The password remains required; only the
cache holding it is shared, and any process running as the primary user can
spend that credential until it lapses.

A PreToolUse hook refuses a privileged command while the cache is cold,
naming the command that warms it, so the condition announces itself rather
than presenting as a stall. Both states were exercised against the running
system.
2026-07-19 22:18:58 -04:00
d0bfc7b21b docs: correct the gitea-axi gotcha and record home.file's absolute keys 2026-07-19 19:08:54 -04:00
e5d8f69f16 feat(claude-code): ask questions in prose, never via AskUserQuestion 2026-07-19 19:04:48 -04:00
42c602b814 docs: close out task 0016 after reimaging neogaia
The reimage is done and verified on hardware: the boot partition is 2 GiB
and reports it, the hardware profile's kernel parameter and module blacklist
are live, and the encrypted root unlocks to a console login.

The install procedure ran without divergence, which is the reproducibility
evidence the task was after.

The reimage also wiped the hand-written git identity, so the gotcha
describing it is updated to record the failure it now provokes.
2026-07-19 18:53:17 -04:00
8 changed files with 229 additions and 66 deletions

View File

@@ -22,40 +22,38 @@ One thing must be true before the disk is erased: every branch worth keeping has
- [x] The `Host`'s disk layout declares a 2 GiB EFI system partition
- [x] Every local branch worth keeping exists on the remote before the disk is erased
- [ ] The reimage is performed from a configuration carrying the hardware profile, following the existing install documentation
- [ ] The install documentation is corrected wherever the procedure diverged from what it describes
- [ ] Manual confirmation: the machine boots, the encrypted root unlocks, and console login succeeds
- [ ] Manual confirmation: reported free space on the boot partition is consistent with its 2 GiB size, resolving the discrepancy observed before the reimage — where a 512 MiB partition reported 1022 MiB
- [x] The reimage is performed from a configuration carrying the hardware profile, following the existing install documentation
- [x] The install documentation is corrected wherever the procedure diverged from what it describes
- [x] Manual confirmation: the machine boots, the encrypted root unlocks, and console login succeeds
- [x] Manual confirmation: reported free space on the boot partition is consistent with its 2 GiB size, resolving the discrepancy observed before the reimage — where a 512 MiB partition reported 1022 MiB
- [x] The project's agent instructions record that a flake only sees git-tracked files, so an untracked file is invisible to evaluation
## Implementation Notes
This task is **blocked on an operator action**, not finished. Three of seven
criteria are satisfied: the declaration, the branch check, and the recorded
gotcha. The remaining four all depend on erasing the disk, which is not an
action taken on the operator's behalf.
Done. The reimage was performed by the operator and the machine now runs the
configuration this repository declares.
The branch check was verified rather than assumed — no local branch holds a
commit absent from the remote, so nothing is lost to the wipe. Two items that
live outside the repo do not survive it and are not covered by any criterion:
the agent memory directory, and the wifi credentials.
The install ran clean: the operator reports no step diverged from
`docs/install.md`, so criterion 4 is satisfied with no further corrections. The
four corrections that landed earlier came from reading the procedure; the run
itself found nothing to add. That is the reproducibility evidence the task was
after — the document is a procedure, not a record of one improvised session.
Criterion 4 is deliberately left open despite four corrections already landing
on the main branch — a wrong repository name in both clone commands, a closing
section describing a superseded key-derivation design, a stale enumeration of
flake inputs, and a bootstrap-ordering sentence contradicting a later one. All
four were found by reading the procedure. The criterion asks for divergences
found by *running* it, which has not happened. The wrong repository name would
have stopped the install at the clone step, so the reading pass was worth doing;
it is just not the same evidence.
Verified on the running machine rather than assumed:
**Ordering hazard.** Between merging this and completing the reimage, the
repository asserts a partition layout the physical disk does not have, and disko
reconciles nothing on a running machine. The declaration should reach the branch
the install reads immediately before the install, not days ahead of it. If the
reimage is deferred, this is the file that quietly lies about the only laptop.
- `/dev/nvme0n1p1` is 2.0 GiB and `df` reports 2.0 GiB. The pre-reimage
discrepancy, where a 512 MiB partition reported 1022 MiB, is gone.
- The hardware profile is live — `mem_sleep_default=deep` is on the kernel
command line and `psmouse` is blacklisted and not loaded.
- `cryptroot` is open on `nvme0n1p2` with btrfs mounted, reached through a
console login, so the boot-unlock-login path is exercised end to end.
- One generation exists (`system-1-link`), confirming a fresh install rather
than a rebuild of the prior system.
One correction outside this task's scope also landed here: the gotcha claiming
git identity was unconfigured, which a hand-written `~/.gitconfig` had made
false. Moving that identity into the flake belongs to the commit-identity work
this task's description explicitly fences off.
The ordering hazard closed favourably: the declaration and the install landed
close enough together that the repository never asserted a layout the disk
lacked for long.
Two items outside the repo did not survive the wipe, as anticipated, and neither
is covered by a criterion: the wifi credentials, and the agent memory directory
— confirmed empty after the reimage.

View File

@@ -14,7 +14,27 @@ Generating the scan requires root on the target machine.
## Acceptance criteria
- [ ] The detection file's contents come from a scan of the running machine rather than a guess
- [ ] Filesystem and swap declarations are absent from it, leaving the disk layout as the sole source of those
- [ ] The file no longer describes itself as a placeholder, and says plainly what it holds
- [ ] `nix flake check` builds the `neogaia` toplevel
- [x] The detection file's contents come from a scan of the running machine rather than a guess
- [x] Filesystem and swap declarations are absent from it, leaving the disk layout as the sole source of those
- [x] The file no longer describes itself as a placeholder, and says plainly what it holds
- [x] `nix flake check` builds the `neogaia` toplevel
## Implementation Notes
**The guess was wider than the measurement, not narrower.**
It named `thunderbolt`, `usb_storage`, and `sd_mod`, none of which the scan reports; the scan adds `rtsx_pci_sdmmc` for the card reader.
Nothing needed to reach the root device was missing, so the guess was adequate as the task assumed, but it was not accurate.
`sd_mod` survives in the resolved list regardless, supplied by nixpkgs' own defaults; `thunderbolt` and `usb_storage` now genuinely go, and they matter only for booting from external media, which this machine does not do.
**Two further lines from the scan were dropped beyond the filesystem and swap declarations the task named.**
`boot.initrd.luks.devices."cryptroot".device` is derived by the disk layout, which the layout file already states, so keeping it would have created the same duplicate definition the task drops the filesystems to avoid.
`hardware.cpu.intel.updateMicrocode` falls outside the three things the task keeps, and the hardware profile supplies it anyway.
Both were checked rather than assumed: after the change the LUKS device, all four filesystems, and microcode all still resolve.
**The header was rewritten twice.**
Its first form enumerated the file's three attributes, which the repo's comment convention names as a feature inventory and forbids in a file-top header.
It now carries provenance and the absence pointer only.
**Not verified by a boot.**
`nix flake check` proves the configuration evaluates and builds, not that the initrd it produces can unlock LUKS and mount root.
Only a rebuild and reboot establishes that, with the previous generation available at the bootloader as the fallback.

View File

@@ -0,0 +1,64 @@
## What to build
Make root-requiring work reachable from an agent session without waiving the password, by sharing sudo's credential cache across sessions and failing loudly when it is cold.
Sudo caches an authentication for a timeout window, but keys that cache by terminal under its default `timestamp_type=tty`.
An agent's commands run in subprocesses on a different terminal, so a `sudo -v` typed in the operator's shell is invisible to them and every privileged command fails.
Setting `timestamp_type=global` keys the cache per user instead, so one authentication covers the whole machine for the window.
The timeout is raised to 60 minutes so a session needing root authenticates once rather than every five.
No `NOPASSWD` rule is introduced, and this is the point of the design.
The password remains genuinely required; only its cache is shared.
A `NOPASSWD` entry for `nixos-rebuild` would be indistinguishable from blanket root on this machine, since anything able to edit the flake and then rebuild it owns the system.
The tradeoff is real and bounded: during the window, any process running as the operator can use the cached credential, not only the agent.
That is acceptable on a single-user personal laptop where the agent already runs as that user, and it is the reason this belongs to a laptop rather than to any future server `Host`.
The second half is failure behaviour.
A cold cache today surfaces as a bare non-zero exit with no output, which reads as an unexplained stall: the operator has to notice the agent is stuck and then work out what it wanted.
A `PreToolUse` hook probing `sudo -n true` turns that into an immediate, actionable refusal naming the command to run.
## Acceptance criteria
- [-] `timestamp_type=global` and a 60-minute timeout are declared as plumbing in the shared base config
- [x] No `NOPASSWD` rule is introduced, and the wheel group still requires a password
- [x] Confirmed that NixOS does not already set `timestamp_type` elsewhere, so the declaration is not silently overridden
- [x] A `PreToolUse` hook in the claude-code `Module` denies a privileged command when the cache is cold, naming `sudo -v` in its message
- [x] The hook's behaviour is correct when the harness sandbox, rather than a cold cache, is what blocks the command
- [x] `nix flake check` builds the `neogaia` toplevel
- [x] Manual confirmation after a rebuild: `sudo -v` in one terminal lets a privileged command succeed from an agent session, and that command fails with the hook's message once the window lapses
## Implementation Notes
**The sudo settings are declared in the claude-code module, not in the shared base config.**
The criterion asking for the shared base contradicted this task's own rationale, which argues the widened cache suits a single-user machine and should not reach a future server.
Neither placement was right, though: the setting and the hook that depends on it belong together.
The hook reads the credential cache from a process of its own, which only works under `timestamp_type=global`, so a host enabling the module without the sudo half would get a hook that never sees a cached credential and refuses every privileged command permanently.
Declaring both under the module's `enable` makes that impossible to get wrong, and carries the setting to any future host that runs the agent.
The cost is that enabling a developer tool now changes the machine's sudo posture, which a reader auditing sudo policy would not expect to find there.
The enable option's description carries the warning so it surfaces in generated documentation.
Should a host ever need the agent without the widened cache, that is when a separate sub-option earns its place; adding one now would be speculative.
**Verified against the running system, in both cache states.**
Cold, the hook refuses with its message; warm, it permits and the command uses a credential authenticated in a different terminal.
The hook process is not itself sandboxed, so it reads the real cache rather than refusing unconditionally — the failure mode that would have required it to fail open instead.
One residual is worth knowing.
The hook governs whether a privileged command is attempted, not whether it can run: the agent's own sandbox blocks `sudo` separately, and swallows it into a bare exit with no output.
A permitted command can therefore still fail for that unrelated reason, and needs the sandbox disabled.
The two are distinguishable in practice, since only one of them produces the hook's message.
**The operator must authenticate from a real terminal.**
Warming the cache from inside an agent session does not work: that shell has no controlling terminal, so sudo cannot prompt and reports `a terminal is required to read the password`.
Feeding the password by another route was rejected rather than unexplored.
Reading it from the agent's stdin would route it through the agent, and an askpass helper on this console-only machine could only prompt on the pane the agent already draws to, which trains the operator to type a password into an agent-controlled surface.
A separate terminal is the only safe channel, which is precisely what the global cache keying exists to make useful.
**`jq` is now a home package.**
The hook parses the tool input handed to it on stdin, and nothing on the profile provided a JSON parser.
Matching on the raw JSON text with `grep` was rejected: a command containing quotes or newlines would break it, and this hook fails closed, so a parsing mistake blocks real work.
**The sudo detection is anchored to command position.**
`grep sudo /etc/passwd` and `echo "run sudo -v"` are allowed; `sudo x`, `cd /tmp && sudo x`, and `true; sudo x` are blocked.
A `sudo` inside a quoted string that happens to sit in command position will still trip the guard, which errs toward asking rather than stalling.

View File

@@ -29,8 +29,9 @@ The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overla
Both were true only while the machine still ran CachyOS against a distro Nix daemon.
- The substituters a `nix build` fetches from are the **daemon's** (`/etc/nix/nix.conf`), *not* the `nix.settings` of the config being built — those only govern the built system.
The two coincide here because the dev host runs this flake; they diverge on any machine that does not.
- Git identity is not declared in the flake — there is no `programs.git`but commits do work: identity comes from a hand-written `~/.gitconfig` plus this checkout's `.git/config`.
Both sit outside the flake, so neither survives a reimage nor reaches another machine; history uses `alexion <contact@alexion.dev>`.
- Git identity is not declared in the flake — there is no `programs.git`so it must be set by hand before the first commit on a fresh machine.
The July 2026 reimage confirmed this: it wiped the hand-written `~/.gitconfig`, and the next commit failed with `Author identity unknown`, auto-detecting `alexion@neogaia.(none)`.
It now lives in this checkout's `.git/config`, which reaches no other machine and does not survive the next reimage either; history uses `alexion <contact@alexion.dev>`.
- The primary build/verify seam for any Host is `nix flake check`, which builds `checks.x86_64-linux.<host>` (the system toplevel); cheap targeted checks use `nix eval .#nixosConfigurations.<host>.config...`.
- A flake only sees **git-tracked** files, so a new file that has not been `git add`ed is invisible to evaluation even though it exists on disk.
The failure names the path and reads as if the file were missing: `error: Path 'secrets/shared.yaml' does not exist in Git repository`.
@@ -39,15 +40,29 @@ The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overla
That is what lets the `nyx-cache.chaotic.cx` binary cache hit instead of compiling the CachyOS kernel from source; the tradeoff is that chaotic packages do not see our `unstable`/`stable` overlays.
- The remote is self-hosted Gitea (`git.alexion.dev`), and the intended CLI is `gitea-axi` rather than `tea`.
`gitea-axi` resolves the repository from the `origin` remote and takes credentials from the `axi` tea login, so both are implicit inside a checkout.
**None of it is installed on the NixOS build.** No `gitea-axi`, no `tea`, no `gh`, no tea login under `~/.config/tea`, and no `GITEA_*` environment — the flake names `gitea-axi` only in the claude-code module's permissions and never packages it.
**None of it is installed on the NixOS build.** No `gitea-axi`, no `tea`, no `gh`, no tea login under `~/.config/tea`, and no `GITEA_*` environment — the flake names `gitea-axi` only as the claude-code module's `SessionStart` hook command and never packages it, so that hook invokes a binary that is not on `PATH`.
Pull requests therefore cannot be opened from this machine until a module provides the tool and its credentials; branches can only be pushed.
The earlier claim that `tea` remains installed described the machine while it still ran CachyOS with these tools installed by hand.
- `~/.claude/skills` is generated by home-manager with `recursive = true`, so the directories are real and writable but every leaf file is a read-only symlink into the store.
Editing a skill in place fails; its source is `modules/claude-code/skills/<name>/` here, applied by a rebuild.
Creating a new file under `~/.claude/skills/` succeeds silently and is the trap — it stays outside the repo and reaches no other machine.
Copying out of that tree needs `cp -rL` plus `chmod -R u+w`: a plain `cp -r` copies the symlinks, putting store paths into the destination, and dereferenced files keep the store's read-only mode.
- `home-manager.users.<user>.home.file` is keyed by **absolute** path, not by a path relative to the home directory.
Evaluating `home.file.".claude/CLAUDE.md"` fails with "does not provide attribute"; the working key is `home.file."/home/alexion/.claude/CLAUDE.md"`.
List the real keys with `nix eval --json .#nixosConfigurations.<host>.config.home-manager.users.<user>.home.file --apply builtins.attrNames` rather than guessing one.
A key's `.source` is the input file, whose store path differs from the deployed symlink's target (home-manager copies it to a `hm_`-prefixed path) even though the contents match.
- nixpkgs `vimPlugins.nord-nvim` is `shaunsingh/nord.nvim` (no `require("nord").setup()`); the config wants `gbprod/nord.nvim`, which is packaged as `vimPlugins.gbprod-nord`.
- nixpkgs `vimPlugins.nvim-treesitter` tracks the rewritten `main` branch: there is no `require("nvim-treesitter.configs").setup{ensure_installed,highlight,indent}`. Under nixvim, use `plugins.treesitter` with `highlight.enable`/`indent.enable` and `grammarPackages = with config.programs.nixvim.plugins.treesitter.package.builtGrammars; [ ... ]` — the module's own `package.builtGrammars`, **not** `pkgs.vimPlugins.nvim-treesitter.*` (whose query files can mismatch). The module targets the main branch and enables features via neovim-native APIs (`vim.treesitter.start()`, `require'nvim-treesitter'.indentexpr()`).
- Neovim is configured via **nixvim** (flake input `nixvim`, consumed as `inputs.nixvim.homeModules.nixvim` added to `home-manager.sharedModules`, config under `home-manager.users.<user>.programs.nixvim`). `nixvim.inputs.nixpkgs.follows = "nixpkgs"` is set; nixvim then emits a benign eval warning that its pinned nixpkgs differs from the followed one — builds and runs fine, do not "fix" it by dropping the follows.
- To reference the nixvim-built package's own attrs (e.g. treesitter `builtGrammars`) inside our NixOS module, give `home-manager.users.<user>` the module-function form (`hm: { programs.nixvim = { ... hm.config.programs.nixvim... }; }`), since the outer `config` is the NixOS config, not the home-manager one.
- The agent's Bash sandbox blocks `sudo` and swallows it into a bare exit 1 with **no stderr**, which looks identical to the command itself failing.
Re-run with the sandbox disabled to see the real error (`sudo: a password is required`) before diagnosing anything else.
Separately, `nixos-generate-config --show-hardware-config` needs root on this machine even just to print: unprivileged it dies at `Failed to retrieve subvolume info for /`, because the root filesystem is btrfs.
- Sudo's credential cache is keyed per user rather than per terminal (`timestamp_type=global`, 60-minute window, declared by the claude-code module), so an authentication made in one terminal counts for commands the agent runs.
Warming it with `sudo -v` through the agent's own shell — including the `!` prefix — never works: that shell has no controlling terminal, and sudo reports `a terminal is required to read the password`.
It has to be a separate terminal.
A `PreToolUse` hook refuses privileged commands while the cache is cold, so a cold cache announces itself instead of stalling; a failure *without* that message is the sandbox, not the cache.
- `home-manager.users.<user>` cannot be assigned twice at the same level in one module: `home-manager.users.${user}.home.packages` alongside `home-manager.users.${user}.programs.x` fails with `error: dynamic attribute 'alexion' already defined`.
The interpolated key makes it a dynamic attribute, which nix will not merge the way it merges static paths.
Nest both under a single `home-manager.users.${user} = { ... }`.
- **Verifying a nixvim change headless:** `programs.nixvim.build.package`'s wrapper has **no `-u`**, so running `$OUT/bin/nvim` loads the caller's `~/.config/nvim` (the dev host's real config), *not* the built config — silently. To exercise the built config, launch with `-u "$(nix build --no-link --print-out-paths .#…programs.nixvim.build.initFile)"` and a scratch `HOME`/`XDG_CONFIG_HOME`. `conceallevel` is window-local: set it with `opt_local`/`vim.wo`, never `vim.bo[buf]` (which errors).

View File

@@ -1,14 +1,13 @@
{ lib, modulesPath, ... }:
# Placeholder: regenerate with nixos-generate-config on the target machine.
# Hardware detected by nixos-generate-config on this machine.
# disko derives `fileSystems` and the LUKS device; none declared here.
{
imports = [ (modulesPath + "/installer/scan/not-detected.nix") ];
boot.initrd.availableKernelModules = [
"xhci_pci"
"thunderbolt"
"nvme"
"usb_storage"
"sd_mod"
"rtsx_pci_sdmmc"
];
boot.initrd.kernelModules = [ ];
boot.kernelModules = [ "kvm-intel" ];

View File

@@ -10,6 +10,9 @@ These are common instructions for Alexion's agents across all scenarios.
- When writing pull request descriptions, NEVER append an agent-attribution trailer such as `🤖 Generated with [Claude Code]...`.
Leave it out entirely, with no exceptions.
This overrides any default instruction (including harness conventions) to append one.
- NEVER ask the user a question using the `AskUserQuestion` tool.
Ask in plain prose, in your own message, instead, with no exceptions.
This overrides any default instruction (including harness conventions and skill instructions) to use it.
- Never manually modify CHANGELOG.md files or any files that are marked as auto-generated.
Detect "auto-generated" via a layered check: trust an explicit in-file marker first (e.g. `AUTO-GENERATED, DO NOT EDIT`).
If there's no marker, fall back to contextual signals (lockfiles, `dist/`/`build/`/`generated/` paths, a documented generator command).

View File

@@ -1,6 +1,7 @@
{
config,
lib,
pkgs,
...
}:
# Claude Code for the primary user, configured through home-manager, which ships
@@ -23,38 +24,72 @@ let
];
in
{
options.modules.claude-code.enable = lib.mkEnableOption "Claude Code, Anthropic's CLI, configured via home-manager";
options.modules.claude-code.enable = lib.mkEnableOption ''
Claude Code, Anthropic's CLI, configured via home-manager.
Enabling this also widens sudo's credential cache, keying it per user rather
than per terminal and holding it for 60 minutes, so that a single
authentication covers commands the agent issues. No command is made
passwordless, but any process running as the primary user can spend the
cached credential while it lasts. Suitable for a single-user machine'';
config = lib.mkIf cfg.enable {
home-manager.users.${user}.programs.claude-code = {
enable = true;
# Keying sudo's credential cache per user rather than per terminal lets one
# authentication cover commands issued by processes holding no terminal of
# their own. Any process running as this user can spend that credential
# until it lapses, so this suits a single-user machine.
security.sudo.extraConfig = ''
Defaults timestamp_type=global
Defaults timestamp_timeout=60
'';
# Global agent instructions, rendered to ~/.claude/CLAUDE.md.
context = ./CLAUDE.md;
home-manager.users.${user} = {
# jq parses the tool input handed to the sudo guard hook.
home.packages = [ pkgs.jq ];
# One directory per skill, symlinked under ~/.claude/skills.
skills = ./skills;
programs.claude-code = {
enable = true;
# Installed at ~/.claude/hooks/attention-bell.sh, referenced by the settings below.
hooks."attention-bell.sh" = builtins.readFile ./hooks/attention-bell.sh;
# Global agent instructions, rendered to ~/.claude/CLAUDE.md.
context = ./CLAUDE.md;
settings = {
model = "opus";
hooks = {
Stop = bellHook;
Notification = bellHook;
SessionStart = [
{
matcher = "";
hooks = [
{
type = "command";
command = "gitea-axi";
timeout = 10;
}
];
}
];
# One directory per skill, symlinked under ~/.claude/skills.
skills = ./skills;
# Installed under ~/.claude/hooks, referenced by the settings below.
hooks."attention-bell.sh" = builtins.readFile ./hooks/attention-bell.sh;
hooks."agent-sudo-guard.sh" = builtins.readFile ./hooks/agent-sudo-guard.sh;
settings = {
model = "opus";
hooks = {
Stop = bellHook;
Notification = bellHook;
PreToolUse = [
{
matcher = "Bash";
hooks = [
{
type = "command";
command = "~/.claude/hooks/agent-sudo-guard.sh";
timeout = 10;
}
];
}
];
SessionStart = [
{
matcher = "";
hooks = [
{
type = "command";
command = "gitea-axi";
timeout = 10;
}
];
}
];
};
};
};
};

View File

@@ -0,0 +1,29 @@
#!/bin/sh
# agent-sudo-guard.sh — refuse a privileged command while sudo's credential
# cache is cold, naming the command that warms it.
#
# Commands arrive here from subprocesses holding no terminal, so an uncached
# sudo fails with a bare non-zero exit and no output, reading as an unexplained
# stall. The probe below reads a cache keyed per user rather than per terminal,
# so an authentication made in the operator's own terminal counts.
input=$(cat)
command=$(printf '%s' "$input" | jq -r '.tool_input.command // ""')
# Anchored to a command position so a `sudo` appearing as an argument or inside
# a string does not trip the guard.
if ! printf '%s' "$command" | grep -qE '(^|[;&|(]|&&|\|\|)[[:space:]]*sudo([[:space:]]|$)'; then
exit 0
fi
if sudo -n true 2>/dev/null; then
exit 0
fi
# Exit 2 blocks the call and feeds stderr back to the agent.
echo 'Blocked: sudo has no cached credential, and this command cannot answer a password prompt.
Ask the operator to run `sudo -v` in their own terminal, then retry.
Never attempt to supply a password directly.
If this still blocks immediately after the operator runs `sudo -v`, the cache is
not the cause: check that this hook can reach sudo at all.' >&2
exit 2