Compare commits
5 Commits
431f75aad7
...
66582b498c
| Author | SHA1 | Date | |
|---|---|---|---|
| 66582b498c | |||
| 54c191f801 | |||
| d0bfc7b21b | |||
| e5d8f69f16 | |||
| 42c602b814 |
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
64
.claude/tasks/0018-agent-sudo-credential-caching.md
Normal file
64
.claude/tasks/0018-agent-sudo-credential-caching.md
Normal 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.
|
||||
21
CLAUDE.md
21
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 <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).
|
||||
|
||||
@@ -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" ];
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
# Claude Code for the primary user, configured through home-manager, which ships
|
||||
@@ -23,10 +24,30 @@ 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 = {
|
||||
# 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
|
||||
'';
|
||||
|
||||
home-manager.users.${user} = {
|
||||
# jq parses the tool input handed to the sudo guard hook.
|
||||
home.packages = [ pkgs.jq ];
|
||||
|
||||
programs.claude-code = {
|
||||
enable = true;
|
||||
|
||||
# Global agent instructions, rendered to ~/.claude/CLAUDE.md.
|
||||
@@ -35,14 +56,27 @@ in
|
||||
# One directory per skill, symlinked under ~/.claude/skills.
|
||||
skills = ./skills;
|
||||
|
||||
# Installed at ~/.claude/hooks/attention-bell.sh, referenced by the settings below.
|
||||
# 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 = "";
|
||||
@@ -59,4 +93,5 @@ in
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
29
modules/claude-code/hooks/agent-sudo-guard.sh
Executable file
29
modules/claude-code/hooks/agent-sudo-guard.sh
Executable 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
|
||||
Reference in New Issue
Block a user