diff --git a/.changeset/claude-code-session-hook.md b/.changeset/claude-code-session-hook.md new file mode 100644 index 0000000..3e1aaad --- /dev/null +++ b/.changeset/claude-code-session-hook.md @@ -0,0 +1,11 @@ +--- +"agentic": minor +--- + +Run the Claude Code Safe Chain bootstrap from a SessionStart hook script + +Claude Code on the web now runs `.claude/hooks/session-start.sh` (cloud sessions only) with a 600s +timeout, and the install log goes to stderr so it is not injected into the session. `.gitignore` +must keep `.claude/settings.json` and `.claude/hooks/` tracked; a blanket `.claude` ignore cannot +be negated. Shim `PATH` stays in `setup-cloud-environment.sh`, written before the bootstrap can +fail. diff --git a/scripts/validate-safe-chain-setup.mjs b/scripts/validate-safe-chain-setup.mjs index 85ac960..bf15a68 100644 --- a/scripts/validate-safe-chain-setup.mjs +++ b/scripts/validate-safe-chain-setup.mjs @@ -14,10 +14,11 @@ const SCRIPT = join(SKILL, 'scripts/setup-cloud-environment.sh'); const DEVCONTAINER = join(SKILL, 'templates/.devcontainer/devcontainer.json'); const ENVIRONMENT = join(SKILL, 'templates/.cursor/environment.json'); const CLAUDE_SETTINGS = join(SKILL, 'templates/.claude/settings.json'); +const CLAUDE_HOOK_SCRIPT = join(SKILL, 'templates/.claude/hooks/session-start.sh'); const AGENTS = join(SKILL, 'templates/AGENTS.md'); const REFERENCE = join(SKILL, 'reference.md'); const BOOTSTRAP = 'bash ./scripts/setup-cloud-environment.sh'; -const CLAUDE_HOOK = `if [ "$CLAUDE_CODE_REMOTE" = true ]; then cd "$CLAUDE_PROJECT_DIR" && ${BOOTSTRAP}; fi`; +const CLAUDE_HOOK = 'bash "$CLAUDE_PROJECT_DIR"/.claude/hooks/session-start.sh'; const SHIM_PATH_EXPORT = 'export PATH="$HOME/.safe-chain/shims:$HOME/.safe-chain/bin:$PATH"'; const GITHUB_CLI_FEATURE = 'ghcr.io/devcontainers/features/github-cli:1'; const DOCKER_IN_DOCKER_FEATURE = 'ghcr.io/devcontainers/features/docker-in-docker:4'; @@ -83,6 +84,9 @@ if (claudeSettings) { if (hooks.length !== 1 || hooks[0].type !== 'command' || hooks[0].command !== CLAUDE_HOOK) { err(`.claude/settings.json must have one SessionStart command hook: ${CLAUDE_HOOK}`); } + if (hooks[0]?.timeout !== 600) { + err('.claude/settings.json SessionStart hook must set timeout 600; the 60s default kills a cold Safe Chain install'); + } if (groups.some((group) => group.matcher !== undefined)) { err('.claude/settings.json SessionStart hook must not set a matcher; /clear, compaction, and forks need the shims too'); } @@ -91,6 +95,29 @@ if (claudeSettings) { } } +const claudeHookScript = existsSync(CLAUDE_HOOK_SCRIPT) ? readFileSync(CLAUDE_HOOK_SCRIPT, 'utf8') : ''; +if (!claudeHookScript) { + err('templates/.claude/hooks/session-start.sh is missing'); +} else { + if (!/CLAUDE_CODE_REMOTE/.test(claudeHookScript) || !/!= "true"/.test(claudeHookScript)) { + err('session-start.sh must exit unless CLAUDE_CODE_REMOTE=true'); + } + if (!/cd "\$CLAUDE_PROJECT_DIR"/.test(claudeHookScript)) { + err('session-start.sh must cd to CLAUDE_PROJECT_DIR'); + } + if (!/setup-cloud-environment\.sh >&2/.test(claudeHookScript)) { + err('session-start.sh must send the bootstrap stdout to stderr; hook stdout becomes session context'); + } + const hookCode = claudeHookScript.replace(/^#.*$/gm, ''); + if (/CLAUDE_ENV_FILE/.test(hookCode)) { + err('session-start.sh must not write CLAUDE_ENV_FILE; setup-cloud-environment.sh does that before it can fail'); + } + const hookSyntax = spawnSync('bash', ['-n', CLAUDE_HOOK_SCRIPT], { encoding: 'utf8' }); + if (hookSyntax.status !== 0) { + err(`session-start.sh failed bash -n: ${hookSyntax.stderr || hookSyntax.stdout}`); + } +} + const agents = readFileSync(AGENTS, 'utf8'); if (!/Safe Chain/i.test(agents) || !/never bypass/i.test(agents)) { err('templates/AGENTS.md must tell agents never to bypass Safe Chain'); @@ -130,6 +157,12 @@ if (scaffoldIdx === -1) { if (!/Claude Code on the web bootstrap/.test(cloud)) { err('catalog § 2 Safe Chain item must cover Claude Code on the web'); } + if (!/\.claude\/hooks\/session-start\.sh/.test(cloud) || !/600s timeout/.test(cloud)) { + err('catalog § 2 Safe Chain item must name the session-start hook and its 600s timeout'); + } + if (!/\.gitignore/.test(cloud) || !/\.claude\/hooks\//.test(cloud)) { + err('catalog § 2 Safe Chain item must require .gitignore to keep the Claude hook tracked'); + } if (!/Codex cloud environments use Manual setup.*\(manual\)$/m.test(cloud)) { err('catalog § 2 must have a (manual) Codex cloud Manual setup item'); } @@ -196,6 +229,15 @@ if (!reference.includes(BOOTSTRAP)) { if (!reference.includes('CLAUDE_CODE_REMOTE') || !reference.includes('CLAUDE_ENV_FILE')) { err('reference.md must gate the Claude Code hook on CLAUDE_CODE_REMOTE and persist shims via CLAUDE_ENV_FILE'); } +if (!reference.includes(CLAUDE_HOOK) || !reference.includes('"timeout": 600')) { + err('reference.md must document the session-start.sh hook command and timeout 600'); +} +if (!reference.includes('>&2') || !reference.includes('.claude/*') || !reference.includes('!.claude/hooks/')) { + err('reference.md must send hook stdout to stderr and un-ignore .claude/settings.json and .claude/hooks/'); +} +if (!reference.includes('must not append `PATH` itself after the bootstrap')) { + err('reference.md must keep CLAUDE_ENV_FILE writes in setup-cloud-environment.sh, before a failed bootstrap'); +} if (!reference.includes('`@AGENTS.md`')) { err('reference.md must have CLAUDE.md import @AGENTS.md so Claude Code reads the Safe Chain section'); } @@ -720,22 +762,43 @@ const claudeHookDir = mkdtempSync(join(tmpdir(), 'safe-chain-claude-hook-')); try { const project = join(claudeHookDir, 'project'); mkdirSync(join(project, 'scripts'), { recursive: true }); + mkdirSync(join(project, '.claude/hooks'), { recursive: true }); writeFileSync(join(project, 'scripts/setup-cloud-environment.sh'), readFileSync(SCRIPT)); + writeFileSync(join(project, '.claude/hooks/session-start.sh'), readFileSync(CLAUDE_HOOK_SCRIPT)); + const envFile = join(claudeHookDir, 'claude-env.sh'); const runHook = (extraEnv) => - spawnSync('sh', ['-c', CLAUDE_HOOK], { + spawnSync('bash', ['-c', CLAUDE_HOOK], { cwd: claudeHookDir, encoding: 'utf8', - env: { PATH: process.env.PATH, HOME: claudeHookDir, LANG: 'C', CLAUDE_PROJECT_DIR: project, ...extraEnv }, + env: { + PATH: process.env.PATH, + HOME: claudeHookDir, + LANG: 'C', + CLAUDE_PROJECT_DIR: project, + CLAUDE_ENV_FILE: envFile, + ...extraEnv, + }, }); const local = runHook({}); if (local.status !== 0 || `${local.stdout}${local.stderr}`.trim() !== '') { err(`.claude/settings.json hook must be a silent no-op outside cloud sessions (got ${local.status}: ${local.stdout}${local.stderr})`); } + if (existsSync(envFile)) { + err('session-start.sh must not touch CLAUDE_ENV_FILE outside cloud sessions'); + } // The project has no pnpm-lock.yaml, so reaching the bootstrap from another cwd fails on that check. const cloud = runHook({ CLAUDE_CODE_REMOTE: 'true' }); - if (cloud.status === 0 || !/pnpm-lock\.yaml/.test(`${cloud.stdout}${cloud.stderr}`)) { + if (cloud.stdout.trim() !== '') { + err(`session-start.sh must keep the install log off stdout (got: ${cloud.stdout})`); + } + if (cloud.status === 0 || !/pnpm-lock\.yaml/.test(cloud.stderr)) { err(`.claude/settings.json hook must run the bootstrap from CLAUDE_PROJECT_DIR in cloud sessions (got ${cloud.status}: ${cloud.stdout}${cloud.stderr})`); } + const want = `export PATH="${claudeHookDir}/.safe-chain/shims:${claudeHookDir}/.safe-chain/bin:$PATH"`; + const written = existsSync(envFile) ? readFileSync(envFile, 'utf8') : ''; + if (!written.includes(want)) { + err(`a failed session-start bootstrap must still write the shim PATH to CLAUDE_ENV_FILE (got: ${written})`); + } } finally { rmSync(claudeHookDir, { recursive: true, force: true }); } diff --git a/skills/security/defense-in-depth-nodejs/SKILL.md b/skills/security/defense-in-depth-nodejs/SKILL.md index 902d813..409b685 100644 --- a/skills/security/defense-in-depth-nodejs/SKILL.md +++ b/skills/security/defense-in-depth-nodejs/SKILL.md @@ -131,8 +131,9 @@ Run on the first invocation and on every resume (`continue`, `next`, `next defen - Safe Chain: copy [`./scripts/setup-cloud-environment.sh`](./scripts/setup-cloud-environment.sh) and the files in `templates/` per [reference.md § 2](./reference.md#2-codeowners-and-cloud-bootstrap) - (merge existing `.devcontainer` / `.cursor` / `.claude` config and `CLAUDE.md`; never - overwrite it). Skip when `pnpm-lock.yaml` is absent. Branch + (merge existing `.devcontainer` / `.cursor` / `.claude` config, `CLAUDE.md`, and `.gitignore` + so `.claude/settings.json` and `.claude/hooks/` stay tracked; never overwrite existing + config). Skip when `pnpm-lock.yaml` is absent. Branch `chore/defense-safe-chain-cloud`. A leftover PMG / VM-egress line is dropped in that PR. - Dev Container image pin: rewrite each `.devcontainer/**/devcontainer.json` (and repo-root `devcontainer.json`) `image` to `name:@sha256:` per diff --git a/skills/security/defense-in-depth-nodejs/reference.md b/skills/security/defense-in-depth-nodejs/reference.md index 7770d44..1463f7b 100644 --- a/skills/security/defense-in-depth-nodejs/reference.md +++ b/skills/security/defense-in-depth-nodejs/reference.md @@ -60,7 +60,7 @@ Profile: · ## 2. CODEOWNERS and cloud bootstrap - [ ] `.github/CODEOWNERS` covers `/.github/`, `/.vscode/`, `/.cursor/`, `/.devcontainer/`, `/.claude/`, `/.codex/`, `/scripts/` with owners the maintainer names -- [ ] Codespaces, Cursor Cloud Agents, and Claude Code on the web bootstrap Aikido Safe Chain via scripts/setup-cloud-environment.sh (--ci shims, frozen lockfile) +- [ ] Codespaces, Cursor Cloud Agents, and Claude Code on the web bootstrap Aikido Safe Chain via scripts/setup-cloud-environment.sh (--ci shims, frozen lockfile); Claude Code runs it from `.claude/hooks/session-start.sh` (web sessions only, install log on stderr, 600s timeout) and `.gitignore` keeps `.claude/settings.json` and `.claude/hooks/` tracked - [ ] Codex cloud environments use Manual setup with `bash ./scripts/setup-cloud-environment.sh` as the setup and maintenance script (manual) - [ ] Claude Code on the web environments allow `malware-list.aikido.dev` (Custom network access plus the default package-manager list) (manual) - [ ] Dev Container `image` pinned by digest (`name:@sha256:`; not a floating tag) @@ -157,6 +157,7 @@ target's `scripts/setup-cloud-environment.sh`, and copy from this skill's `templ | `templates/.devcontainer/devcontainer.json` | `.devcontainer/devcontainer.json` | | `templates/.cursor/environment.json` | `.cursor/environment.json` | | `templates/.claude/settings.json` | `.claude/settings.json` | +| `templates/.claude/hooks/session-start.sh` | `.claude/hooks/session-start.sh` | | `templates/AGENTS.md` | `AGENTS.md` (section only — see merge rules) | | `@AGENTS.md` (one line) | `CLAUDE.md` (import only — see merge rules) | | `scripts/setup-cloud-environment.sh` | `scripts/setup-cloud-environment.sh` | @@ -174,13 +175,17 @@ Dockerfile; never `:latest`) and installs GitHub CLI plus Docker via Dev Contain (`ghcr.io/devcontainers/features/github-cli:1` and `ghcr.io/devcontainers/features/docker-in-docker:4` with `"moby": false` — Trixie has no Moby packages). Cursor uses a managed environment with only `install` (no `build`, no Dockerfile, no -snapshot). Claude Code on the web uses a SessionStart hook in `.claude/settings.json` that runs only -when `CLAUDE_CODE_REMOTE` is `true` — local Claude Code sessions skip it — and first `cd`s to -`$CLAUDE_PROJECT_DIR`. It sets no `matcher`, so resume, `/clear`, compaction, and forks re-run it, -and it is not `async`, so the shims exist before Claude runs a command. All three invoke -`bash ./scripts/setup-cloud-environment.sh` so the copied script does not need the executable bit. -Leave `postCreateCommand` / `install` / the hook `command` as that invocation — do not wrap it -in `bash -i`, `source ~/.bashrc`, or `source "$NVM_DIR/nvm.sh"`. A fresh Codespace runs a +snapshot). Claude Code on the web uses a SessionStart hook in `.claude/settings.json` whose +command is `bash "$CLAUDE_PROJECT_DIR"/.claude/hooks/session-start.sh` with `"timeout": 600`. The +hook script runs only when `CLAUDE_CODE_REMOTE` is `true` — local Claude Code sessions exit 0 with +no output — then `cd`s to `$CLAUDE_PROJECT_DIR` and runs `bash ./scripts/setup-cloud-environment.sh >&2`. +Hook stdout is injected into the session; the install log must not land there. The default hook +timeout is 60s, which kills a cold Safe Chain install, so 600 is required. It sets no `matcher`, +so resume, `/clear`, compaction, and forks re-run it, and it is not `async`, so the shims exist +before Claude runs a command. Invoke the hook with `bash` so it does not need the executable bit. +Codespaces and Cursor still invoke `bash ./scripts/setup-cloud-environment.sh` directly. +Leave `postCreateCommand`, `install`, and the hook `command` as those invocations — do not wrap +them in `bash -i`, `source ~/.bashrc`, or `source "$NVM_DIR/nvm.sh"`. A fresh Codespace runs a non-interactive shell, so those only help an already-open terminal (`source ~/.bashrc` after a one-off run). If `pnpm` is missing, the script enables Corepack's `pnpm` shim into `~/.safe-chain/bin` (`--install-directory`) so it does not EACCES on the javascript-node image's @@ -200,19 +205,37 @@ files, so the script also appends the shim `PATH` to `CLAUDE_ENV_FILE`, which Cl every Bash command. A failed SessionStart hook does not stop the session, so the script writes that line before anything can fail and seeds `~/.safe-chain/shims` with `npm` / `npx` / `pnpm` / `pnpx` stubs that exit 1 until Safe Chain's `setup-ci` replaces them: a failed bootstrap leaves package -installs blocked, not unprotected. A session with several repositories loads no repo hooks, so run -Claude Code sessions on this repo alone. `CLAUDE.md` imports `@AGENTS.md` because Claude Code reads -only `CLAUDE.md` when both files exist, and some sessions never read `AGENTS.md` (versions before -2.1.277, third-party providers, the first session after an install). +installs blocked, not unprotected. The hook script must not append `PATH` itself after the bootstrap +returns — that write would be skipped when the bootstrap fails. A session with several +repositories loads no repo hooks, so run Claude Code sessions on this repo alone. `CLAUDE.md` +imports `@AGENTS.md` because Claude Code reads only `CLAUDE.md` when both files exist, and some +sessions never read `AGENTS.md` (versions before 2.1.277, third-party providers, the first session +after an install). + +`.gitignore` must not hide the hook. A line `.claude` or `.claude/` ignores the directory, and Git +will not re-include anything underneath it. Use this block (replace a blanket `.claude` / +`.claude/` rule with it; if `.claude/*` is already present, add only the two negations that are +missing; if no Claude rule exists, append the block): + +``` +# AI (keep the shared Claude Code settings and hooks; ignore local state) +.claude/* +!.claude/settings.json +!.claude/hooks/ +``` + +`settings.local.json` and other `.claude` entries stay ignored. Do not un-ignore them. Merge — never blindly overwrite: | File | Missing | Already present | | --- | --- | --- | | `scripts/setup-cloud-environment.sh` | Copy from the skill | Replace with the skill's script (this is the security control) | +| `.claude/hooks/session-start.sh` | Copy from the skill | Replace with the skill's script (this is the security control) | | `.devcontainer/devcontainer.json` | Write the template | Keep existing keys, image, and Dockerfile. Set or chain `postCreateCommand` with the same-shell pattern above so the bootstrap runs and later installs stay shimmed. Detect GitHub CLI / Docker by feature id, ignoring the tag (`github-cli`, `docker-in-docker`, `docker-outside-of-docker`, `docker-from-docker`). If no GitHub CLI feature is present, add `github-cli:1`. If no Docker feature is present, add `docker-in-docker:4` with `"moby": false`. Do not add a second copy of either. Do not add a Dockerfile. Do not replace an existing image with the template image — pinning that image is the next item. | | `.cursor/environment.json` | Write `{ "install": "bash ./scripts/setup-cloud-environment.sh" }` | Keep other keys; if `install` exists, prepend the same-shell pattern above unless it already runs the script. Do not add `build` or a Dockerfile. | -| `.claude/settings.json` | Write the template | Keep other keys and hooks. Replace a SessionStart hook that already runs the script with the template's hook (remote-gated, run from `$CLAUDE_PROJECT_DIR`, no `matcher`, not `async`); if none does, add the template's `SessionStart` group. All matching hooks run in parallel, so remove package installs from other SessionStart hooks — the bootstrap already runs `pnpm install --frozen-lockfile`. | +| `.claude/settings.json` | Write the template | Keep other keys and hooks. Replace a SessionStart hook that already runs the bootstrap (inline or via `session-start.sh`) with the template's hook (`bash "$CLAUDE_PROJECT_DIR"/.claude/hooks/session-start.sh`, `"timeout": 600`, no `matcher`, not `async`); if none does, add the template's `SessionStart` group. All matching hooks run in parallel, so remove package installs from other SessionStart hooks — the bootstrap already runs `pnpm install --frozen-lockfile`. | +| `.gitignore` | Append the `.claude/*` block above | Replace a `.claude` or `.claude/` directory rule with that block. If `.claude/*` is already there, add `!.claude/settings.json` and `!.claude/hooks/` when missing. | | `AGENTS.md` | Write the template's sections (Safe Chain, Pull requests) | Append each section that is absent; leave existing content alone. | | `CLAUDE.md` | Write `@AGENTS.md` | Add `@AGENTS.md` as the first line unless it already imports `AGENTS.md` (or is a symlink to it); leave the rest alone. | @@ -221,10 +244,12 @@ JSON. A leftover catalog line about PMG / VM-egress filtering is dropped in this body). Reconcile Safe Chain as done when the bootstrap script is present, the Dev Container and Cursor -configs invoke it, `.claude/settings.json` has the template's SessionStart hook, `CLAUDE.md` imports -`@AGENTS.md`, and `AGENTS.md` has both template sections (Safe Chain, Pull requests) — a repo -hardened before a config or section existed is not done until it is added. Image digest pinning is -the next item — a greenfield copy of the template already satisfies it. +configs invoke it, `.claude/hooks/session-start.sh` matches the template, `.claude/settings.json` +runs that hook with timeout 600, `.gitignore` does not ignore `.claude/settings.json` or +`.claude/hooks/`, `CLAUDE.md` imports `@AGENTS.md`, and `AGENTS.md` has both template sections +(Safe Chain, Pull requests) — a repo hardened before the hook script, the timeout, the stderr +redirect, or the gitignore exceptions existed is not done until they are added. Image digest +pinning is the next item — a greenfield copy of the template already satisfies it. ### Codex cloud and Claude Code environments (manual) diff --git a/skills/security/defense-in-depth-nodejs/templates/.claude/hooks/session-start.sh b/skills/security/defense-in-depth-nodejs/templates/.claude/hooks/session-start.sh new file mode 100755 index 0000000..dee0e53 --- /dev/null +++ b/skills/security/defense-in-depth-nodejs/templates/.claude/hooks/session-start.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# SessionStart hook for Claude Code on the web: bootstrap Aikido Safe Chain and +# install dependencies through its shims, as .devcontainer and .cursor do. +# +# Hook stdout is injected into the session, so the install log goes to stderr. +# Shim PATH is written by setup-cloud-environment.sh before that script can fail +# (CLAUDE_ENV_FILE). Do not append it here after the bootstrap: a failed hook +# would then leave the package managers unshimmed. +set -euo pipefail + +if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then + exit 0 +fi + +cd "$CLAUDE_PROJECT_DIR" + +bash ./scripts/setup-cloud-environment.sh >&2 diff --git a/skills/security/defense-in-depth-nodejs/templates/.claude/settings.json b/skills/security/defense-in-depth-nodejs/templates/.claude/settings.json index ce7d1bd..1833e5e 100644 --- a/skills/security/defense-in-depth-nodejs/templates/.claude/settings.json +++ b/skills/security/defense-in-depth-nodejs/templates/.claude/settings.json @@ -5,7 +5,8 @@ "hooks": [ { "type": "command", - "command": "if [ \"$CLAUDE_CODE_REMOTE\" = true ]; then cd \"$CLAUDE_PROJECT_DIR\" && bash ./scripts/setup-cloud-environment.sh; fi" + "command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-start.sh", + "timeout": 600 } ] } diff --git a/skills/shared/agentic-upstream-sync/SKILL.md b/skills/shared/agentic-upstream-sync/SKILL.md index 4948d0c..c61ca49 100644 --- a/skills/shared/agentic-upstream-sync/SKILL.md +++ b/skills/shared/agentic-upstream-sync/SKILL.md @@ -18,6 +18,7 @@ resume, before any upgrade group; on its own it answers "is this repo current wi | In the target repo | Upstream source on `main` | Kind | | --- | --- | --- | | `scripts/setup-cloud-environment.sh` | `skills/security/defense-in-depth-nodejs/scripts/setup-cloud-environment.sh` | verbatim copy | +| `.claude/hooks/session-start.sh` | `skills/security/defense-in-depth-nodejs/templates/.claude/hooks/session-start.sh` | verbatim copy | | `AGENTS.md` sections whose `## ` heading matches a template section (`Safe Chain`, `Pull requests`, …) | `skills/security/defense-in-depth-nodejs/templates/AGENTS.md` | section copies | | `DEFENSE_IN_DEPTH.md` catalog — section headings and item lines | the `DEFENSE_IN_DEPTH.md` scaffold in `skills/security/defense-in-depth-nodejs/reference.md` § 1 | catalog | @@ -30,7 +31,7 @@ files) belong to their own items. 1. `git clone --filter=blob:none --quiet https://github.com/jaredwray/agentic "$tmp"` — a fresh clone every run, never a cached plugin copy. -2. Script: `diff` the repo's copy against upstream. Different → stale. +2. Verbatim copies: `diff` each verbatim row against upstream. Different → stale. `bash -n` each shell script after a refresh. 3. Sections: for each template section (from its `## ` heading to the next), compare the repo's section with the same heading. Identical → current. Absent → missing. Different but equal to an earlier upstream revision (`git log -p -- skills/security/defense-in-depth-nodejs/templates/AGENTS.md` @@ -41,10 +42,10 @@ files) belong to their own items. ## Act -- Stale script or section → replace with the upstream text; missing section → append it. Together these - are one PR: branch `chore/agentic-sync`, title ` - chore: sync defense-in-depth files from - agentic`, body listing each file and the upstream commit it now matches. Verify with `bash -n` on the - script; the sections are prose. +- Stale verbatim copy or section → replace with the upstream text; missing section → append it. + Together these are one PR: branch `chore/agentic-sync`, title ` - chore: sync defense-in-depth files from + agentic`, body listing each file and the upstream commit it now matches. Verify with `bash -n` on + each shell script; the sections are prose. - Locally edited section → leave it and list it in the PR body (or the report) with a link to the upstream diff, for the user to reconcile. - Catalog drift → do not edit `DEFENSE_IN_DEPTH.md` here. Report "catalog behind upstream: run