diff --git a/.claude/tasks/0016-esp-resize-and-reimage.md b/.claude/tasks/0016-esp-resize-and-reimage.md index 406d7d2..b701c1c 100644 --- a/.claude/tasks/0016-esp-resize-and-reimage.md +++ b/.claude/tasks/0016-esp-resize-and-reimage.md @@ -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. diff --git a/.claude/tasks/0017-hardware-detection-refresh.md b/.claude/tasks/0017-hardware-detection-refresh.md index 49fab3b..0104a99 100644 --- a/.claude/tasks/0017-hardware-detection-refresh.md +++ b/.claude/tasks/0017-hardware-detection-refresh.md @@ -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. + +**Verified by a boot.** +`nix flake check` proves only that the configuration evaluates and builds, so the reduced initrd was exercised on the machine: it unlocked LUKS and mounted the btrfs root unaided. +The running system's store path matches this configuration's build exactly, confirming the boot used it rather than an earlier generation. diff --git a/.claude/tasks/0018-agent-sudo-credential-caching.md b/.claude/tasks/0018-agent-sudo-credential-caching.md new file mode 100644 index 0000000..782fcc1 --- /dev/null +++ b/.claude/tasks/0018-agent-sudo-credential-caching.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 9c88ceb..d79d698 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `. +- 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 `. - The primary build/verify seam for any Host is `nix flake check`, which builds `checks.x86_64-linux.` (the system toplevel); cheap targeted checks use `nix eval .#nixosConfigurations..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//` 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..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..config.home-manager.users..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..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.` 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.` 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). diff --git a/hosts/neogaia/hardware-configuration.nix b/hosts/neogaia/hardware-configuration.nix index b1e2bbe..cf45488 100644 --- a/hosts/neogaia/hardware-configuration.nix +++ b/hosts/neogaia/hardware-configuration.nix @@ -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" ]; diff --git a/modules/claude-code/CLAUDE.md b/modules/claude-code/CLAUDE.md index 7c341e5..8fbd285 100644 --- a/modules/claude-code/CLAUDE.md +++ b/modules/claude-code/CLAUDE.md @@ -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). diff --git a/modules/claude-code/claude-code.nix b/modules/claude-code/claude-code.nix index e47b02a..d0ae9b8 100644 --- a/modules/claude-code/claude-code.nix +++ b/modules/claude-code/claude-code.nix @@ -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; + } + ]; + } + ]; + }; }; }; }; diff --git a/modules/claude-code/hooks/agent-sudo-guard.sh b/modules/claude-code/hooks/agent-sudo-guard.sh new file mode 100755 index 0000000..29ba861 --- /dev/null +++ b/modules/claude-code/hooks/agent-sudo-guard.sh @@ -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