Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/claude-code-session-hook.md
Original file line number Diff line number Diff line change
@@ -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.
71 changes: 67 additions & 4 deletions scripts/validate-safe-chain-setup.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
}
Expand All @@ -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');
Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -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 });
}
Expand Down
5 changes: 3 additions & 2 deletions skills/security/defense-in-depth-nodejs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<versioned-tag>@sha256:<digest>` per
Expand Down
59 changes: 42 additions & 17 deletions skills/security/defense-in-depth-nodejs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Profile: <npm library | website/app> · <public | private>

## 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:<tag>@sha256:<digest>`; not a floating tag)
Expand Down Expand Up @@ -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` |
Expand All @@ -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
Expand All @@ -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. |

Expand All @@ -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)

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
Expand Down
Loading
Loading