From 671a9c749e4c5c6f43b5873381431d0d8a3bd1bb Mon Sep 17 00:00:00 2001 From: yaowenc2 Date: Fri, 4 Sep 2026 20:05:50 -0700 Subject: [PATCH 1/4] fix(observe,skills): gitignore the local state the CLI writes; make the Codex hook portable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `insta project create|link` (and `insta observe install`) left three things for the user to discover in `git status`: `.insta/observe/`, `.codex/hooks.json` with an absolute path baked in, and `skills-lock.json`. Only the three skill dirs were ever ignored. - New `src/gitignore.ts`: the one `ensureGitignore(cwd, entries, comment)` helper, used by both installers (moved out of ensure-skills.ts; re-exported there for existing importers). Rule: the step that writes a regenerable or machine-local file adds its ignore entry, same as `insta secrets` does for .env. - observe install ignores `.insta/observe/` (this CLI version's hook copy) and `.insta/audit.jsonl` (this machine's findings: partial fingerprints + redacted context). Never `.insta/` wholesale: `project.json` is the team binding the skill tells users to commit. - skills install also ignores `skills-lock.json`: its payload is already ignored, it pins only a content hash (not the prod/staging source we resolve per env), and `project link` is the restore path. - Codex hook: `$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.insta/observe/hook.js`, guarded like the Claude entry, instead of the absolute project path — Codex runs project hooks with the project as cwd and its docs resolve repo-local scripts this way. `.codex/hooks.json` is now shareable across machines instead of shipping `/Users//…` to every clone. - Both commands print `.gitignore += …` for what they added; re-runs add nothing. Tests: gitignore entries + idempotence + project.json untouched; Codex entry has no absolute path, no-ops on a fresh clone, runs from a subdirectory of the repo; header written once. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0144rBPvDXaGgoje2QnmfxzY --- src/commands/observe.ts | 1 + src/commands/project.ts | 7 +++--- src/ensure-skills.ts | 31 +++++++++----------------- src/gitignore.ts | 21 ++++++++++++++++++ src/observe/install.ts | 30 ++++++++++++++++++++----- test/ensure-skills.test.ts | 15 +++++++++++++ test/observe-install.test.ts | 43 +++++++++++++++++++++++++++++++++++- 7 files changed, 117 insertions(+), 31 deletions(-) create mode 100644 src/gitignore.ts diff --git a/src/commands/observe.ts b/src/commands/observe.ts index 61ef885..808aea5 100644 --- a/src/commands/observe.ts +++ b/src/commands/observe.ts @@ -28,6 +28,7 @@ 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(', ')}`) 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..c6bb0d7 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -19,10 +19,9 @@ 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(', ')}`) } catch { /* assets missing (dev/unbuilt) — skip silently */ } } diff --git a/src/ensure-skills.ts b/src/ensure-skills.ts index 96ebb0d..7c4f1f3 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 { ensureGitignore } 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 }> @@ -95,23 +100,9 @@ export async function installSkills(deps: Deps): Promise { const r = await run('npx', s.args) print(r.ok ? ` ${s.label} ✓` : ` ${s.label} failed — add manually: npx ${s.args.join(' ')}`) } - const added = ensureGitignore(deps.cwd, SKILL_DIRS) + const added = ensureGitignore(deps.cwd, SKILL_DIRS, GITIGNORE_COMMENT) if (added.length) print(` .gitignore += ${added.join(', ')}`) } 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..730d055 --- /dev/null +++ b/src/gitignore.ts @@ -0,0 +1,21 @@ +// 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 { 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 +} diff --git a/src/observe/install.ts b/src/observe/install.ts index 865e5da..02bcaaa 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 { 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,28 @@ 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 }] } +function codexEntry(): Group { + // Codex runs project hooks with the session cwd (the project) as working directory and has no + // $CLAUDE_PROJECT_DIR; its own docs resolve repo-local scripts via `git rev-parse --show-toplevel`. + // Same guard as the Claude entry so a fresh clone without ./.insta no-ops. No absolute path: + // an absolute one made .codex/hooks.json machine-specific (a teammate committing it shipped + // /Users//… to everyone) — this form is shareable, and Codex has each user trust it first. + const hook = '"$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.insta/observe/hook.js"' + return { matcher: '*', hooks: [{ type: 'command', + command: `[ ! -f ${hook} ] || node ${hook}`, timeout: 15, _insta: MARKER }] } } -export function installObserve(opts: { cwd: string; assetDir?: string }): { claude: boolean; codex: boolean } { +export function installObserve(opts: { cwd: string; assetDir?: string }): { claude: boolean; codex: boolean; ignored: string[] } { 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. + let ignored: string[] = [] + try { ignored = ensureGitignore(opts.cwd, LOCAL_PATHS, GITIGNORE_COMMENT) } catch { /* keep going */ } 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 } } export function uninstallObserve(cwd: string): void { diff --git a/test/ensure-skills.test.ts b/test/ensure-skills.test.ts index cb13943..4a7fd9d 100644 --- a/test/ensure-skills.test.ts +++ b/test/ensure-skills.test.ts @@ -52,6 +52,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 () => { @@ -73,3 +77,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..75b72ba 100644 --- a/test/observe-install.test.ts +++ b/test/observe-install.test.ts @@ -3,7 +3,7 @@ // ${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 { mkdtempSync, readFileSync, mkdirSync, writeFileSync, realpathSync } from 'node:fs' import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -11,6 +11,47 @@ import { installObserve } from '../src/observe/install.js' 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. Codex runs project hooks with the project as cwd and its own +// docs resolve repo-local scripts via `git rev-parse --show-toplevel`; use that, guarded like the +// Claude entry, so the file is portable. +posixTest('codex hook entry is portable (no absolute path), no-ops without ./.insta, runs from a subdir', () => { + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'obs-proj-'))) + installObserve({ cwd, assetDir: fakeAssets() }) + const hooks = JSON.parse(readFileSync(join(cwd, '.codex', 'hooks.json'), 'utf8')) + const cmd: string = hooks.hooks.PostToolUse.at(-1).hooks[0].command + expect(cmd).not.toContain(cwd) + expect(cmd).toContain('git rev-parse --show-toplevel') + const run = (dir: string) => spawnSync('sh', ['-c', cmd], { cwd: dir }) + const bare = mkdtempSync(join(tmpdir(), 'obs-clone-')) // fresh clone: no .insta → silent no-op + expect(run(bare).status).toBe(0) + expect(run(bare).stderr.toString()).toBe('') + // in a git repo, resolves from the toplevel even when the session cwd is a subdirectory + expect(spawnSync('git', ['init', '-q'], { cwd }).status).toBe(0) + mkdirSync(join(cwd, 'sub')) + writeFileSync(join(cwd, '.insta', 'observe', 'hook.js'), 'process.stdout.write("ran")') + expect(run(join(cwd, 'sub')).stdout.toString()).toBe('ran') +}) + function fakeAssets(): string { const d = mkdtempSync(join(tmpdir(), 'obs-assets-')) writeFileSync(join(d, 'hook.js'), '// hook') From 3dd7ea9a378ef4c0d23b54ba5008a1b95bf52a22 Mon Sep 17 00:00:00 2001 From: yaowenc2 Date: Fri, 4 Sep 2026 20:18:06 -0700 Subject: [PATCH 2/4] fix(observe): shell-neutral Codex hook that climbs to the insta root; report already-tracked paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 on #178 found two Criticals in the first Codex entry: - `git rev-parse --show-toplevel` is the git root, not the insta project root — in a monorepo (project linked in apps/api) the hook resolved a path that does not exist and silently never fired; re-linking regenerated the same dead entry. - With no `commandWindows` override Codex hands `command` verbatim to cmd.exe on Windows, where `[ ! -f … ]` / `$(…)` are parse errors after every tool call — a regression against the old absolute-path form, which was shell-agnostic. Replace it with one shell-neutral `node -e` script that climbs from the session cwd to the nearest .insta/observe/hook.js (the same walk `findProjectRoot` does) and runs it with stdin passed through; nothing found → silent no-op. The script is kept free of every character sh or cmd.exe rewrites inside double quotes ($ ` % ! ") and asserted so. No git forks per tool call. Tests run the command through the platform shell (`shell: true`), so the Windows CI job now exercises the real thing: nested monorepo project (from the project dir and a subdirectory), git root above the project, fresh clone, stdin passthrough. Also from the review: - `alreadyTracked` + `untrackHint` in gitignore.ts: an ignore entry does nothing for a path git already tracks, and the repos that most need these entries are the ones that committed audit.jsonl / skills-lock.json before the CLI ignored them. Both installers now print the one `git rm -r --cached …` line that fixes it. - installSkills only touches .gitignore when at least one `skills add` succeeded, so an offline run no longer prints `.gitignore += …` for files that were never created. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0144rBPvDXaGgoje2QnmfxzY --- src/commands/observe.ts | 3 ++ src/commands/project.ts | 3 ++ src/ensure-skills.ts | 9 +++- src/gitignore.ts | 22 ++++++++++ src/observe/install.ts | 47 +++++++++++++++----- test/ensure-skills.test.ts | 25 ++++++++++- test/observe-install.test.ts | 84 ++++++++++++++++++++++++++++-------- 7 files changed, 164 insertions(+), 29 deletions(-) diff --git a/src/commands/observe.ts b/src/commands/observe.ts index 808aea5..8e07373 100644 --- a/src/commands/observe.ts +++ b/src/commands/observe.ts @@ -4,6 +4,7 @@ import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { installObserve, uninstallObserve } from '../observe/install.js' +import { untrackHint } from '../gitignore.js' import { renderReport } from '../observe/report.js' import { ApiClient, requireProject } from '../api.js' import { info, printJson } from '../util.js' @@ -29,6 +30,8 @@ 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 c6bb0d7..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 @@ -22,6 +23,8 @@ function tryInstallObserve(quiet = false): void { 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 7c4f1f3..3c8d295 100644 --- a/src/ensure-skills.ts +++ b/src/ensure-skills.ts @@ -9,7 +9,7 @@ import { spawn } from 'node:child_process' import { resolveEnv } from './config.js' import { resolveSpawnable } from './commands/setup.js' import { DEFAULT_ENV, ENVS } from './env.js' -import { ensureGitignore } from './gitignore.js' +import { alreadyTracked, ensureGitignore, untrackHint } from './gitignore.js' export { ensureGitignore } from './gitignore.js' @@ -92,16 +92,23 @@ 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(' ')}`) } + // 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 */ } diff --git a/src/gitignore.ts b/src/gitignore.ts index 730d055..c92b192 100644 --- a/src/gitignore.ts +++ b/src/gitignore.ts @@ -2,6 +2,7 @@ // 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' @@ -19,3 +20,24 @@ export function ensureGitignore(cwd: string, entries: string[], comment?: string 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/install.ts b/src/observe/install.ts index 02bcaaa..c463406 100644 --- a/src/observe/install.ts +++ b/src/observe/install.ts @@ -3,7 +3,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { ensureGitignore } from '../gitignore.js' +import { alreadyTracked, ensureGitignore } from '../gitignore.js' const MARKER = 'insta-observe' @@ -73,28 +73,55 @@ function claudeEntry(): Group { return { matcher: '*', hooks: [{ type: 'command', command: `[ ! -f ${hook} ] || node ${hook}`, 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 { - // Codex runs project hooks with the session cwd (the project) as working directory and has no - // $CLAUDE_PROJECT_DIR; its own docs resolve repo-local scripts via `git rev-parse --show-toplevel`. - // Same guard as the Claude entry so a fresh clone without ./.insta no-ops. No absolute path: - // an absolute one made .codex/hooks.json machine-specific (a teammate committing it shipped - // /Users//… to everyone) — this form is shareable, and Codex has each user trust it first. - const hook = '"$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.insta/observe/hook.js"' return { matcher: '*', hooks: [{ type: 'command', - command: `[ ! -f ${hook} ] || node ${hook}`, timeout: 15, _insta: MARKER }] } + 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; ignored: string[] } { +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()); codex = true } catch { /* skip malformed */ } - return { claude, codex, ignored } + 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 4a7fd9d..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' @@ -67,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') diff --git a/test/observe-install.test.ts b/test/observe-install.test.ts index 75b72ba..8155ae8 100644 --- a/test/observe-install.test.ts +++ b/test/observe-install.test.ts @@ -31,25 +31,75 @@ test('install gitignores the machine-local .insta state but not project.json, id // 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. Codex runs project hooks with the project as cwd and its own -// docs resolve repo-local scripts via `git rev-parse --show-toplevel`; use that, guarded like the -// Claude entry, so the file is portable. -posixTest('codex hook entry is portable (no absolute path), no-ops without ./.insta, runs from a subdir', () => { - const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'obs-proj-'))) +// 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 hooks = JSON.parse(readFileSync(join(cwd, '.codex', 'hooks.json'), 'utf8')) - const cmd: string = hooks.hooks.PostToolUse.at(-1).hooks[0].command + const cmd = codexCommand(cwd) expect(cmd).not.toContain(cwd) - expect(cmd).toContain('git rev-parse --show-toplevel') - const run = (dir: string) => spawnSync('sh', ['-c', cmd], { cwd: dir }) - const bare = mkdtempSync(join(tmpdir(), 'obs-clone-')) // fresh clone: no .insta → silent no-op - expect(run(bare).status).toBe(0) - expect(run(bare).stderr.toString()).toBe('') - // in a git repo, resolves from the toplevel even when the session cwd is a subdirectory - expect(spawnSync('git', ['init', '-q'], { cwd }).status).toBe(0) - mkdirSync(join(cwd, 'sub')) - writeFileSync(join(cwd, '.insta', 'observe', 'hook.js'), 'process.stdout.write("ran")') - expect(run(join(cwd, 'sub')).stdout.toString()).toBe('ran') + 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('') +}) + +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 { From 38743c35e5c196a0914e2bb34f18877ccd8a994f Mon Sep 17 00:00:00 2001 From: yaowenc2 Date: Fri, 4 Sep 2026 20:27:10 -0700 Subject: [PATCH 3/4] fix(observe): the hook records at the linked project root, derived from its own entry path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2 on #178: the Codex wrapper found the right apps/api/.insta/observe/hook.js from a nested session cwd, but the hook still wrote findings to `CLAUDE_PROJECT_DIR || event.cwd` — and Codex's event.cwd is the session cwd — so apps/api/src/routes/.insta/audit.jsonl appeared, unignored, and invisible to `insta observe report`. The materialized hook lives at /.insta/observe/hook.js, so its entry path (argv[1]) IS the project root: `projectRootFor` uses that first and falls back to the harness env / event cwd only when not running from a materialized location. `observe report|sync` read the audit from `findProjectRoot()` so they work from any subdirectory too. Tests: unit for projectRootFor; end to end with the REAL hook source (loaded via tsx through NODE_OPTIONS, no build step) — the generated Codex command run from apps/api/src/routes with a Codex-shaped event carrying a DB password appends a redacted finding to apps/api/.insta/audit.jsonl and writes nothing at the session cwd. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0144rBPvDXaGgoje2QnmfxzY --- src/commands/observe.ts | 6 ++++- src/observe/hook.ts | 17 +++++++++++-- test/observe-install.test.ts | 49 ++++++++++++++++++++++++++++++++++-- 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/src/commands/observe.ts b/src/commands/observe.ts index 8e07373..ac0b4f2 100644 --- a/src/commands/observe.ts +++ b/src/commands/observe.ts @@ -5,13 +5,17 @@ import { readFile } from 'node:fs/promises' import { join } 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' +// The audit lives at the linked project root (where the hook is materialized), so report/sync +// work from any subdirectory of the project, like every other project-scoped command. async function readAudit(): Promise>> { try { - const txt = await readFile(join(process.cwd(), '.insta', 'audit.jsonl'), 'utf8') + const root = (await findProjectRoot()) ?? process.cwd() + const txt = await readFile(join(root, '.insta', 'audit.jsonl'), 'utf8') return txt.split('\n').filter(Boolean).map((l) => JSON.parse(l)) } catch { return [] 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/test/observe-install.test.ts b/test/observe-install.test.ts index 8155ae8..e763a03 100644 --- a/test/observe-install.test.ts +++ b/test/observe-install.test.ts @@ -3,11 +3,16 @@ // ${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, realpathSync } 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' + +const __dirname = dirname(fileURLToPath(import.meta.url)) const posixTest = process.platform === 'win32' ? test.skip : test @@ -76,6 +81,46 @@ test('codex hook climbs to the insta root from a nested monorepo project and pas 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 +}) + 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() }) From 80900640fd252247ba88ba79c2b7292cdb6a6335 Mon Sep 17 00:00:00 2001 From: yaowenc2 Date: Fri, 4 Sep 2026 20:35:56 -0700 Subject: [PATCH 4/4] fix(observe): report/sync anchor on the same root the hook writes to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 3 of #178 (clean): auditRoot() = link file, else the materialized hook (a standalone `insta observe install` in an unlinked dir has no project.json), else cwd — so `observe report` from a subdirectory finds what the hook wrote. Test covers all three anchors. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0144rBPvDXaGgoje2QnmfxzY --- src/commands/observe.ts | 25 ++++++++++++++++++++----- test/observe-install.test.ts | 21 +++++++++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/commands/observe.ts b/src/commands/observe.ts index ac0b4f2..104bde3 100644 --- a/src/commands/observe.ts +++ b/src/commands/observe.ts @@ -1,8 +1,9 @@ // `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' @@ -10,12 +11,26 @@ import { renderReport } from '../observe/report.js' import { ApiClient, requireProject } from '../api.js' import { info, printJson } from '../util.js' -// The audit lives at the linked project root (where the hook is materialized), so report/sync -// work from any subdirectory of the project, like every other project-scoped command. +// 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 root = (await findProjectRoot()) ?? process.cwd() - const txt = await readFile(join(root, '.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 [] diff --git a/test/observe-install.test.ts b/test/observe-install.test.ts index e763a03..41fdaed 100644 --- a/test/observe-install.test.ts +++ b/test/observe-install.test.ts @@ -11,6 +11,7 @@ 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)) @@ -121,6 +122,26 @@ test('codex command from a nested session cwd records findings at the linked pro 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() })