diff --git a/src/commands/observe.ts b/src/commands/observe.ts index 61ef885..104bde3 100644 --- a/src/commands/observe.ts +++ b/src/commands/observe.ts @@ -1,16 +1,36 @@ // `insta observe` — the local credential-audit hook. install wires a PostToolUse hook into the // agent harness; report renders the local audit; sync uploads findings into the project timeline // (idempotent via a stable dedup key, matching the platform's audit-event ingest). +import { existsSync } from 'node:fs' import { readFile } from 'node:fs/promises' -import { join } from 'node:path' +import { dirname, join, resolve } from 'node:path' import { installObserve, uninstallObserve } from '../observe/install.js' +import { untrackHint } from '../gitignore.js' +import { findProjectRoot } from '../config.js' import { renderReport } from '../observe/report.js' import { ApiClient, requireProject } from '../api.js' import { info, printJson } from '../util.js' +// Where the audit lives: the hook records at the directory it is materialized in +// (/.insta/observe/hook.js — see projectRootFor), so report/sync must anchor on the same +// thing. The link file is the usual root, but a standalone `insta observe install` in an +// unlinked directory has no project.json — climb for the hook itself then, so both halves agree +// from any subdirectory. Falls back to cwd when neither exists (→ "audit log is empty"). +export async function auditRoot(cwd = process.cwd()): Promise { + const linked = await findProjectRoot(cwd) + if (linked) return linked + let dir = resolve(cwd) + for (;;) { + if (existsSync(join(dir, '.insta', 'observe', 'hook.js'))) return dir + const parent = dirname(dir) + if (parent === dir) return cwd + dir = parent + } +} + async function readAudit(): Promise>> { try { - const txt = await readFile(join(process.cwd(), '.insta', 'audit.jsonl'), 'utf8') + const txt = await readFile(join(await auditRoot(), '.insta', 'audit.jsonl'), 'utf8') return txt.split('\n').filter(Boolean).map((l) => JSON.parse(l)) } catch { return [] @@ -28,6 +48,9 @@ function* chunk(a: T[], n: number): Generator { export async function observeInstall(): Promise { const res = installObserve({ cwd: process.cwd() }) info(`installed observe hook (claude: ${res.claude}, codex: ${res.codex}) → ./.insta/observe`) + if (res.ignored.length) info(` .gitignore += ${res.ignored.join(', ')}`) + const hint = untrackHint(res.tracked) + if (hint) info(hint) info('it scans agent tool-use for credential exposure; findings append to ./.insta/audit.jsonl') info('run `insta observe report` to review, `insta observe sync` to upload to the project timeline') } diff --git a/src/commands/project.ts b/src/commands/project.ts index 34dda43..567b462 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -3,6 +3,7 @@ import { ApiClient, requireProject } from '../api.js' import { writeProject } from '../config.js' import { info, die, printJson, handleApproval, renderNextActions } from '../util.js' import { installObserve } from '../observe/install.js' +import { untrackHint } from '../gitignore.js' import { installSkills } from '../ensure-skills.js' // Generic directory names that make a useless project name ("projects", "~", "tmp", …). When the @@ -19,10 +20,11 @@ const GENERIC_DIRS = new Set([ function tryInstallObserve(quiet = false): void { try { const r = installObserve({ cwd: process.cwd() }) - if (r.claude || r.codex) { - const line = ' installed observe hook (credential audit) → ./.insta/observe' - quiet ? process.stderr.write(line + '\n') : info(line) - } + const say = (line: string) => (quiet ? process.stderr.write(line + '\n') : info(line)) + if (r.claude || r.codex) say(' installed observe hook (credential audit) → ./.insta/observe') + if (r.ignored.length) say(` .gitignore += ${r.ignored.join(', ')}`) + const hint = untrackHint(r.tracked) + if (hint) say(hint) } catch { /* assets missing (dev/unbuilt) — skip silently */ } } diff --git a/src/ensure-skills.ts b/src/ensure-skills.ts index 96ebb0d..3c8d295 100644 --- a/src/ensure-skills.ts +++ b/src/ensure-skills.ts @@ -6,16 +6,21 @@ // repo moved) prints a manual fallback and never blocks or fails the host command — same contract // as the observe-hook install. import { spawn } from 'node:child_process' -import { existsSync, readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' import { resolveEnv } from './config.js' import { resolveSpawnable } from './commands/setup.js' import { DEFAULT_ENV, ENVS } from './env.js' +import { alreadyTracked, ensureGitignore, untrackHint } from './gitignore.js' + +export { ensureGitignore } from './gitignore.js' // Where `npx skills add` drops skills for the agents we pin below: Claude Code → .claude/skills/, -// Codex → .agents/skills/ (.github/skills/ is the third well-known dir). These are regenerable -// agent context, not the developer's source — keep them out of git. -const SKILL_DIRS = ['.claude/skills/', '.agents/skills/', '.github/skills/'] +// Codex → .agents/skills/ (.github/skills/ is the third well-known dir), plus the skills-lock.json +// it writes at the project root. All regenerable agent context, not the developer's source — keep +// them out of git. The lock goes too: its payload is already ignored, it pins only a content hash +// (not the prod/staging source this CLI resolves per environment), and `insta project link` is +// the restore path — so a committed lock would be a lockfile for nothing. +const SKILL_DIRS = ['.claude/skills/', '.agents/skills/', '.github/skills/', 'skills-lock.json'] +const GITIGNORE_COMMENT = '# InstaCloud: agent skills installed by `npx skills add` (regenerable, not source)' export type Runner = (cmd: string, args: string[], inherit?: boolean) => Promise<{ ok: boolean }> @@ -87,31 +92,24 @@ export async function installSkills(deps: Deps): Promise { targets = skillTargets(skills) } catch { /* keep production defaults */ } print(' installing related agent skills (insta, tigris, better-auth) …') + let installed = 0 for (const s of targets) { // Don't stream: the `skills` tool's clack UI (clone spinner, banners) is noise. Run it // silent (stdio 'ignore') and let the per-skill ✓/failed line below be the clean output — // it appears as each skill finishes, so there's still live progress. (Also avoids the // child inheriting a piped stdin.) const r = await run('npx', s.args) + if (r.ok) installed++ print(r.ok ? ` ${s.label} ✓` : ` ${s.label} failed — add manually: npx ${s.args.join(' ')}`) } - const added = ensureGitignore(deps.cwd, SKILL_DIRS) + // Only when something was actually written: an offline run that failed every add has no + // skill dirs or lock to ignore, and a `.gitignore +=` line there would claim otherwise. + if (installed === 0) return + const added = ensureGitignore(deps.cwd, SKILL_DIRS, GITIGNORE_COMMENT) if (added.length) print(` .gitignore += ${added.join(', ')}`) + const hint = untrackHint(alreadyTracked(deps.cwd, SKILL_DIRS)) + if (hint) print(hint) } catch { /* best-effort convenience — never block the host command */ } } - -// Append any missing entries to the project's ./.gitignore (creating it if absent). Idempotent: -// entries already present are left alone. Returns the entries it added. -export function ensureGitignore(cwd: string, entries: string[]): string[] { - const p = join(cwd, '.gitignore') - const existing = existsSync(p) ? readFileSync(p, 'utf8') : '' - const have = new Set(existing.split('\n').map((l) => l.trim())) - const missing = entries.filter((e) => !have.has(e)) - if (missing.length === 0) return [] - const prefix = existing && !existing.endsWith('\n') ? '\n' : '' - const comment = '# InstaCloud: agent skills installed by `npx skills add` (regenerable, not source)' - writeFileSync(p, existing + `${prefix}\n${comment}\n${missing.join('\n')}\n`) - return missing -} diff --git a/src/gitignore.ts b/src/gitignore.ts new file mode 100644 index 0000000..c92b192 --- /dev/null +++ b/src/gitignore.ts @@ -0,0 +1,43 @@ +// Shared `.gitignore` maintenance for files the CLI writes into a project that are not the +// developer's source (installed skills, observe-hook state). The rule: whatever writes a +// regenerable or machine-local file adds its ignore entry in the same step, so `git status` never +// surfaces it as a surprise — the same convention as `insta secrets` for .env. +import { spawnSync } from 'node:child_process' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +// Append any missing entries to the project's ./.gitignore (creating it if absent). Idempotent: +// entries already present (exact line match) are left alone; a block gets one `comment` header +// the first time it contributes. Returns the entries it added. +export function ensureGitignore(cwd: string, entries: string[], comment?: string): string[] { + const p = join(cwd, '.gitignore') + const existing = existsSync(p) ? readFileSync(p, 'utf8') : '' + const have = new Set(existing.split('\n').map((l) => l.trim())) + const missing = entries.filter((e) => !have.has(e)) + if (missing.length === 0) return [] + const prefix = existing && !existing.endsWith('\n') ? '\n' : '' + const header = comment && !have.has(comment) ? `${comment}\n` : '' + writeFileSync(p, existing + `${prefix}\n${header}${missing.join('\n')}\n`) + return missing +} + +// An ignore entry does nothing for a path git already tracks — and the repos that most need these +// entries are the ones where the files were committed before the CLI ignored them. Returns the +// entries (as given) that have tracked files under them, so the caller can print the one hint +// that fixes it (`git rm -r --cached …`). Empty when git is absent or cwd is not a repo. +export function alreadyTracked(cwd: string, entries: string[]): string[] { + if (entries.length === 0) return [] + try { + const r = spawnSync('git', ['ls-files', '-z', '--', ...entries], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }) + if (r.status !== 0 || !r.stdout) return [] + const tracked = r.stdout.split('\0').filter(Boolean) + return entries.filter((e) => tracked.some((t) => t === e || t.startsWith(e.endsWith('/') ? e : `${e}/`))) + } catch { + return [] + } +} + +/** The one-line hint for `alreadyTracked` hits, or null when there are none. */ +export function untrackHint(tracked: string[]): string | null { + return tracked.length ? ` already tracked by git — to stop committing: git rm -r --cached ${tracked.join(' ')}` : null +} diff --git a/src/observe/hook.ts b/src/observe/hook.ts index 2b36b70..b1d1fd6 100644 --- a/src/observe/hook.ts +++ b/src/observe/hook.ts @@ -1,7 +1,7 @@ // PostToolUse hook: reads a tool-use event on stdin (Claude Code / Codex), scans every string // surface for credential exposure, and appends findings to ./.insta/audit.jsonl. Ported from firth. import { appendFileSync, mkdirSync } from 'node:fs' -import { dirname, join, resolve } from 'node:path' +import { basename, dirname, join, resolve } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { scanEvent, type ToolEvent } from './scanner.js' @@ -34,10 +34,23 @@ async function readStdin(): Promise { return Buffer.concat(chunks).toString('utf8') } +// Where findings go. The materialized hook lives at /.insta/observe/hook.js, so its +// own entry path names the linked project root — the one directory whose .insta/audit.jsonl is +// gitignored and that `insta observe report` reads. Anything else (the harness's project-dir env, +// the event cwd) is only a guess: Codex passes the SESSION cwd, which in a monorepo can be a +// subdirectory of the project, and writing there would leave an unignored audit log behind. +export function projectRootFor(entry: string | undefined, env: NodeJS.ProcessEnv, eventCwd: string | undefined): string { + if (entry) { + const dir = resolve(dirname(entry)) + if (basename(dir) === 'observe' && basename(dirname(dir)) === '.insta') return dirname(dirname(dir)) + } + return env.CLAUDE_PROJECT_DIR || eventCwd || '.' +} + export async function main(): Promise { let event: ToolEvent try { event = JSON.parse(await readStdin()) } catch { process.exit(0) } - const base = process.env.CLAUDE_PROJECT_DIR || (event.cwd as string) || '.' + const base = projectRootFor(process.argv[1], process.env, event.cwd as string | undefined) try { recordFindings(event, base) } catch (e) { process.stderr.write(`insta-observe: ${e instanceof Error ? e.message : e}\n`) } process.exit(0) } diff --git a/src/observe/install.ts b/src/observe/install.ts index 865e5da..c463406 100644 --- a/src/observe/install.ts +++ b/src/observe/install.ts @@ -3,8 +3,16 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' +import { alreadyTracked, ensureGitignore } from '../gitignore.js' const MARKER = 'insta-observe' + +// What the hook leaves under ./.insta that is machine-local, not project source: observe/ is a +// copy of this CLI version's hook + scanner (regenerated by every `project link`), and +// audit.jsonl is this machine's findings (fingerprints + redacted context — `insta observe sync` +// is the share path). ./.insta/project.json stays committable: it is the team's project binding. +const LOCAL_PATHS = ['.insta/observe/', '.insta/audit.jsonl'] +const GITIGNORE_COMMENT = '# InstaCloud: local observe-hook state (regenerated per machine, not source)' const DEFAULT_ASSET_DIR = dirname(fileURLToPath(import.meta.url)) // built: cli/dist/observe function cliVersion(): string { @@ -65,18 +73,55 @@ function claudeEntry(): Group { return { matcher: '*', hooks: [{ type: 'command', command: `[ ! -f ${hook} ] || node ${hook}`, timeout: 15, _insta: MARKER }] } } -function codexEntry(cwd: string): Group { - const abs = join(cwd, '.insta', 'observe', 'hook.js') // Codex doesn't expand ${CLAUDE_PROJECT_DIR}; use an absolute path - return { matcher: '*', hooks: [{ type: 'command', command: `node ${JSON.stringify(abs)}`, timeout: 15, _insta: MARKER }] } +// The Codex hook command. Constraints that rule out every simpler form: +// - no absolute path: it made .codex/hooks.json machine-specific (a teammate committing it shipped +// /Users//… to every clone, where node failed after each tool call); +// - not `git rev-parse --show-toplevel`: the insta project root is wherever `project link` ran, +// which in a monorepo is below the git root (`findProjectRoot` climbs for .insta/project.json +// for exactly that reason), so the hook would silently never fire there; +// - no POSIX shell syntax: with no `commandWindows` override Codex hands `command` verbatim to +// cmd.exe on Windows, where `[ ! -f … ]` / `$(…)` are parse errors — after every tool call. +// So: one shell-neutral `node -e` that climbs from the session cwd (Codex runs project hooks with +// the project as cwd) to the nearest .insta/observe/hook.js and runs it with stdin passed through; +// a fresh clone with no ./.insta anywhere above is a silent no-op. The script must stay free of +// characters either shell rewrites inside double quotes: `$` and backticks (sh), `%` and `!` +// (cmd.exe), and `"` (both). Codex has each user trust the entry before it runs, so shareable is safe. +const CODEX_HOOK_SCRIPT = [ + "const f=require('fs'),p=require('path'),c=require('child_process');", + 'let d=process.cwd();', + 'for(;;){', + "const h=p.join(d,'.insta/observe/hook.js');", + "if(f.existsSync(h)){const r=c.spawnSync(process.execPath,[h],{stdio:'inherit'});process.exitCode=r.status===null?1:r.status;break}", + 'const u=p.dirname(d);if(u===d)break;d=u}', +].join('') + +function codexEntry(): Group { + return { matcher: '*', hooks: [{ type: 'command', + command: `node -e "${CODEX_HOOK_SCRIPT}"`, timeout: 15, _insta: MARKER }] } +} + +export type ObserveInstall = { + claude: boolean + codex: boolean + /** .gitignore entries added by this call (empty on a re-link). */ + ignored: string[] + /** LOCAL_PATHS entries git already tracks — an ignore entry can't help those; see `untrackHint`. */ + tracked: string[] } -export function installObserve(opts: { cwd: string; assetDir?: string }): { claude: boolean; codex: boolean } { +export function installObserve(opts: { cwd: string; assetDir?: string }): ObserveInstall { materialize(opts.cwd, opts.assetDir ?? DEFAULT_ASSET_DIR) // hook required → let a missing asset throw to the caller + // Ignore the local state in the same step that creates it, so nobody discovers it via + // `git status`. Best-effort: an unwritable .gitignore must not fail the hook install. + // (Uninstall deliberately leaves the entries: a stale audit.jsonl should stay ignored.) + let ignored: string[] = [] + try { ignored = ensureGitignore(opts.cwd, LOCAL_PATHS, GITIGNORE_COMMENT) } catch { /* keep going */ } + const tracked = alreadyTracked(opts.cwd, LOCAL_PATHS) let claude = false let codex = false try { registerHarness(join(opts.cwd, '.claude', 'settings.json'), claudeEntry()); claude = true } catch { /* skip malformed */ } - try { registerHarness(join(opts.cwd, '.codex', 'hooks.json'), codexEntry(opts.cwd)); codex = true } catch { /* skip malformed */ } - return { claude, codex } + try { registerHarness(join(opts.cwd, '.codex', 'hooks.json'), codexEntry()); codex = true } catch { /* skip malformed */ } + return { claude, codex, ignored, tracked } } export function uninstallObserve(cwd: string): void { diff --git a/test/ensure-skills.test.ts b/test/ensure-skills.test.ts index cb13943..4d24e1b 100644 --- a/test/ensure-skills.test.ts +++ b/test/ensure-skills.test.ts @@ -1,4 +1,5 @@ -import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, expect, test } from 'vitest' @@ -52,6 +53,10 @@ test('installSkills adds insta + the service stack skills, non-interactively, an expect(gi).toMatch(/\.claude\/skills\//) expect(gi).toMatch(/\.agents\/skills\//) expect(gi).toMatch(/\.github\/skills\//) + // …and so is the skills-lock.json the tool writes at the project root: a lockfile whose payload + // is ignored (and which pins only a content hash, not our per-env source) is noise in `git status`. + expect(gi).toMatch(/^skills-lock\.json$/m) + expect(out.join('\n')).toMatch(/\.gitignore \+= .*skills-lock\.json/) }) test('a failed skill add still continues to the rest and reports the failure', async () => { @@ -63,6 +68,28 @@ test('a failed skill add still continues to the rest and reports the failure', a expect(out.join('\n')).toMatch(/better-auth ✓/) // reached the skill after the failure }) +test('when every skill add fails nothing was written, so nothing is gitignored and no += line is printed', async () => { + const dir = mkdtempSync(join(tmpdir(), 'insta-')) + const out: string[] = [] + await installSkills({ cwd: dir, run: async () => ({ ok: false }), print: (s) => out.push(s) }) + expect(existsSync(join(dir, '.gitignore'))).toBe(false) + expect(out.join('\n')).not.toMatch(/\.gitignore \+=/) +}) + +test('skills already committed before the CLI ignored them get the git rm --cached hint', async () => { + const dir = mkdtempSync(join(tmpdir(), 'insta-')) + const git = (...args: string[]) => spawnSync('git', ['-c', 'user.email=t@t', '-c', 'user.name=t', ...args], { cwd: dir }) + expect(git('init', '-q').status).toBe(0) + writeFileSync(join(dir, 'skills-lock.json'), '{}\n') + mkdirSync(join(dir, '.claude', 'skills', 'insta'), { recursive: true }) + writeFileSync(join(dir, '.claude', 'skills', 'insta', 'SKILL.md'), '# insta\n') + expect(git('add', '-f', '-A').status).toBe(0) // -f: a global excludes file must not blank the test + expect(git('commit', '-q', '-m', 'oops').status).toBe(0) + const out: string[] = [] + await installSkills({ cwd: dir, run: fakeRun().run, print: (s) => out.push(s) }) + expect(out.join('\n')).toMatch(/git rm -r --cached \.claude\/skills\/ skills-lock\.json/) +}) + test('ensureGitignore appends missing entries idempotently, preserving existing content', async () => { const dir = mkdtempSync(join(tmpdir(), 'insta-')) writeFileSync(join(dir, '.gitignore'), 'node_modules\n.env\n') @@ -73,3 +100,14 @@ test('ensureGitignore appends missing entries idempotently, preserving existing expect((gi.match(/^\.env$/gm) || []).length).toBe(1) // not duplicated expect(ensureGitignore(dir, ['.claude/skills/', '.env'])).toEqual([]) // re-run adds nothing }) + +test('ensureGitignore writes its comment header once per block, and creates the file when absent', () => { + const dir = mkdtempSync(join(tmpdir(), 'insta-')) + const c = '# InstaCloud: test block' + expect(ensureGitignore(dir, ['a/'], c)).toEqual(['a/']) // no .gitignore yet → created + expect(ensureGitignore(dir, ['a/', 'b'], c)).toEqual(['b']) // later addition under the same block + const gi = readFileSync(join(dir, '.gitignore'), 'utf8') + expect((gi.match(/^# InstaCloud: test block$/gm) || []).length).toBe(1) // header not repeated + expect(gi).toMatch(/^a\/$/m) + expect(gi).toMatch(/^b$/m) +}) diff --git a/test/observe-install.test.ts b/test/observe-install.test.ts index f252ee9..41fdaed 100644 --- a/test/observe-install.test.ts +++ b/test/observe-install.test.ts @@ -3,14 +3,171 @@ // ${CLAUDE_PROJECT_DIR} template — nothing expands it, so node threw MODULE_NOT_FOUND after EVERY // tool call in every linked project. Found live (user report, 2026-07-12). import { test, expect } from 'vitest' -import { mkdtempSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, mkdirSync, writeFileSync, realpathSync } from 'node:fs' import { spawnSync } from 'node:child_process' +import { createRequire } from 'node:module' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' import { installObserve } from '../src/observe/install.js' +import { projectRootFor } from '../src/observe/hook.js' +import { auditRoot } from '../src/commands/observe.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) const posixTest = process.platform === 'win32' ? test.skip : test +// The install materializes ./.insta/observe (this CLI version's hook, regenerated on every link) +// and the hook appends ./.insta/audit.jsonl (this machine's findings: partial fingerprints + +// redacted context). Neither is project source; both used to be left for the user to discover in +// `git status` (user report, 2026-09-04). ./.insta/project.json is the team binding and must NOT +// be caught by these entries. +test('install gitignores the machine-local .insta state but not project.json, idempotently', () => { + const cwd = mkdtempSync(join(tmpdir(), 'obs-proj-')) + writeFileSync(join(cwd, '.gitignore'), 'node_modules\n') + const first = installObserve({ cwd, assetDir: fakeAssets() }) + expect(first.ignored).toEqual(['.insta/observe/', '.insta/audit.jsonl']) + const gi = readFileSync(join(cwd, '.gitignore'), 'utf8') + expect(gi).toMatch(/^node_modules$/m) // existing content preserved + expect(gi).toMatch(/^\.insta\/observe\/$/m) + expect(gi).toMatch(/^\.insta\/audit\.jsonl$/m) + expect(gi).not.toMatch(/^\.insta\/?$/m) // never the whole dir: project.json stays committable + expect(installObserve({ cwd, assetDir: fakeAssets() }).ignored).toEqual([]) // re-link adds nothing +}) + +// Codex has no $CLAUDE_PROJECT_DIR, so the installer used to bake the absolute project path into +// .codex/hooks.json — a file teams commit — which shipped /Users//… to every clone and +// failed there after every tool call. The replacement must ALSO survive two things a first cut +// got wrong (review of #178): a monorepo where the insta project root is below the git root +// (`git rev-parse --show-toplevel` finds the wrong dir → hook silently dead), and Windows, where +// Codex hands `command` verbatim to cmd.exe unless a `commandWindows` override exists (POSIX +// `[ ! -f … ]` → parse error after every tool call). So: one shell-neutral `node -e` that climbs +// from the session cwd. These tests run the command through the platform shell (`shell: true` → +// sh on POSIX, cmd.exe on Windows), so the Windows CI job exercises the real thing. +const codexCommand = (cwd: string): string => + JSON.parse(readFileSync(join(cwd, '.codex', 'hooks.json'), 'utf8')).hooks.PostToolUse.at(-1).hooks[0].command +const runHook = (cmd: string, cwd: string, input = '{}') => spawnSync(cmd, { cwd, shell: true, input }) + +test('codex hook entry is shell-neutral: no absolute path, no POSIX syntax, nothing sh or cmd.exe rewrites', () => { + const cwd = mkdtempSync(join(tmpdir(), 'obs-proj-')) + installObserve({ cwd, assetDir: fakeAssets() }) + const cmd = codexCommand(cwd) + expect(cmd).not.toContain(cwd) + expect(cmd).toMatch(/^node -e "[^"]+"$/) // one double-quoted script, no inner quotes + expect(cmd).not.toMatch(/[$`%!]/) // $ and ` expand in sh; % and ! in cmd.exe + expect(cmd).not.toContain('[ ') // no test(1) + expect(cmd).toContain('.insta/observe/hook.js') // keeps the legacy-entry marker isInstaHook keys on +}) + +test('codex hook climbs to the insta root from a nested monorepo project and passes stdin through', () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'obs-mono-'))) + expect(spawnSync('git', ['init', '-q'], { cwd: root }).status).toBe(0) // git root ≠ project root + const project = join(root, 'apps', 'api') + mkdirSync(project, { recursive: true }) + installObserve({ cwd: project, assetDir: fakeAssets() }) + writeFileSync(join(project, '.insta', 'observe', 'hook.js'), + "process.stdin.on('data', (d) => process.stdout.write('got:' + d))") + const cmd = codexCommand(project) + // from the project dir, and from a subdirectory of it (the session cwd is wherever Codex runs) + const sub = join(project, 'src', 'routes') + mkdirSync(sub, { recursive: true }) + for (const dir of [project, sub]) { + const r = runHook(cmd, dir, '{"tool_name":"Bash"}') + expect(r.status).toBe(0) + expect(r.stdout.toString()).toBe('got:{"tool_name":"Bash"}') + } + // from the git root itself there is no .insta above → silent no-op, not an error + const atRoot = runHook(cmd, root) + expect(atRoot.status).toBe(0) + expect(atRoot.stdout.toString() + atRoot.stderr.toString()).toBe('') +}) + +// Finding the right hook.js is only half of it: the hook must also WRITE to that project root. +// It used to record into `CLAUDE_PROJECT_DIR || event.cwd`, and Codex's event.cwd is the session +// cwd — so a session started in apps/api/src/routes ran apps/api/.insta/observe/hook.js but left +// an unignored apps/api/src/routes/.insta/audit.jsonl behind (review of #178, round 2). Now the +// hook derives the root from its own entry path (/.insta/observe/hook.js). +test('projectRootFor: the materialized entry path wins; env / event cwd are only fallbacks', () => { + const root = join(tmpdir(), 'proj') + expect(projectRootFor(join(root, '.insta', 'observe', 'hook.js'), { CLAUDE_PROJECT_DIR: '/elsewhere' }, '/session')) + .toBe(root) + expect(projectRootFor('/somewhere/else/hook.js', { CLAUDE_PROJECT_DIR: '/claude' }, '/session')).toBe('/claude') + expect(projectRootFor('/somewhere/else/hook.js', {}, '/session')).toBe('/session') + expect(projectRootFor(undefined, {}, undefined)).toBe('.') +}) + +// End to end with the REAL hook source (loaded through tsx via NODE_OPTIONS so no build step is +// needed): the generated Codex command, run from a nested session cwd with a Codex-shaped event +// carrying a credential, must append to /.insta/audit.jsonl and nothing else. +test('codex command from a nested session cwd records findings at the linked project root', () => { + const mono = realpathSync(mkdtempSync(join(tmpdir(), 'obs-mono-'))) + const project = join(mono, 'apps', 'api') + mkdirSync(project, { recursive: true }) + installObserve({ cwd: project, assetDir: fakeAssets() }) + // materialized entry → the real hook's main(), so process.argv[1] is /.insta/observe/hook.js + const hookSrc = pathToFileURL(resolve(__dirname, '..', 'src', 'observe', 'hook.ts')).href + writeFileSync(join(project, '.insta', 'observe', 'hook.js'), `import { main } from ${JSON.stringify(hookSrc)}\nmain()\n`) + const tsxLoader = pathToFileURL(createRequire(import.meta.url).resolve('tsx/esm')).href + const session = join(project, 'src', 'routes') + mkdirSync(session, { recursive: true }) + const event = JSON.stringify({ tool_name: 'Bash', cwd: session, tool_input: { command: 'psql postgres://user:secretpass@db:5432/app' } }) + const r = spawnSync(codexCommand(project), { cwd: session, shell: true, input: event, + env: { ...process.env, NODE_OPTIONS: `--import ${tsxLoader}`, CLAUDE_PROJECT_DIR: '' } }) + expect(r.stderr.toString()).toBe('') + expect(r.status).toBe(0) + const audit = join(project, '.insta', 'audit.jsonl') + expect(existsSync(audit)).toBe(true) + expect(readFileSync(audit, 'utf8')).toMatch(/"fingerprint":/) + expect(readFileSync(audit, 'utf8')).not.toContain('secretpass') // redacted, never raw + expect(existsSync(join(session, '.insta'))).toBe(false) // nothing written at the session cwd +}) + +// The other half of "record at the project root": report/sync must READ from the same root the +// hook writes to, from any subdirectory — anchored on the link file when there is one, else on +// the materialized hook (a standalone `insta observe install` in an unlinked dir has no +// project.json), else cwd. +test('auditRoot: link file, else the materialized hook, else cwd — resolved from a subdirectory', async () => { + const linked = realpathSync(mkdtempSync(join(tmpdir(), 'obs-linked-'))) + mkdirSync(join(linked, '.insta'), { recursive: true }) + writeFileSync(join(linked, '.insta', 'project.json'), '{"projectId":"p","orgId":"o","branch":"main"}') + mkdirSync(join(linked, 'src', 'routes'), { recursive: true }) + expect(await auditRoot(join(linked, 'src', 'routes'))).toBe(linked) + + const unlinked = realpathSync(mkdtempSync(join(tmpdir(), 'obs-unlinked-'))) + installObserve({ cwd: unlinked, assetDir: fakeAssets() }) // hook materialized, no project.json + mkdirSync(join(unlinked, 'src', 'routes'), { recursive: true }) + expect(await auditRoot(join(unlinked, 'src', 'routes'))).toBe(unlinked) + + const bare = realpathSync(mkdtempSync(join(tmpdir(), 'obs-bare-'))) + expect(await auditRoot(bare)).toBe(bare) +}) + +test('codex hook is a silent no-op on a fresh clone with no ./.insta anywhere above', () => { + const cwd = mkdtempSync(join(tmpdir(), 'obs-proj-')) + installObserve({ cwd, assetDir: fakeAssets() }) + const bare = mkdtempSync(join(tmpdir(), 'obs-clone-')) + const r = runHook(codexCommand(cwd), bare) + expect(r.status).toBe(0) + expect(r.stdout.toString() + r.stderr.toString()).toBe('') +}) + +// A .gitignore entry does nothing for a path git already tracks — and the repos that most need +// these entries are the ones where audit.jsonl was committed before the CLI ignored it. The +// install reports those so the command can print the `git rm --cached` hint. +posixTest('install reports LOCAL_PATHS entries git already tracks', () => { + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'obs-proj-'))) + const git = (...args: string[]) => spawnSync('git', ['-c', 'user.email=t@t', '-c', 'user.name=t', ...args], { cwd }) + expect(git('init', '-q').status).toBe(0) + mkdirSync(join(cwd, '.insta'), { recursive: true }) + writeFileSync(join(cwd, '.insta', 'audit.jsonl'), '{}\n') + expect(git('add', '-f', '.insta/audit.jsonl').status).toBe(0) // -f: immune to a global excludes file + expect(git('commit', '-q', '-m', 'oops').status).toBe(0) + const r = installObserve({ cwd, assetDir: fakeAssets() }) + expect(r.tracked).toEqual(['.insta/audit.jsonl']) // observe/ is untracked → not reported + const fresh = installObserve({ cwd: mkdtempSync(join(tmpdir(), 'obs-nogit-')), assetDir: fakeAssets() }) + expect(fresh.tracked).toEqual([]) // not a repo → nothing to report, no error +}) + function fakeAssets(): string { const d = mkdtempSync(join(tmpdir(), 'obs-assets-')) writeFileSync(join(d, 'hook.js'), '// hook')