@ai-sdlc/orchestrator 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,7 +28,12 @@ const MODULE_MARKERS = ['index.ts', 'index.js', 'index.mjs', 'package.json'];
28
28
  function matchesGlob(filePath, patterns) {
29
29
  for (const pattern of patterns) {
30
30
  // Simple glob matching: supports ** and *
31
+ // Escape backslashes FIRST before any other replacement so that the
32
+ // subsequent `.replace(/\./g, '\\.')` doesn't produce `\\.` sequences
33
+ // that are themselves broken when the input contained a `\`
34
+ // (CodeQL js/incomplete-sanitization alert #67).
31
35
  const regex = pattern
36
+ .replace(/\\/g, '\\\\')
32
37
  .replace(/\./g, '\\.')
33
38
  .replace(/\*\*/g, '{{GLOBSTAR}}')
34
39
  .replace(/\*/g, '[^/]*')
@@ -29,7 +29,7 @@ export function parseRemoteUrl(url) {
29
29
  return { org: sshShort[1].split('/').slice(-1)[0], repo: sshShort[2], detected: true };
30
30
  }
31
31
  // SSH or HTTPS with a scheme
32
- let parsed = null;
32
+ let parsed;
33
33
  try {
34
34
  parsed = new URL(trimmed);
35
35
  }
@@ -298,6 +298,47 @@ export interface FeatureAdapters {
298
298
  appendOnce: (path: string, contents: string, sentinel: string) => 'appended' | 'skipped';
299
299
  /** mkdir -p. Production = `node:fs.mkdirSync({ recursive: true })`. */
300
300
  mkdirp: (path: string) => void;
301
+ /**
302
+ * Read a text file, or `null` if it doesn't exist / can't be read.
303
+ * Production = `node:fs.readFileSync`. AISDLC-555: used to detect whether
304
+ * a project declares `husky` in package.json before deciding where to
305
+ * install the attestation-sign pre-push hook.
306
+ */
307
+ readTextFile: (path: string) => string | null;
308
+ /**
309
+ * chmod the file executable (0o755). Production =
310
+ * `node:fs.chmodSync(path, 0o755)`. AISDLC-555: a freshly WRITTEN
311
+ * `.husky/pre-push` or `.git/hooks/pre-push` must be executable or git
312
+ * silently never runs it — the AC #2 "working hook" requirement.
313
+ */
314
+ chmodExecutable: (path: string) => void;
315
+ /**
316
+ * Resolve `path` through symlinks, or `null` when it does not exist.
317
+ * Production = `node:fs.realpathSync`.
318
+ *
319
+ * AISDLC-555: `outsideProject` is decided with `path.relative`, which is
320
+ * purely LEXICAL — a repo that commits `.husky` (or `.husky/pre-push`) as a
321
+ * symlink pointing out of the tree still looks inside it, so neither the
322
+ * machine-wide refusal nor any string check fires, while `writeFileSync` and
323
+ * `chmodSync` happily follow the link. Resolving the real path is the only
324
+ * way to tell those apart.
325
+ */
326
+ realpath: (path: string) => string | null;
327
+ /**
328
+ * True when `path` itself is a symlink (does NOT follow it). Production =
329
+ * `node:fs.lstatSync(path).isSymbolicLink()`, `false` when `path` does not
330
+ * exist or `lstat` throws for any other reason.
331
+ *
332
+ * AISDLC-555 follow-up (dangling-symlink escape): `realpath` alone cannot
333
+ * distinguish "not a symlink" from "a symlink whose final component does
334
+ * not exist yet" — `realpathSync` throws ENOENT for the latter too, so the
335
+ * caller's `realpath(hookPath) ?? realpath(dirname(hookPath))` fallback
336
+ * silently resolves to the (real, in-project) parent directory and
337
+ * containment passes even though `hookPath` is a symlink pointing
338
+ * anywhere. `isSymlink` lets the caller ask the orthogonal question
339
+ * directly and refuse when a symlink's target can't be resolved at all.
340
+ */
341
+ isSymlink: (path: string) => boolean;
301
342
  /** Test for path existence. Production = `node:fs.existsSync`. */
302
343
  exists: (path: string) => boolean;
303
344
  /** Run a shell command (used for `gh api`). Production = `execSync`. */
@@ -411,6 +452,50 @@ export declare function buildProductionAdapters(): FeatureAdapters;
411
452
  * exactly the union of features marked true.
412
453
  */
413
454
  export declare function resolveFeatureSelection(flags: WizardFlags, adapters: Pick<FeatureAdapters, 'prompt' | 'log'>): Promise<FeatureSelection>;
455
+ /** Resolved target for the attestation-sign pre-push hook. */
456
+ export interface HookTarget {
457
+ /** Absolute path to the hook file. */
458
+ path: string;
459
+ /** Path relative to `projectDir`, for logging/result tracking. */
460
+ relPath: string;
461
+ /** True when the target is `.husky/pre-push`; false for `.git/hooks/pre-push`. */
462
+ isHusky: boolean;
463
+ /**
464
+ * True when the resolved hook lives OUTSIDE `projectDir` — e.g. a global
465
+ * `core.hooksPath = ~/.githooks`, which is shared by every repository on the
466
+ * machine. Callers warn before writing there: silently appending to a
467
+ * machine-wide hook is not something an adopter running `init` on one repo
468
+ * would expect.
469
+ */
470
+ outsideProject: boolean;
471
+ /**
472
+ * How the target was decided. Load-bearing, not cosmetic: `core.hooksPath`
473
+ * resolving outside the project usually means a GLOBAL or SYSTEM git setting
474
+ * shared by every repository on the machine, which is refused; a
475
+ * `git-hooks-dir` outside the project is a linked worktree's or submodule's
476
+ * common dir, which is still this one repository and is fine.
477
+ */
478
+ source: 'core.hooksPath' | 'git-hooks-dir' | 'husky-default';
479
+ }
480
+ /**
481
+ * Decide whether the attestation-sign hook belongs at `.husky/pre-push` or
482
+ * `.git/hooks/pre-push` (AISDLC-555 AC #4 — "handle repos that do not use
483
+ * husky at all... decided explicitly, not left undefined").
484
+ *
485
+ * Signal: read `package.json` at the project root and look for a `husky`
486
+ * entry in `dependencies` or `devDependencies`.
487
+ *
488
+ * - package.json parses AND does NOT declare `husky` → `.git/hooks/pre-push`
489
+ * (git's native hook path — always executed regardless of husky
490
+ * configuration; writing `.husky/pre-push` here would be silently inert
491
+ * since nothing ever points `core.hooksPath` at `.husky/`).
492
+ * - Every other case (package.json missing, unreadable, malformed, or DOES
493
+ * declare husky) → `.husky/pre-push` (the historical default — fails
494
+ * open to the common case: most repos running `ai-sdlc init
495
+ * --with-attestation` are JS/TS projects that already use, or will
496
+ * shortly `npm install` and pick up, husky).
497
+ */
498
+ export declare function resolveHookTarget(projectDir: string, adapters: FeatureAdapters): HookTarget;
414
499
  /** Return value of `applyFeatureSelection` — what was actually written. */
415
500
  export interface ApplyResult {
416
501
  /** Files that were newly created on this run. */
@@ -19,8 +19,8 @@
19
19
  * up a TTY or shelling out to `gh`. Production callers in `init.ts` pass
20
20
  * the real adapters.
21
21
  */
22
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
23
- import { join, dirname, basename } from 'node:path';
22
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync, } from 'node:fs';
23
+ import { join, dirname, basename, isAbsolute, relative } from 'node:path';
24
24
  import { execFileSync, execSync } from 'node:child_process';
25
25
  import { BASELINE_DERIVED_GATES } from '../../compliance/types.js';
26
26
  import { ATTESTATION_TEMPLATES, BASELINE_WORKFLOW_TEMPLATES, CLASSIFIER_TEMPLATES, DOR_TEMPLATES, HUSKY_PREPUSH_SIGN_SNIPPET, SIGNAL_INGESTION_TEMPLATES, WORKFLOWS_TEMPLATES, } from './init-templates.js';
@@ -195,6 +195,17 @@ export function describeOq11Trigger(kind) {
195
195
  return 'operator security review identified a risk RLS cannot mitigate (operator-declared)';
196
196
  }
197
197
  }
198
+ /**
199
+ * Escape a string for safe embedding inside a YAML double-quoted scalar.
200
+ * YAML double-quoted strings treat `\` as an escape character, so backslashes
201
+ * must be escaped first, then double-quotes. Escaping only `"` (without
202
+ * first escaping `\`) is an incomplete sanitization — a value like `foo\bar`
203
+ * would produce `"foo\bar"` where `\b` is a YAML escape sequence.
204
+ * (CodeQL js/incomplete-sanitization alerts #68, #69, #70.)
205
+ */
206
+ function escapeYamlDoubleQuoted(value) {
207
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
208
+ }
198
209
  /**
199
210
  * Build the .ai-sdlc/compliance.yaml content for a given compliance declaration.
200
211
  *
@@ -209,8 +220,10 @@ export function buildComplianceYaml(opts) {
209
220
  const { projectName, regimes, attestedBy, attestedAt, attestedNotes, derivedGates } = opts;
210
221
  // AISDLC-324 review fix: quote attestedBy/id/attestedAt so an operator
211
222
  // git config user.email containing ": " or other YAML-significant chars
212
- // can't break the YAML structure. attestedNotes already quoted+escaped.
213
- const quotedAttestedBy = `"${attestedBy.replace(/"/g, '\\"')}"`;
223
+ // can't break the YAML structure. Use escapeYamlDoubleQuoted() which
224
+ // escapes backslashes before quotes (incomplete-sanitization fix for
225
+ // CodeQL alerts #68/#69/#70).
226
+ const quotedAttestedBy = `"${escapeYamlDoubleQuoted(attestedBy)}"`;
214
227
  const regimeItems = regimes
215
228
  .map((id) => {
216
229
  // id is from hardcoded COMPLIANCE_REGIME_CHOICES (validated upstream)
@@ -218,10 +231,10 @@ export function buildComplianceYaml(opts) {
218
231
  const lines = [
219
232
  ` - id: ${id}`,
220
233
  ` attestedBy: ${quotedAttestedBy}`,
221
- ` attestedAt: "${attestedAt}"`,
234
+ ` attestedAt: "${escapeYamlDoubleQuoted(attestedAt)}"`,
222
235
  ];
223
236
  if (attestedNotes) {
224
- lines.push(` attestedNotes: "${attestedNotes.replace(/"/g, '\\"')}"`);
237
+ lines.push(` attestedNotes: "${escapeYamlDoubleQuoted(attestedNotes)}"`);
225
238
  }
226
239
  return lines.join('\n');
227
240
  })
@@ -244,7 +257,7 @@ export function buildComplianceYaml(opts) {
244
257
  `apiVersion: ai-sdlc.io/v1alpha1`,
245
258
  `kind: CompliancePosture`,
246
259
  `metadata:`,
247
- ` name: "${projectName.replace(/"/g, '\\"')}"`,
260
+ ` name: "${escapeYamlDoubleQuoted(projectName)}"`,
248
261
  `spec:`,
249
262
  regimesSection,
250
263
  ` auditExports: []`,
@@ -596,6 +609,45 @@ export function buildProductionAdapters() {
596
609
  },
597
610
  mkdirp: (path) => mkdirSync(path, { recursive: true }),
598
611
  exists: (path) => existsSync(path),
612
+ readTextFile: (path) => {
613
+ try {
614
+ return existsSync(path) ? readFileSync(path, 'utf-8') : null;
615
+ }
616
+ catch {
617
+ return null;
618
+ }
619
+ },
620
+ chmodExecutable: (path) => {
621
+ try {
622
+ // AISDLC-555 round-1 security review: do NOT force 0o755. That widens
623
+ // an adopter's deliberately-restrictive mode (0600 → world-readable
624
+ // and world-executable) on a file that sits in their repo. Add only
625
+ // the exec bits and preserve everything else they chose.
626
+ const current = statSync(path).mode & 0o777;
627
+ chmodSync(path, current | 0o111);
628
+ }
629
+ catch {
630
+ // Best-effort — a chmod failure (e.g. read-only filesystem) should
631
+ // not abort the whole wizard run; the operator sees the created
632
+ // file either way and can chmod it themselves if needed.
633
+ }
634
+ },
635
+ realpath: (path) => {
636
+ try {
637
+ return realpathSync(path);
638
+ }
639
+ catch {
640
+ return null; // does not exist (or is unreadable) — caller decides.
641
+ }
642
+ },
643
+ isSymlink: (path) => {
644
+ try {
645
+ return lstatSync(path).isSymbolicLink();
646
+ }
647
+ catch {
648
+ return false; // does not exist / unreadable — not a symlink to us.
649
+ }
650
+ },
599
651
  runCommand: (cmd, args) => {
600
652
  try {
601
653
  // Use `execFileSync` (no shell) so args are passed as a true
@@ -731,6 +783,161 @@ export async function resolveFeatureSelection(flags, adapters) {
731
783
  }
732
784
  return sel;
733
785
  }
786
+ /**
787
+ * Decide whether the attestation-sign hook belongs at `.husky/pre-push` or
788
+ * `.git/hooks/pre-push` (AISDLC-555 AC #4 — "handle repos that do not use
789
+ * husky at all... decided explicitly, not left undefined").
790
+ *
791
+ * Signal: read `package.json` at the project root and look for a `husky`
792
+ * entry in `dependencies` or `devDependencies`.
793
+ *
794
+ * - package.json parses AND does NOT declare `husky` → `.git/hooks/pre-push`
795
+ * (git's native hook path — always executed regardless of husky
796
+ * configuration; writing `.husky/pre-push` here would be silently inert
797
+ * since nothing ever points `core.hooksPath` at `.husky/`).
798
+ * - Every other case (package.json missing, unreadable, malformed, or DOES
799
+ * declare husky) → `.husky/pre-push` (the historical default — fails
800
+ * open to the common case: most repos running `ai-sdlc init
801
+ * --with-attestation` are JS/TS projects that already use, or will
802
+ * shortly `npm install` and pick up, husky).
803
+ */
804
+ export function resolveHookTarget(projectDir, adapters) {
805
+ // AISDLC-555 rounds 1-2: ASK git where hooks live rather than guessing.
806
+ // `rev-parse --git-path hooks` is the only answer that is right in all the
807
+ // shapes that bit earlier rounds of this task:
808
+ //
809
+ // - it honours `core.hooksPath`, which git consults BEFORE `.git/hooks`
810
+ // (lefthook, a global `~/.githooks`, husky v9);
811
+ // - it expands a `~`-prefixed value the way git itself does — `isAbsolute`
812
+ // says false for `~/.githooks` and `join` would have produced a literal
813
+ // `<projectDir>/~/.githooks/pre-push`;
814
+ // - it resolves the COMMON dir for linked worktrees and submodules, where
815
+ // `.git` is a FILE and `join(dir, '.git/hooks')` fails with ENOTDIR.
816
+ //
817
+ // The pre-round-1 docblock claimed `.git/hooks/pre-push` is "always executed
818
+ // regardless of husky configuration". That is simply false, and believing it
819
+ // is how this task's own fix shipped a silently-inert hook twice.
820
+ //
821
+ // Two git calls, because they answer different questions and conflating them
822
+ // is itself a bug: `rev-parse --git-path hooks` always returns SOMETHING
823
+ // (defaulting to `.git/hooks`), so it cannot distinguish "explicitly
824
+ // configured" from "not configured". A repo that declares husky but has not
825
+ // run `husky install` yet reports `.git/hooks` — installing there works right
826
+ // up until husky sets core.hooksPath and git stops reading `.git/hooks` at
827
+ // all. So: `config --get` decides WHETHER it is set, `rev-parse` RESOLVES it.
828
+ const configured = adapters.runCommand('git', [
829
+ '-C',
830
+ projectDir,
831
+ 'config',
832
+ '--get',
833
+ 'core.hooksPath',
834
+ ]);
835
+ const configuredValue = configured.exitCode === 0 ? configured.stdout.trim() : '';
836
+ const hooksPathIsSet = configuredValue !== '';
837
+ if (configured.exitCode === 0 && configuredValue === '') {
838
+ // `core.hooksPath = ""` — git reports success with an empty value. Treating
839
+ // it as configured would resolve nowhere; treating it as unset silently is
840
+ // how an inert hook gets installed. Say so.
841
+ adapters.log(` NOTE core.hooksPath is set to an empty value — treating it as unset.` +
842
+ ` If hooks are not running, unset it explicitly: git config --unset core.hooksPath`);
843
+ }
844
+ const probe = adapters.runCommand('git', ['-C', projectDir, 'rev-parse', '--git-path', 'hooks']);
845
+ const gitHooks = probe.exitCode === 0 ? probe.stdout.trim() : '';
846
+ if (hooksPathIsSet && gitHooks) {
847
+ let base = isAbsolute(gitHooks) ? gitHooks : join(projectDir, gitHooks);
848
+ // husky v9 sets `core.hooksPath = .husky/_` — its own generated internals,
849
+ // whose `.gitignore` is literally `*`, regenerated by every `husky` run
850
+ // (i.e. every `npm install`, via the `prepare` script). A hook installed
851
+ // there works exactly until the next install and then vanishes with no
852
+ // signal — the precise failure this task exists to eliminate, aimed at the
853
+ // most common adopter topology.
854
+ //
855
+ // The v9 wrapper (`.husky/_/h`) runs `$(dirname $(dirname $0))/<hookname>`
856
+ // and exits 0 when it is absent, so the durable, adopter-owned target is
857
+ // the PARENT directory. Verified against husky 9 by executing a real
858
+ // `git push`, not by reading the wrapper.
859
+ //
860
+ // `husky <dir>` supports a custom directory (`core.hooksPath =
861
+ // .config/husky/_`), and on a fresh clone the `_` internals may not exist
862
+ // on disk yet — `_/.gitignore` is `*`, so nothing under it is committed.
863
+ // Neither the `.husky` parent name nor the wrapper files would match, so
864
+ // also accept "the project declares husky" as evidence of the layout.
865
+ const looksLikeHuskyInternals = basename(base) === '_' &&
866
+ (basename(dirname(base)) === '.husky' ||
867
+ adapters.exists(join(base, 'h')) ||
868
+ adapters.exists(join(base, 'husky.sh')) ||
869
+ projectDeclaresHusky(projectDir, adapters, false));
870
+ if (looksLikeHuskyInternals) {
871
+ base = dirname(base);
872
+ }
873
+ const rel = relative(projectDir, base);
874
+ const outsideProject = rel.startsWith('..') || isAbsolute(rel);
875
+ return {
876
+ path: join(base, 'pre-push'),
877
+ // Outside the project there is no meaningful relative path to show, so
878
+ // log the absolute one — an adopter needs to see that it is machine-wide.
879
+ relPath: outsideProject ? join(base, 'pre-push') : join(rel, 'pre-push'),
880
+ isHusky: base.includes('husky'),
881
+ outsideProject,
882
+ // Distinguished from the common-dir case below because the remedies and
883
+ // the risks are completely different: a configured hooks path outside the
884
+ // project is very likely a GLOBAL or SYSTEM setting shared by every repo
885
+ // on the machine, whereas a common dir is still this one repository.
886
+ source: 'core.hooksPath',
887
+ };
888
+ }
889
+ const isHusky = projectDeclaresHusky(projectDir, adapters, true);
890
+ if (isHusky) {
891
+ // husky is declared but has not configured core.hooksPath yet (no
892
+ // `husky install` run). `.husky/pre-push` is where husky will look once
893
+ // it does, and it is the adopter-owned, committed location either way.
894
+ return {
895
+ path: join(projectDir, '.husky', 'pre-push'),
896
+ relPath: '.husky/pre-push',
897
+ isHusky: true,
898
+ outsideProject: false,
899
+ source: 'husky-default',
900
+ };
901
+ }
902
+ // No husky, no core.hooksPath: git's own hooks dir. Prefer the path git
903
+ // reports over hand-building `<dir>/.git/hooks` — in a linked worktree or
904
+ // submodule `.git` is a FILE, so the hand-built path fails with ENOTDIR and
905
+ // aborts init, and the real hooks live in the common dir anyway.
906
+ const base = gitHooks
907
+ ? isAbsolute(gitHooks)
908
+ ? gitHooks
909
+ : join(projectDir, gitHooks)
910
+ : join(projectDir, '.git', 'hooks');
911
+ const rel = relative(projectDir, base);
912
+ const outsideProject = rel.startsWith('..') || isAbsolute(rel);
913
+ return {
914
+ path: join(base, 'pre-push'),
915
+ relPath: outsideProject ? join(base, 'pre-push') : join(rel, 'pre-push'),
916
+ isHusky: false,
917
+ outsideProject,
918
+ source: 'git-hooks-dir',
919
+ };
920
+ }
921
+ /**
922
+ * True when the project declares `husky` as a (dev)dependency.
923
+ *
924
+ * Fails OPEN (returns true) when package.json is missing or malformed: the
925
+ * pre-AISDLC-555 default was `.husky/pre-push`, and an adopter-owned committed
926
+ * file is a safer place to be wrong than git's internal hooks directory.
927
+ */
928
+ function projectDeclaresHusky(projectDir, adapters, whenUnknown) {
929
+ const pkgRaw = adapters.readTextFile(join(projectDir, 'package.json'));
930
+ if (pkgRaw === null)
931
+ return whenUnknown;
932
+ try {
933
+ const pkg = JSON.parse(pkgRaw);
934
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
935
+ return 'husky' in deps;
936
+ }
937
+ catch {
938
+ return whenUnknown;
939
+ }
940
+ }
734
941
  /**
735
942
  * Write the union of feature templates into the project dir. AC #4 says
736
943
  * the BASELINE workflow templates (gate workflow) are always written; the
@@ -806,34 +1013,247 @@ export async function applyFeatureSelection(projectDir, selection, flags, adapte
806
1013
  }
807
1014
  }
808
1015
  }
809
- // Husky pre-push sign hook is a separate concern from the
810
- // FeatureTemplateSet because it's an APPEND (not a write-from-empty)
811
- // — adopters often already have a .husky/pre-push from their existing
812
- // tooling and we don't want to clobber it. Only fired when attestation
813
- // is on.
1016
+ // Pre-push sign hook is a separate concern from the FeatureTemplateSet
1017
+ // because it's an APPEND (not a write-from-empty) — adopters often
1018
+ // already have a pre-push hook from their existing tooling and we don't
1019
+ // want to clobber it. Only fired when attestation is on.
1020
+ //
1021
+ // AISDLC-555 AC #4: decide the hook TARGET explicitly rather than always
1022
+ // assuming husky. `resolveHookTarget` inspects package.json for a
1023
+ // declared `husky` dependency; when the repo positively does NOT declare
1024
+ // husky, the hook is written straight to `.git/hooks/pre-push` — git's
1025
+ // own native hook path, which git always executes regardless of husky
1026
+ // configuration (writing `.husky/pre-push` into a repo that never wires
1027
+ // `core.hooksPath` there would be silently inert). Every other case
1028
+ // (package.json missing/unreadable, or husky declared) keeps the
1029
+ // pre-AISDLC-555 default of `.husky/pre-push`.
814
1030
  if (selection.attestation && !flags.dryRun) {
815
- const hookPath = join(projectDir, '.husky', 'pre-push');
816
- if (!adapters.exists(hookPath)) {
1031
+ const { path: hookPath, relPath: hookRelPath, isHusky: hookIsHusky, outsideProject: hookOutsideProject, source: hookSource, } = resolveHookTarget(projectDir, adapters);
1032
+ // Set when the target is refused; every other feature still applies, only
1033
+ // the hook install is skipped.
1034
+ let refuseHookInstall = false;
1035
+ // Say where the hook landed and why. Round-2 review: the resolution is now
1036
+ // non-obvious (git-reported hooks dir, with a husky-internals step-up), so
1037
+ // an adopter debugging "why did nothing sign?" needs the decision visible.
1038
+ adapters.log(` hook target ${hookRelPath} (${hookIsHusky ? 'husky' : 'git hooks dir'})`);
1039
+ // Round-3 security review (medium): `git config --get core.hooksPath` reads
1040
+ // ALL scopes, so a GLOBAL or SYSTEM value — `~/.githooks`, an org-mandated
1041
+ // `/etc/team-hooks` — makes this fire for a repo whose own config never
1042
+ // opted in. Appending there puts the key-bearing signer on EVERY push in
1043
+ // EVERY repo on the machine, including untrusted clones, where it would
1044
+ // read that repo's `.active-task` and verdicts and invoke the signer with
1045
+ // the operator's Ed25519 key. A per-repo `init` must not silently become a
1046
+ // machine-wide execution surface, and a warning is not a consent gate when
1047
+ // `init --yes` runs non-interactively. So: refuse, and make the opt-in
1048
+ // explicit and auditable.
1049
+ //
1050
+ // Deliberately scoped to `core.hooksPath`. A linked worktree or submodule
1051
+ // also resolves outside the project (git's common dir), but that is still
1052
+ // this one repository — refusing there would break a legitimate topology.
1053
+ if (hookOutsideProject && hookSource === 'core.hooksPath') {
1054
+ if (process.env.AI_SDLC_ALLOW_GLOBAL_HOOKS === '1') {
1055
+ adapters.log(` WARNING installing into ${hookRelPath}, which is OUTSIDE this project.` +
1056
+ ` core.hooksPath is set globally, so the sign block will run on every` +
1057
+ ` push in every repository on this machine.` +
1058
+ ` Proceeding because AI_SDLC_ALLOW_GLOBAL_HOOKS=1.`);
1059
+ }
1060
+ else {
1061
+ adapters.log(` REFUSED to install the attestation hook: core.hooksPath resolves to` +
1062
+ ` ${hookRelPath}, OUTSIDE this project — almost certainly a global or` +
1063
+ ` system git setting shared by every repository on this machine.`);
1064
+ adapters.log(` Installing there would run the attestation signer, with your signing` +
1065
+ ` key, on every push in every repo — including ones you do not control.`);
1066
+ adapters.log(` Fix by scoping the hooks path to this repo:` +
1067
+ ` git -C . config core.hooksPath .husky` +
1068
+ ` — then re-run. To install machine-wide anyway (rarely what you want):` +
1069
+ ` AI_SDLC_ALLOW_GLOBAL_HOOKS=1 ai-sdlc init --with-attestation`);
1070
+ result.skipped.push(hookRelPath);
1071
+ refuseHookInstall = true;
1072
+ }
1073
+ }
1074
+ else if (hookOutsideProject) {
1075
+ // Worktree / submodule common dir: real, but a different situation with a
1076
+ // different remedy. Round-3 review flagged the previous shared wording as
1077
+ // factually wrong here — it named core.hooksPath, which is not set.
1078
+ adapters.log(` NOTE ${hookRelPath} is outside this working tree — it is this` +
1079
+ ` repository's shared git hooks directory (linked worktree or submodule),` +
1080
+ ` so the sign block also applies to sibling worktrees of the same repo.`);
1081
+ }
1082
+ // Symlink containment. Only meaningful when we BELIEVE the target is
1083
+ // inside the project: the worktree-common-dir and explicit
1084
+ // AI_SDLC_ALLOW_GLOBAL_HOOKS cases are knowingly outside and already
1085
+ // handled above. Here the lexical check said "inside", so if the real path
1086
+ // says otherwise, something in the repo is redirecting us — refuse rather
1087
+ // than append a shell snippet to, and set the exec bit on, a file outside
1088
+ // the tree (`~/.bashrc` being the obvious target).
1089
+ if (!refuseHookInstall && !hookOutsideProject) {
1090
+ const realProject = adapters.realpath(projectDir);
1091
+ const hookDir = dirname(hookPath);
1092
+ // Permissive when `realProject` itself can't be resolved (mirrors the
1093
+ // pre-existing fallback's `realProject !== null` gate): we can only
1094
+ // flag a candidate as "definitely outside" when we actually know what
1095
+ // "inside" means. A NULL `real` here always means "dangling" and is
1096
+ // handled as its own, unconditional refusal below — this helper is
1097
+ // only ever called with a non-null resolved path.
1098
+ const isDefinitelyOutside = (real) => {
1099
+ if (realProject === null)
1100
+ return false;
1101
+ const rel = relative(realProject, real);
1102
+ return rel.startsWith('..') || isAbsolute(rel);
1103
+ };
1104
+ // Check `hookPath` and its parent directory (`.husky` itself may be the
1105
+ // symlink) EXPLICITLY for symlink-ness, rather than only resolving the
1106
+ // "deepest existing component" as a fallback. A DANGLING final-component
1107
+ // symlink — `.husky/pre-push` (or `.husky` itself) pointing at a path
1108
+ // that does not exist YET — makes `realpath(hookPath)` throw ENOENT just
1109
+ // like "not a symlink at all" does, so falling straight to
1110
+ // `realpath(dirname(hookPath))` silently resolves to the real,
1111
+ // in-project parent and containment passes even though the final
1112
+ // component redirects somewhere unknown. `isSymlink` lets us ask "is
1113
+ // this a symlink" independently of whether it currently resolves, so a
1114
+ // dangling link is refused UNCONDITIONALLY — a symlink we cannot
1115
+ // resolve at all is never treated as "probably fine".
1116
+ const candidates = [
1117
+ { path: hookPath, relPath: hookRelPath },
1118
+ { path: hookDir, relPath: relative(projectDir, hookDir) || '.' },
1119
+ ];
1120
+ for (const { path: candidatePath, relPath: candidateRelPath } of candidates) {
1121
+ if (refuseHookInstall)
1122
+ break;
1123
+ if (!adapters.isSymlink(candidatePath))
1124
+ continue;
1125
+ const realCandidate = adapters.realpath(candidatePath);
1126
+ if (realCandidate === null) {
1127
+ adapters.log(` REFUSED to install the attestation hook: ${candidateRelPath} is a symlink` +
1128
+ ` whose target does not exist (a "dangling" symlink). Refusing rather than` +
1129
+ ` following it — writing through a dangling symlink would create a new file` +
1130
+ ` (with the exec bit set) at whatever path the link names, anywhere on disk.` +
1131
+ ` Remove the symlink and re-run.`);
1132
+ }
1133
+ else if (isDefinitelyOutside(realCandidate)) {
1134
+ adapters.log(` REFUSED to install the attestation hook: ${candidateRelPath} resolves to` +
1135
+ ` ${realCandidate}, outside this project. A symlink in the repository is` +
1136
+ ` redirecting the hook path.`);
1137
+ adapters.log(` Refusing rather than appending a shell snippet to a file outside the` +
1138
+ ` project and marking it executable. Remove the symlink and re-run.`);
1139
+ }
1140
+ else {
1141
+ continue; // resolves, and stays inside the project — fine.
1142
+ }
1143
+ result.skipped.push(hookRelPath);
1144
+ refuseHookInstall = true;
1145
+ }
1146
+ // Fallback for the case where neither `hookPath` nor its immediate
1147
+ // parent is itself a symlink, but some other already-existing component
1148
+ // resolves outside the project (e.g. a symlink further up the tree).
1149
+ // Resolve the deepest existing component and check containment.
1150
+ if (!refuseHookInstall) {
1151
+ const realTarget = adapters.realpath(hookPath) ?? adapters.realpath(hookDir) ?? null;
1152
+ if (realTarget !== null && isDefinitelyOutside(realTarget)) {
1153
+ adapters.log(` REFUSED to install the attestation hook: ${hookRelPath} resolves to` +
1154
+ ` ${realTarget}, outside this project. A symlink in the repository is` +
1155
+ ` redirecting the hook path.`);
1156
+ adapters.log(` Refusing rather than appending a shell snippet to a file outside the` +
1157
+ ` project and marking it executable. Remove the symlink and re-run.`);
1158
+ result.skipped.push(hookRelPath);
1159
+ refuseHookInstall = true;
1160
+ }
1161
+ }
1162
+ }
1163
+ if (refuseHookInstall) {
1164
+ // Refused above — nothing further to do for the hook.
1165
+ }
1166
+ else if (!adapters.exists(hookPath)) {
817
1167
  // No existing hook — write a minimal one with the sign block.
818
- adapters.mkdirp(dirname(hookPath));
819
- adapters.writeFile(hookPath, `#!/usr/bin/env bash\nset -euo pipefail\n\n${HUSKY_PREPUSH_SIGN_SNIPPET}`);
820
- result.created.push('.husky/pre-push');
821
- adapters.log(` created .husky/pre-push`);
1168
+ //
1169
+ // Defense-in-depth: the symlink-containment check above should already
1170
+ // have refused a dangling `.husky` (or `.husky/pre-push`) symlink before
1171
+ // we get here, but `mkdirp` on a path whose parent is a dangling symlink
1172
+ // throws EEXIST (the symlink itself exists; what it points at does not).
1173
+ // Wrap the create sequence so a gap in that check — or a TOCTOU race
1174
+ // where the symlink appears between the check and this write — aborts
1175
+ // only the hook install, not the entire wizard with an unhandled
1176
+ // exception.
1177
+ try {
1178
+ adapters.mkdirp(dirname(hookPath));
1179
+ // POSIX `sh`, not bash: husky v9 runs hooks via its `_/pre-push`
1180
+ // wrapper with `sh -e "$hook"`, IGNORING the shebang. On adopters
1181
+ // whose `/bin/sh` is dash (Debian/Ubuntu/CI default), `set -o pipefail`
1182
+ // is not a valid option, so a bash-style `set -euo pipefail` preamble
1183
+ // aborts the hook on line 2 — before the sign block runs — and the
1184
+ // attestation is silently never signed (AISDLC-565). The snippet body
1185
+ // is pure POSIX and never pipes, so `set -eu` under `sh` is correct in
1186
+ // both dash and bash. For the `.git/hooks/pre-push` path git honours
1187
+ // the shebang directly, and `sh` works there too.
1188
+ adapters.writeFile(hookPath, `#!/usr/bin/env sh\nset -eu\n\n${HUSKY_PREPUSH_SIGN_SNIPPET}`);
1189
+ adapters.chmodExecutable(hookPath);
1190
+ result.created.push(hookRelPath);
1191
+ adapters.log(` created ${hookRelPath}`);
1192
+ }
1193
+ catch (err) {
1194
+ const message = err instanceof Error ? err.message : String(err);
1195
+ adapters.log(` REFUSED to install the attestation hook: could not create ${hookRelPath}` +
1196
+ ` (${message}). This usually means a path component (e.g. \`.husky\`) is a` +
1197
+ ` symlink pointing at a target that does not exist. Remove it and re-run.`);
1198
+ result.skipped.push(hookRelPath);
1199
+ }
822
1200
  }
823
1201
  else {
1202
+ // AISDLC-555 round-1 security review: appending lands at EOF, so an
1203
+ // existing hook that ends in a top-level `exit 0` — an extremely common
1204
+ // shape — makes our block permanently unreachable while init happily
1205
+ // reports "appended sign block". Warn rather than silently succeed;
1206
+ // rewriting someone else's hook is not ours to do.
1207
+ const existing = adapters.readTextFile(hookPath) ?? '';
1208
+ if (!existing.includes('# ai-sdlc:attestation-sign-block')) {
1209
+ const hasTopLevelExit = existing
1210
+ .split('\n')
1211
+ // Best-effort: covers `exit 0`, indented, `;`-terminated, commented,
1212
+ // and CRLF (`\s` eats the trailing `\r`). Deliberately does NOT try
1213
+ // to parse shell — `exit $rc`, `exec other-hook` and same-line
1214
+ // `if ...; then exit 0; fi` are known misses. A missed warning is a
1215
+ // worse hook than it could be; a false one would train adopters to
1216
+ // ignore it.
1217
+ .some((line) => /^\s*exit\s+0\s*;?\s*(#.*)?$/.test(line));
1218
+ if (hasTopLevelExit) {
1219
+ adapters.log(` WARNING ${hookRelPath} contains a top-level \`exit 0\` — the appended` +
1220
+ ` sign block will never be reached. Move the block above that line,` +
1221
+ ` or the attestation will silently never be signed.`);
1222
+ }
1223
+ }
824
1224
  const status = adapters.appendOnce(hookPath, HUSKY_PREPUSH_SIGN_SNIPPET, '# ai-sdlc:attestation-sign-block');
1225
+ // AC #2 requires a WORKING hook: even when the hook file pre-existed
1226
+ // (append path), make sure it's executable — an adopter's hand-authored
1227
+ // pre-push script may not have had the bit set, and git silently
1228
+ // never runs a non-executable hook.
1229
+ adapters.chmodExecutable(hookPath);
825
1230
  if (status === 'appended') {
826
- adapters.log(` updated .husky/pre-push (appended sign block)`);
1231
+ adapters.log(` updated ${hookRelPath} (appended sign block)`);
827
1232
  }
828
1233
  else {
829
- result.skipped.push('.husky/pre-push');
830
- adapters.log(` skip .husky/pre-push (sign block already present)`);
1234
+ result.skipped.push(hookRelPath);
1235
+ adapters.log(` skip ${hookRelPath} (sign block already present)`);
831
1236
  }
832
1237
  }
833
1238
  }
834
1239
  else if (selection.attestation && flags.dryRun) {
835
- result.wouldCreate.push('.husky/pre-push');
836
- adapters.log(' would update .husky/pre-push (sign block)');
1240
+ const { relPath: hookRelPath, outsideProject: hookOutsideProject, source: hookSource, } = resolveHookTarget(projectDir, adapters);
1241
+ // Round-4 review (security + code, independently): the preview must mirror
1242
+ // the refusal. Reporting "would update <machine-wide path>" for a run that
1243
+ // will actually refuse is exactly backwards — the point of refusing is to
1244
+ // make this decision legible before anything happens.
1245
+ if (hookOutsideProject &&
1246
+ hookSource === 'core.hooksPath' &&
1247
+ process.env.AI_SDLC_ALLOW_GLOBAL_HOOKS !== '1') {
1248
+ result.skipped.push(hookRelPath);
1249
+ adapters.log(` would REFUSE the attestation hook: core.hooksPath resolves to` +
1250
+ ` ${hookRelPath}, OUTSIDE this project (set AI_SDLC_ALLOW_GLOBAL_HOOKS=1` +
1251
+ ` to install machine-wide anyway).`);
1252
+ }
1253
+ else {
1254
+ result.wouldCreate.push(hookRelPath);
1255
+ adapters.log(` would update ${hookRelPath} (sign block)`);
1256
+ }
837
1257
  }
838
1258
  // Branch protection (always last — depends on the gate workflow being
839
1259
  // present so the required check exists when the rule is applied).