@ai-sdlc/orchestrator 0.14.0 → 0.16.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.
|
@@ -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';
|
|
@@ -609,6 +609,45 @@ export function buildProductionAdapters() {
|
|
|
609
609
|
},
|
|
610
610
|
mkdirp: (path) => mkdirSync(path, { recursive: true }),
|
|
611
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
|
+
},
|
|
612
651
|
runCommand: (cmd, args) => {
|
|
613
652
|
try {
|
|
614
653
|
// Use `execFileSync` (no shell) so args are passed as a true
|
|
@@ -744,6 +783,161 @@ export async function resolveFeatureSelection(flags, adapters) {
|
|
|
744
783
|
}
|
|
745
784
|
return sel;
|
|
746
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
|
+
}
|
|
747
941
|
/**
|
|
748
942
|
* Write the union of feature templates into the project dir. AC #4 says
|
|
749
943
|
* the BASELINE workflow templates (gate workflow) are always written; the
|
|
@@ -819,34 +1013,247 @@ export async function applyFeatureSelection(projectDir, selection, flags, adapte
|
|
|
819
1013
|
}
|
|
820
1014
|
}
|
|
821
1015
|
}
|
|
822
|
-
//
|
|
823
|
-
//
|
|
824
|
-
//
|
|
825
|
-
//
|
|
826
|
-
//
|
|
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`.
|
|
827
1030
|
if (selection.attestation && !flags.dryRun) {
|
|
828
|
-
const hookPath =
|
|
829
|
-
|
|
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)) {
|
|
830
1167
|
// No existing hook — write a minimal one with the sign block.
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
+
}
|
|
835
1200
|
}
|
|
836
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
|
+
}
|
|
837
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);
|
|
838
1230
|
if (status === 'appended') {
|
|
839
|
-
adapters.log(` updated
|
|
1231
|
+
adapters.log(` updated ${hookRelPath} (appended sign block)`);
|
|
840
1232
|
}
|
|
841
1233
|
else {
|
|
842
|
-
result.skipped.push(
|
|
843
|
-
adapters.log(` skip
|
|
1234
|
+
result.skipped.push(hookRelPath);
|
|
1235
|
+
adapters.log(` skip ${hookRelPath} (sign block already present)`);
|
|
844
1236
|
}
|
|
845
1237
|
}
|
|
846
1238
|
}
|
|
847
1239
|
else if (selection.attestation && flags.dryRun) {
|
|
848
|
-
|
|
849
|
-
|
|
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
|
+
}
|
|
850
1257
|
}
|
|
851
1258
|
// Branch protection (always last — depends on the gate workflow being
|
|
852
1259
|
// present so the required check exists when the rule is applied).
|
|
@@ -42,16 +42,56 @@ export declare const AI_SDLC_GATE_WORKFLOW = "name: AI-SDLC PR Ready Gate\n\n# S
|
|
|
42
42
|
*/
|
|
43
43
|
export declare const VERIFY_ATTESTATION_WORKFLOW = "name: AI-SDLC Verify Review Attestation\n\n# Reads the DSSE attestation at .ai-sdlc/attestations/<head-sha>.dsse.json\n# and verifies the signature against any-of-N pubkeys in\n# .ai-sdlc/trusted-reviewers.yaml.\n#\n# AUDIT-ONLY: this workflow logs verification results (success/failure with\n# reason) for forensic purposes but does NOT post a required commit status.\n# The single merge gate is `ai-sdlc/pr-ready` from ai-sdlc-gate.yml.\n\non:\n pull_request:\n types: [opened, synchronize, reopened]\n branches: [main]\n paths-ignore:\n - 'docs/**'\n - '*.md'\n merge_group:\n types: [checks_requested]\n\nconcurrency:\n group: verify-attestation-${{ github.event.pull_request.number || github.event.merge_group.head_sha }}\n cancel-in-progress: true\n\njobs:\n verify:\n name: Verify attestation\n runs-on: ubuntu-latest\n permissions:\n contents: read\n steps:\n - name: Resolve subject SHA + base SHA from event payload\n id: resolve\n run: |\n if [ \"${{ github.event_name }}\" = \"merge_group\" ]; then\n echo \"head_sha=${{ github.event.merge_group.head_sha }}\" >> \"$GITHUB_OUTPUT\"\n echo \"base_sha=${{ github.event.merge_group.base_sha }}\" >> \"$GITHUB_OUTPUT\"\n else\n echo \"head_sha=${{ github.event.pull_request.head.sha }}\" >> \"$GITHUB_OUTPUT\"\n echo \"base_sha=${{ github.event.pull_request.base.sha }}\" >> \"$GITHUB_OUTPUT\"\n fi\n\n - uses: actions/checkout@v4\n with:\n fetch-depth: 0\n ref: ${{ steps.resolve.outputs.head_sha }}\n\n - name: Log audit result\n env:\n HEAD_SHA: ${{ steps.resolve.outputs.head_sha }}\n run: |\n if [ -f \".ai-sdlc/attestations/${HEAD_SHA}.dsse.json\" ]; then\n echo \"::notice::ai-sdlc attestation AUDIT \u2014 envelope present at ${HEAD_SHA}\"\n else\n echo \"::notice::ai-sdlc attestation AUDIT \u2014 no envelope on ${HEAD_SHA} (audit-only, not blocking)\"\n fi\n";
|
|
44
44
|
/**
|
|
45
|
-
* `.husky/pre-push`
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
45
|
+
* `.husky/pre-push` (or `.git/hooks/pre-push` for non-husky repos) snippet
|
|
46
|
+
* that signs an attestation when one is missing for the current HEAD.
|
|
47
|
+
* Installed when `--with-attestation` is opted in.
|
|
48
|
+
*
|
|
49
|
+
* AISDLC-555: pre-fix, this snippet checked ONLY `./scripts/check-attestation-
|
|
50
|
+
* sign.sh` — a path that exists in the ai-sdlc monorepo (where the hook is
|
|
51
|
+
* hand-authored, not wizard-generated) but NEVER in an adopter repo, because
|
|
52
|
+
* nothing ever copied that script there. The `[ -x ... ]` guard silently
|
|
53
|
+
* failed forever, so `--with-attestation` produced a hook that looked
|
|
54
|
+
* complete but never signed anything — the exact bug this task exists to
|
|
55
|
+
* fix. `check-attestation-sign.sh` now also ships under
|
|
56
|
+
* `ai-sdlc-plugin/scripts/` (AISDLC-555), so this snippet resolves it from
|
|
57
|
+
* the PLUGIN INSTALL ONLY: `$CLAUDE_PLUGIN_ROOT` / `$CLAUDE_PLUGIN_DIR` (the
|
|
58
|
+
* zero-config path when `git push` runs inside a Claude Code session), then a
|
|
59
|
+
* read-only plugin-cache probe (bare-terminal `git push`, which never inherits
|
|
60
|
+
* those env vars).
|
|
61
|
+
*
|
|
62
|
+
* There is deliberately NO repo-local tier — see item 2 below. An earlier
|
|
63
|
+
* revision of this docblock described one, which contradicted the code and,
|
|
64
|
+
* worse, advertised a resolution order that was removed for security.
|
|
65
|
+
*
|
|
66
|
+
* Review round 1 (AISDLC-555) — TWO deliberate changes here, both correcting
|
|
67
|
+
* the first version of this fix:
|
|
68
|
+
*
|
|
69
|
+
* 1. **It is no longer silent when nothing resolves.** The original ended in a
|
|
70
|
+
* bare `if [ -n "$HOOK" ]; then bash ...; fi` with no else, so an adopter
|
|
71
|
+
* who installed via `npm i -g @ai-sdlc/orchestrator` (the documented
|
|
72
|
+
* getting-started path) and never installed the Claude Code plugin got a
|
|
73
|
+
* hook that could never fire and never said so — reproducing the exact
|
|
74
|
+
* defect this task exists to close, for a whole adopter persona.
|
|
75
|
+
* `ai-sdlc-plugin/` is not published to npm and orchestrator's `files` is
|
|
76
|
+
* `["dist"]`, so none of the tiers can resolve in that setup.
|
|
77
|
+
*
|
|
78
|
+
* Silence is still correct when there is nothing to sign, so the diagnostic
|
|
79
|
+
* fires only when `.ai-sdlc/verdicts/` is non-empty: reviewers ran, an
|
|
80
|
+
* envelope is owed, and none will be produced. That is the state an
|
|
81
|
+
* operator must never discover months later.
|
|
82
|
+
*
|
|
83
|
+
* 2. **The repo-relative tier was removed.** It previously preferred
|
|
84
|
+
* `./scripts/check-attestation-sign.sh` from the working tree, which put
|
|
85
|
+
* repo-tracked content on the push-time execution path with the operator's
|
|
86
|
+
* Ed25519 signing key in scope — a contributor could land that file and
|
|
87
|
+
* have it run as the maintainer on their next push. Resolution is now only
|
|
88
|
+
* from the plugin install (env vars, then the read-only cache probe).
|
|
49
89
|
*
|
|
50
90
|
* Adopters typically already have a `.husky/pre-push` from their existing
|
|
51
91
|
* tooling; the wizard appends our snippet behind a sentinel so we can
|
|
52
92
|
* extend an existing hook without trampling user content.
|
|
53
93
|
*/
|
|
54
|
-
export declare const HUSKY_PREPUSH_SIGN_SNIPPET = "# ai-sdlc:attestation-sign-block\n# Signs the DSSE attestation envelope for the current HEAD when verdict\n# files exist. Skip with AI_SDLC_SKIP_ATTESTATION_SIGN=1.\nif [ -z \"${AI_SDLC_SKIP_ATTESTATION_SIGN:-}\" ] && [ -
|
|
94
|
+
export declare const HUSKY_PREPUSH_SIGN_SNIPPET = "# ai-sdlc:attestation-sign-block\n# Signs the DSSE attestation envelope for the current HEAD when verdict\n# files exist. Skip with AI_SDLC_SKIP_ATTESTATION_SIGN=1.\nif [ -z \"${AI_SDLC_SKIP_ATTESTATION_SIGN:-}\" ]; then\n AI_SDLC_ATTESTATION_HOOK=\"\"\n if [ -n \"${CLAUDE_PLUGIN_ROOT:-}\" ] && [ -f \"${CLAUDE_PLUGIN_ROOT}/scripts/check-attestation-sign.sh\" ]; then\n AI_SDLC_ATTESTATION_HOOK=\"${CLAUDE_PLUGIN_ROOT}/scripts/check-attestation-sign.sh\"\n elif [ -n \"${CLAUDE_PLUGIN_DIR:-}\" ] && [ -f \"${CLAUDE_PLUGIN_DIR}/scripts/check-attestation-sign.sh\" ]; then\n AI_SDLC_ATTESTATION_HOOK=\"${CLAUDE_PLUGIN_DIR}/scripts/check-attestation-sign.sh\"\n else\n for _ai_sdlc_dir in \"$HOME\"/.claude/plugins/cache/*/ai-sdlc/*/; do\n if [ -f \"${_ai_sdlc_dir}scripts/check-attestation-sign.sh\" ]; then\n AI_SDLC_ATTESTATION_HOOK=\"${_ai_sdlc_dir}scripts/check-attestation-sign.sh\"\n break\n fi\n done\n fi\n if [ -n \"$AI_SDLC_ATTESTATION_HOOK\" ]; then\n echo \"[ai-sdlc] attestation signer: $AI_SDLC_ATTESTATION_HOOK\" >&2\n bash \"$AI_SDLC_ATTESTATION_HOOK\"\n elif [ -n \"$(ls -A .ai-sdlc/verdicts 2>/dev/null)\" ]; then\n echo \"[ai-sdlc] ERROR: reviewer verdicts exist under .ai-sdlc/verdicts/ but NO attestation signer\" >&2\n echo \"[ai-sdlc] could be found \u2014 this push will carry no attestation.\" >&2\n echo \"[ai-sdlc] Searched CLAUDE_PLUGIN_ROOT, CLAUDE_PLUGIN_DIR, and\" >&2\n echo \"[ai-sdlc] ~/.claude/plugins/cache/*/ai-sdlc/*/scripts/check-attestation-sign.sh\" >&2\n echo \"[ai-sdlc] Install the ai-sdlc Claude Code plugin, or set CLAUDE_PLUGIN_ROOT.\" >&2\n fi\nfi\n# end ai-sdlc:attestation-sign-block\n";
|
|
55
95
|
/**
|
|
56
96
|
* `.ai-sdlc/trusted-reviewers.yaml` stub — empty allowlist with operator
|
|
57
97
|
* instructions. The wizard scaffolds this so adopters have a single file
|
|
@@ -207,10 +207,50 @@ jobs:
|
|
|
207
207
|
fi
|
|
208
208
|
`;
|
|
209
209
|
/**
|
|
210
|
-
* `.husky/pre-push`
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
210
|
+
* `.husky/pre-push` (or `.git/hooks/pre-push` for non-husky repos) snippet
|
|
211
|
+
* that signs an attestation when one is missing for the current HEAD.
|
|
212
|
+
* Installed when `--with-attestation` is opted in.
|
|
213
|
+
*
|
|
214
|
+
* AISDLC-555: pre-fix, this snippet checked ONLY `./scripts/check-attestation-
|
|
215
|
+
* sign.sh` — a path that exists in the ai-sdlc monorepo (where the hook is
|
|
216
|
+
* hand-authored, not wizard-generated) but NEVER in an adopter repo, because
|
|
217
|
+
* nothing ever copied that script there. The `[ -x ... ]` guard silently
|
|
218
|
+
* failed forever, so `--with-attestation` produced a hook that looked
|
|
219
|
+
* complete but never signed anything — the exact bug this task exists to
|
|
220
|
+
* fix. `check-attestation-sign.sh` now also ships under
|
|
221
|
+
* `ai-sdlc-plugin/scripts/` (AISDLC-555), so this snippet resolves it from
|
|
222
|
+
* the PLUGIN INSTALL ONLY: `$CLAUDE_PLUGIN_ROOT` / `$CLAUDE_PLUGIN_DIR` (the
|
|
223
|
+
* zero-config path when `git push` runs inside a Claude Code session), then a
|
|
224
|
+
* read-only plugin-cache probe (bare-terminal `git push`, which never inherits
|
|
225
|
+
* those env vars).
|
|
226
|
+
*
|
|
227
|
+
* There is deliberately NO repo-local tier — see item 2 below. An earlier
|
|
228
|
+
* revision of this docblock described one, which contradicted the code and,
|
|
229
|
+
* worse, advertised a resolution order that was removed for security.
|
|
230
|
+
*
|
|
231
|
+
* Review round 1 (AISDLC-555) — TWO deliberate changes here, both correcting
|
|
232
|
+
* the first version of this fix:
|
|
233
|
+
*
|
|
234
|
+
* 1. **It is no longer silent when nothing resolves.** The original ended in a
|
|
235
|
+
* bare `if [ -n "$HOOK" ]; then bash ...; fi` with no else, so an adopter
|
|
236
|
+
* who installed via `npm i -g @ai-sdlc/orchestrator` (the documented
|
|
237
|
+
* getting-started path) and never installed the Claude Code plugin got a
|
|
238
|
+
* hook that could never fire and never said so — reproducing the exact
|
|
239
|
+
* defect this task exists to close, for a whole adopter persona.
|
|
240
|
+
* `ai-sdlc-plugin/` is not published to npm and orchestrator's `files` is
|
|
241
|
+
* `["dist"]`, so none of the tiers can resolve in that setup.
|
|
242
|
+
*
|
|
243
|
+
* Silence is still correct when there is nothing to sign, so the diagnostic
|
|
244
|
+
* fires only when `.ai-sdlc/verdicts/` is non-empty: reviewers ran, an
|
|
245
|
+
* envelope is owed, and none will be produced. That is the state an
|
|
246
|
+
* operator must never discover months later.
|
|
247
|
+
*
|
|
248
|
+
* 2. **The repo-relative tier was removed.** It previously preferred
|
|
249
|
+
* `./scripts/check-attestation-sign.sh` from the working tree, which put
|
|
250
|
+
* repo-tracked content on the push-time execution path with the operator's
|
|
251
|
+
* Ed25519 signing key in scope — a contributor could land that file and
|
|
252
|
+
* have it run as the maintainer on their next push. Resolution is now only
|
|
253
|
+
* from the plugin install (env vars, then the read-only cache probe).
|
|
214
254
|
*
|
|
215
255
|
* Adopters typically already have a `.husky/pre-push` from their existing
|
|
216
256
|
* tooling; the wizard appends our snippet behind a sentinel so we can
|
|
@@ -219,8 +259,30 @@ jobs:
|
|
|
219
259
|
export const HUSKY_PREPUSH_SIGN_SNIPPET = `# ai-sdlc:attestation-sign-block
|
|
220
260
|
# Signs the DSSE attestation envelope for the current HEAD when verdict
|
|
221
261
|
# files exist. Skip with AI_SDLC_SKIP_ATTESTATION_SIGN=1.
|
|
222
|
-
if [ -z "\${AI_SDLC_SKIP_ATTESTATION_SIGN:-}" ]
|
|
223
|
-
|
|
262
|
+
if [ -z "\${AI_SDLC_SKIP_ATTESTATION_SIGN:-}" ]; then
|
|
263
|
+
AI_SDLC_ATTESTATION_HOOK=""
|
|
264
|
+
if [ -n "\${CLAUDE_PLUGIN_ROOT:-}" ] && [ -f "\${CLAUDE_PLUGIN_ROOT}/scripts/check-attestation-sign.sh" ]; then
|
|
265
|
+
AI_SDLC_ATTESTATION_HOOK="\${CLAUDE_PLUGIN_ROOT}/scripts/check-attestation-sign.sh"
|
|
266
|
+
elif [ -n "\${CLAUDE_PLUGIN_DIR:-}" ] && [ -f "\${CLAUDE_PLUGIN_DIR}/scripts/check-attestation-sign.sh" ]; then
|
|
267
|
+
AI_SDLC_ATTESTATION_HOOK="\${CLAUDE_PLUGIN_DIR}/scripts/check-attestation-sign.sh"
|
|
268
|
+
else
|
|
269
|
+
for _ai_sdlc_dir in "$HOME"/.claude/plugins/cache/*/ai-sdlc/*/; do
|
|
270
|
+
if [ -f "\${_ai_sdlc_dir}scripts/check-attestation-sign.sh" ]; then
|
|
271
|
+
AI_SDLC_ATTESTATION_HOOK="\${_ai_sdlc_dir}scripts/check-attestation-sign.sh"
|
|
272
|
+
break
|
|
273
|
+
fi
|
|
274
|
+
done
|
|
275
|
+
fi
|
|
276
|
+
if [ -n "$AI_SDLC_ATTESTATION_HOOK" ]; then
|
|
277
|
+
echo "[ai-sdlc] attestation signer: $AI_SDLC_ATTESTATION_HOOK" >&2
|
|
278
|
+
bash "$AI_SDLC_ATTESTATION_HOOK"
|
|
279
|
+
elif [ -n "$(ls -A .ai-sdlc/verdicts 2>/dev/null)" ]; then
|
|
280
|
+
echo "[ai-sdlc] ERROR: reviewer verdicts exist under .ai-sdlc/verdicts/ but NO attestation signer" >&2
|
|
281
|
+
echo "[ai-sdlc] could be found — this push will carry no attestation." >&2
|
|
282
|
+
echo "[ai-sdlc] Searched CLAUDE_PLUGIN_ROOT, CLAUDE_PLUGIN_DIR, and" >&2
|
|
283
|
+
echo "[ai-sdlc] ~/.claude/plugins/cache/*/ai-sdlc/*/scripts/check-attestation-sign.sh" >&2
|
|
284
|
+
echo "[ai-sdlc] Install the ai-sdlc Claude Code plugin, or set CLAUDE_PLUGIN_ROOT." >&2
|
|
285
|
+
fi
|
|
224
286
|
fi
|
|
225
287
|
# end ai-sdlc:attestation-sign-block
|
|
226
288
|
`;
|
|
@@ -857,6 +919,18 @@ export const ATTESTATION_TEMPLATES = {
|
|
|
857
919
|
// first PR's envelope lands cleanly without "directory does not exist"
|
|
858
920
|
// errors from the signing script.
|
|
859
921
|
'.ai-sdlc/attestations/.gitkeep': '',
|
|
922
|
+
// AISDLC-555 (partial AC #7): the pre-push hook's gate condition reads
|
|
923
|
+
// `.ai-sdlc/verdicts/<task-id>.json` — tightly coupled to attestation,
|
|
924
|
+
// so it's scaffolded here rather than deferred to a separate
|
|
925
|
+
// init-orchestration task. Dispatch Board directories + dispatch-
|
|
926
|
+
// config.yaml (the rest of the widened AC #7 scope) are NOT scaffolded
|
|
927
|
+
// here, and — confirmed by round-2 review — NO backlog task currently
|
|
928
|
+
// owns them: nothing under `backlog/tasks/` mentions dispatch-config.yaml.
|
|
929
|
+
// An earlier revision of this comment cited AISDLC-560; that task covers
|
|
930
|
+
// attestation enforcement/doctor and says nothing about the Dispatch
|
|
931
|
+
// Board, so the citation was wrong. Left explicitly unowned rather than
|
|
932
|
+
// pointed at a task that would not deliver it.
|
|
933
|
+
'.ai-sdlc/verdicts/.gitkeep': '',
|
|
860
934
|
},
|
|
861
935
|
};
|
|
862
936
|
export const CLASSIFIER_TEMPLATES = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-sdlc/orchestrator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "AI-SDLC Orchestrator — long-running runtime that drives issues through the complete SDLC with AI agents",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -37,6 +37,10 @@
|
|
|
37
37
|
"types": "./dist/state/index.d.ts",
|
|
38
38
|
"import": "./dist/state/index.js"
|
|
39
39
|
},
|
|
40
|
+
"./runtime": {
|
|
41
|
+
"types": "./dist/runtime/index.d.ts",
|
|
42
|
+
"import": "./dist/runtime/index.js"
|
|
43
|
+
},
|
|
40
44
|
"./cli": {
|
|
41
45
|
"types": "./dist/cli/index.d.ts",
|
|
42
46
|
"import": "./dist/cli/index.js"
|
|
@@ -48,7 +52,7 @@
|
|
|
48
52
|
"commander": "^15.0.0",
|
|
49
53
|
"franc": "^6.2.0",
|
|
50
54
|
"yaml": "^2.9.0",
|
|
51
|
-
"@ai-sdlc/reference": "0.
|
|
55
|
+
"@ai-sdlc/reference": "0.16.0"
|
|
52
56
|
},
|
|
53
57
|
"devDependencies": {
|
|
54
58
|
"@types/better-sqlite3": "^7.6.0",
|