phasegate 0.212.0 → 0.222.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.
Files changed (18) hide show
  1. package/CHANGELOG.md +6 -11
  2. package/docs/ADR/030-injection-threat-model-and-trust-root.md +1 -1
  3. package/docs/guide/hooks-integration.md +53 -2
  4. package/package.json +1 -1
  5. package/scripts/harness/biome-ast-engine/application/mappers/build-harness-error-payload-output-mapper.ts +3 -3
  6. package/scripts/harness/biome-ast-engine/infrastructure/mappers/rule-violation-code-mapper.ts +3 -3
  7. package/scripts/harness/biome-ast-engine/presentation/cli/harness-lint-command-handler.ts +6 -4
  8. package/scripts/harness/config-foundation/application/mappers/validator-system-config-mapper.ts +10 -3
  9. package/scripts/harness/harness-error/infrastructure/registry/l1-error-definitions.ts +9 -9
  10. package/scripts/harness/installation/application/usecases/run-reconcile.ts +69 -1
  11. package/scripts/harness/validator-system/application/use-cases/run-l3-validators-usecase.ts +51 -0
  12. package/scripts/harness/validator-system/composition-root.ts +13 -1
  13. package/scripts/harness/validator-system/domain/ports/coverage-attestation-verification-policy-port.ts +30 -0
  14. package/scripts/harness/validator-system/domain/services/coverage-attestation-verification-service.ts +45 -0
  15. package/scripts/harness/validator-system/domain/value-objects/attestation-verification-report.ts +59 -0
  16. package/scripts/harness/validator-system/domain/value-objects/validator-id.ts +2 -0
  17. package/scripts/harness/validator-system/infrastructure/adapters/file-system-coverage-attestation-verification-adapter.ts +165 -0
  18. package/templates/.claude/scripts/deny-check.sh +260 -0
package/CHANGELOG.md CHANGED
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Added
11
+
12
+ - **WI-264 — `reconcile` prunes bundled skills that left the catalog** — `phasegate reconcile` (and its `update-skills` alias) now detects **manifest-managed** skills that are no longer in the current bundle catalog (`getBundledSkillsForSet("all")`) and prunes them: the on-disk skill directory is removed and the manifest entry is dropped. This closes the WI-256 orphan gap — the removed `implementation-planner` / `doc-freshness-checker` / `pointer-validator` directories are now cleaned up automatically on the next `reconcile --apply` (shared installs under `skills/`, personal installs under `.claude/skills/` and `.codex/skills/`). Pruning is **manifest-scoped**: user-owned skill directories that PhaseGate never recorded are never touched, and `.harness-version` is never pruned. `--dry-run` reports each prune (`action: "prune"`) without mutating disk or manifest, and the operation is idempotent.
13
+
10
14
  ### Changed
11
15
 
12
16
  - **WI-256 — skill catalog 30 → 29 (BREAKING for skill consumers)** — the bundled skill catalog is reshaped. **Removed** `implementation-planner`, `doc-freshness-checker`, and `pointer-validator`. **Added** `doc-health-checker` (core / Verification, `kind: advisory`) and `release-publisher` (guidance / Operations, `kind: advisory`). Net advisory count 7 → 8, lifecycle 23 → 21, total 30 → 29. Both catalog sources (`skill-deployer.ts` `SKILL_CATEGORIES`, `bundled-skill-selection.ts`) and all count phrases / category headings were updated in the same batch.
@@ -14,18 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
14
18
  - Doc-quality checks are now driven by **`doc-health-checker`**, which wraps the correct `p2:`-prefixed CLI commands: `npx phasegate p2:check-freshness` (freshness / code-design drift, L4-004) and `npx phasegate p2:validate-pointers` (broken file-path pointers, L4-005; supported flags are `--pattern` / `--include-urls` / `--format` — there is no auto-fix flag). The removed skills documented the wrong unprefixed command names (`phasegate check-freshness` / `phasegate validate-pointers`); those are corrected here.
15
19
  - **Note**: `doc-freshness-checker` and `pointer-validator` also exist as **L4 validator IDs** (config presets, `validator-id.ts`, `ci-governance`). Those are a different concept and are **not** affected by this change — only the `skills/` directories were removed.
16
20
 
17
- #### Migration (manual removal required in consumer repos)
18
-
19
- `reconcile` does **not** prune skills that have left the bundle, so the three removed skill directories will remain as orphans in already-installed projects. Remove them manually:
20
-
21
- ```bash
22
- rm -r skills/implementation-planner skills/doc-freshness-checker skills/pointer-validator
23
- # If personal-mode per-agent copies exist:
24
- rm -r .claude/skills/{implementation-planner,doc-freshness-checker,pointer-validator}
25
- rm -r .codex/skills/{implementation-planner,doc-freshness-checker,pointer-validator}
26
- ```
21
+ #### Migration (consumer repos)
27
22
 
28
- A permanent `reconcile`-based prune is deferred to a follow-up WI.
23
+ As of WI-264 (see **Added** above), `reconcile` prunes the three removed skill directories automatically — run `phasegate reconcile --apply` (or the `update-skills` alias) in each already-installed project and the orphaned `implementation-planner` / `doc-freshness-checker` / `pointer-validator` directories are removed from `skills/` (and, for personal installs, `.claude/skills/` / `.codex/skills/`) along with their manifest entries. Use `--dry-run` first to preview. Manual `rm` is no longer required.
29
24
 
30
25
  ## [0.171.0] - 2026-07-05
31
26
 
@@ -69,7 +69,7 @@ L0–L2 は **fast-path** として正直に位置づける。fast-path の価
69
69
 
70
70
  指示を搭載するファイル群の SHA-256 を `phasegate.integrity.json` に pin する。対象:
71
71
 
72
- - SKILL.md × 30
72
+ - SKILL.md × 29
73
73
  - `CLAUDE.md` / `AGENTS.md` テンプレート
74
74
  - hook 定義
75
75
  - `deny-check.sh`
@@ -155,13 +155,64 @@ The permitted set lives in `GIT_ALLOWED_SUBCOMMANDS` in `.claude/scripts/deny-ch
155
155
  ```
156
156
  status log show diff add commit tag restore rev-parse rev-list
157
157
  merge-base branch worktree fetch grep cat-file ls-files ls-tree
158
- ls-remote config init remote describe blame shortlog
159
- symbolic-ref for-each-ref name-rev check-ignore check-attr
158
+ ls-remote init remote describe blame shortlog
159
+ for-each-ref name-rev check-ignore check-attr
160
160
  stripspace var help version whatchanged push
161
161
  ```
162
162
 
163
163
  History- and working-tree-mutating subcommands are intentionally **absent** so they fail closed, including: `checkout`, `switch`, `reset`, `rebase`, `merge`, `cherry-pick`, `revert`, `stash`, `clean`, `update-ref`, `reflog`, `filter-branch`, `replace`, and `am`.
164
164
 
165
+ ### `symbolic-ref`: read allowed, write denied (WI-269)
166
+
167
+ <!-- @work-item-id WI-269 -->
168
+
169
+ `symbolic-ref` is **not** on the plain allowlist above; it is adjudicated by a
170
+ dedicated guard (`check_symbolic_ref`), because it has both a read form and a
171
+ state-mutating write form:
172
+
173
+ - **Read (allowed)** — reports the ref that HEAD points at without changing
174
+ anything: `git symbolic-ref HEAD`, `git symbolic-ref --short HEAD`,
175
+ `git symbolic-ref -q HEAD`.
176
+ - **Write (denied)** — re-points HEAD, which is **checkout-equivalent HEAD
177
+ mutation**: `git symbolic-ref HEAD refs/heads/<branch>` (a second positional
178
+ argument), `git symbolic-ref -d HEAD` / `--delete` (removes the symbolic ref),
179
+ and `-m <reason>` write variants.
180
+
181
+ The guard tolerates the same global-flag stuffing as the allowlist extractor, so
182
+ `git -C <path> symbolic-ref HEAD refs/heads/x` is still denied, and write forms
183
+ smuggled behind chaining/substitution operators are caught by per-segment
184
+ inspection. This closes the same class of hole as the `git switch` leak that
185
+ motivated the allowlist (WI-253): before this guard, `symbolic-ref` sat on the
186
+ allowlist and its write form re-pointed HEAD exactly like a `checkout`.
187
+
188
+ ### `config`: read allowed, write denied (WI-271)
189
+
190
+ <!-- @work-item-id WI-271 -->
191
+
192
+ `config` is likewise **not** on the plain allowlist; it is adjudicated by a
193
+ dedicated guard (`check_git_config`). The write form is strictly worse than the
194
+ `symbolic-ref` hole it follows: `git config core.hooksPath <dir>` re-points the
195
+ hook path itself, which would disable the **entire L0 defence layer** (this very
196
+ hook included).
197
+
198
+ - **Read (allowed)** — `git config --get <key>`, `--get-all`, `--get-regexp`,
199
+ `--get-urlmatch`, `--list` / `-l`, and the bare value read
200
+ `git config <key>` (one positional argument, no value). Read forms combined
201
+ with scope flags are legitimate and pass: `git config --global --list`,
202
+ `git config --local --get user.name`.
203
+ - **Write (denied)** — `git config <key> <value>` (two positional arguments),
204
+ `--unset` / `--unset-all`, `--add`, `--replace-all`, `--edit` / `-e`,
205
+ `--remove-section`, `--rename-section`, and the new-style verb subcommands
206
+ (`git config set|unset|edit|rename-section|remove-section ...`, git >= 2.46).
207
+
208
+ Ambiguous invocations fail closed: anything that is not a recognized read form
209
+ and carries two or more positionals is denied, so the new-style
210
+ `git config get <key>` spelling is (conservatively) denied — use the flag form
211
+ `git config --get <key>` instead. The guard tolerates the same global-flag
212
+ stuffing as the allowlist extractor (`git -C <path> config <key> <value>` is
213
+ still denied) and chained/substituted write forms are caught by per-segment
214
+ inspection.
215
+
165
216
  ### How the subcommand is extracted
166
217
 
167
218
  The check tolerates global options placed before the subcommand, so evasion via flag stuffing does not bypass it. All of the following resolve to their real subcommand (`merge`, denied):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phasegate",
3
- "version": "0.212.0",
3
+ "version": "0.222.0",
4
4
  "packageManager": "pnpm@10.30.1",
5
5
  "description": "Phasegate — AI-agnostic quality defense toolkit. Enforces structural integrity between design intent and code.",
6
6
  "license": "MIT",
@@ -24,9 +24,9 @@ const RULE_CODE_BY_NAME: Readonly<Record<string, string>> = Object.freeze({
24
24
  'no-layer-violation': 'L1-003',
25
25
  'enforce-folder-structure': 'L1-004',
26
26
  'no-any-abuse': 'L1-005',
27
- 'no-ghost-file': 'L1-006',
28
- 'no-comment-flood': 'L1-007',
29
- 'no-code-duplication': 'L1-008',
27
+ 'no-code-duplication': 'L1-006',
28
+ 'no-ghost-file': 'L1-007',
29
+ 'no-comment-flood': 'L1-008',
30
30
  });
31
31
 
32
32
  const toHarnessErrorPayloadItem = (
@@ -11,9 +11,9 @@ const RULE_TO_CODE: Readonly<Record<RuleNameValue, string>> = {
11
11
  'no-layer-violation': 'L1-003',
12
12
  'enforce-folder-structure': 'L1-004',
13
13
  'no-any-abuse': 'L1-005',
14
- 'no-ghost-file': 'L1-006',
15
- 'no-comment-flood': 'L1-007',
16
- 'no-code-duplication': 'L1-008',
14
+ 'no-code-duplication': 'L1-006',
15
+ 'no-ghost-file': 'L1-007',
16
+ 'no-comment-flood': 'L1-008',
17
17
  };
18
18
 
19
19
  export class UnknownRuleCodeMappingError extends Error {
@@ -104,13 +104,15 @@ export class HarnessLintCommandHandler {
104
104
  if (errorPayload.errors.length === 0) {
105
105
  lines.push('No violations found');
106
106
  } else {
107
- lines.push(`${errorPayload.errors.length} violation(s):`);
108
- const shown = errorPayload.errors.slice(0, 3);
107
+ const total = errorPayload.errors.length;
108
+ lines.push(`${total} violation(s):`);
109
+ const displayLimit = 3;
110
+ const shown = errorPayload.errors.slice(0, displayLimit);
109
111
  for (const e of shown) {
110
112
  lines.push(` [${e.severity}] ${e.code}: ${e.message}`);
111
113
  }
112
- if (errorPayload.errors.length > 3) {
113
- lines.push(` ... and ${errorPayload.errors.length - 3} more`);
114
+ if (total > displayLimit) {
115
+ lines.push(` ... and ${total - displayLimit} more (shown ${shown.length} of ${total})`);
114
116
  }
115
117
  }
116
118
 
@@ -6,6 +6,7 @@
6
6
  * @work-item-id WI-212
7
7
  * @work-item-id WI-258
8
8
  * @work-item-id WI-259
9
+ * @work-item-id WI-268
9
10
  */
10
11
  import type { HarnessConfigV2 } from "../../domain/harness-config.js";
11
12
 
@@ -21,6 +22,7 @@ export function toValidatorSystemConfig(resolvedConfig: HarnessConfigV2 | undefi
21
22
  nyquist: "L3-004",
22
23
  "ac-bound-coverage": "L3-005",
23
24
  "injection-scan": "L3-006",
25
+ "coverage-attestation-verification": "L3-007",
24
26
  },
25
27
  /^L3-\d{3}$/,
26
28
  );
@@ -63,10 +65,15 @@ export function toValidatorSystemConfig(resolvedConfig: HarnessConfigV2 | undefi
63
65
  L3: {
64
66
  enabled: resolvedConfig.layers.L3.enabled,
65
67
  // WI-259 / ADR-030 §Decision.3.④: L3-006 (injection-scan) は advisory default-ON。
66
- // fallback 判定の後に force-include し、normalize 結果でも fallback でも常に含める(warning-only ゆえ安全)。
68
+ // WI-268 / ADR-030 §Decision.1・§Decision.3.② 第2段: L3-007 (coverage-attestation-verification)
69
+ // は fail-closed default-ON。いずれも fallback 判定の後に force-include し、normalize 結果でも
70
+ // fallback でも常に含める(L3-007 は fail-closed だが現 corpus は実参照 0 件ゆえ緑)。
67
71
  validators: includeValidator(
68
- l3Validators.length > 0 ? l3Validators : ["L3-001", "L3-002", "L3-003", "L3-004"],
69
- "L3-006",
72
+ includeValidator(
73
+ l3Validators.length > 0 ? l3Validators : ["L3-001", "L3-002", "L3-003", "L3-004"],
74
+ "L3-006",
75
+ ),
76
+ "L3-007",
70
77
  ),
71
78
  coverageThreshold: resolvedConfig.layers.L3.coverageThreshold,
72
79
  requirementMatrixPath: resolvedConfig.layers.L3.requirementMatrixPath,
@@ -67,24 +67,24 @@ export const L1_ERROR_DEFINITIONS = Object.freeze([
67
67
  }),
68
68
  createDefinition({
69
69
  code: 'L1-006',
70
- title: '参照されないファイルが残っている',
70
+ title: '重複コードが検出された',
71
71
  category: 'architecture',
72
72
  ownerValidatorId: 'architecture',
73
- defaultFixExample: 'export const actual = "shared";',
73
+ defaultFixExample:
74
+ 'function buildValue(): string { return "shared"; }\nconst actual = buildValue();',
74
75
  }),
75
76
  createDefinition({
76
77
  code: 'L1-007',
77
- title: 'コメント量が過剰である',
78
- category: 'quality',
78
+ title: '参照されないファイルが残っている',
79
+ category: 'architecture',
79
80
  ownerValidatorId: 'architecture',
80
- defaultFixExample: 'const actual = "keep comments concise";',
81
+ defaultFixExample: 'export const actual = "shared";',
81
82
  }),
82
83
  createDefinition({
83
84
  code: 'L1-008',
84
- title: '重複コードが検出された',
85
- category: 'architecture',
85
+ title: 'コメント量が過剰である',
86
+ category: 'quality',
86
87
  ownerValidatorId: 'architecture',
87
- defaultFixExample:
88
- 'function buildValue(): string { return "shared"; }\nconst actual = buildValue();',
88
+ defaultFixExample: 'const actual = "keep comments concise";',
89
89
  }),
90
90
  ]);
@@ -6,6 +6,7 @@
6
6
  // @work-item-id WI-210
7
7
  // @work-item-id WI-216
8
8
  // @work-item-id WI-219
9
+ // @work-item-id WI-264
9
10
 
10
11
  import { access, chmod, copyFile, lstat, mkdir, readFile, readlink, readdir, rm, symlink, writeFile } from "node:fs/promises";
11
12
  import { dirname, join, relative, resolve } from "node:path";
@@ -18,7 +19,7 @@ import type { HashCalculatorPort } from "../ports/hash-calculator-port.js";
18
19
  import type { ManifestRepositoryPort } from "../ports/manifest-repository-port.js";
19
20
  import type { ModelDelegationPort } from "../ports/model-delegation-port.js";
20
21
 
21
- type ReconcileAction = "missing-manifest" | "update" | "add" | "link" | "skip" | "refuse";
22
+ type ReconcileAction = "missing-manifest" | "update" | "add" | "link" | "skip" | "refuse" | "prune";
22
23
  type StrategyType = "json" | "shell" | "yaml-add" | "package-json" | "markdown-managed" | "copy-dir" | "symlink" | "unknown";
23
24
 
24
25
  export interface ReconcilePlanItem {
@@ -62,6 +63,8 @@ const SHELL_END = "# === phasegate managed (END) ===";
62
63
  const MARKDOWN_BEGIN = "<!-- phasegate:managed-section:start -->";
63
64
  const MARKDOWN_END = "<!-- phasegate:managed-section:end -->";
64
65
  const SHARED_SKILLS_VERSION_PATH = "skills/.harness-version";
66
+ const HARNESS_VERSION_BASENAME = ".harness-version";
67
+ const SKILL_ROOT_PREFIXES = ["skills", ".claude/skills", ".codex/skills"] as const;
65
68
 
66
69
  function isRecord(value: unknown): value is Record<string, unknown> {
67
70
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -302,6 +305,7 @@ export class RunReconcileUseCase {
302
305
  const outcomes: Array<{
303
306
  readonly item: ReconcilePlanItem;
304
307
  readonly needsBackup: boolean;
308
+ readonly prune?: boolean;
305
309
  readonly apply: () => Promise<string | null>;
306
310
  }> = [];
307
311
 
@@ -346,12 +350,23 @@ export class RunReconcileUseCase {
346
350
  }
347
351
  }
348
352
 
353
+ for (const outcome of this.planOrphanSkills(input, manifest)) {
354
+ outcomes.push(outcome);
355
+ plan.push(outcome.item);
356
+ }
357
+
349
358
  if (!input.apply || refused.length > 0) {
350
359
  return { plan, refused, changed, backupDir: null };
351
360
  }
352
361
 
353
362
  for (const outcome of outcomes) {
354
363
  if (!outcome.item.changed) continue;
364
+ if (outcome.prune === true) {
365
+ await outcome.apply();
366
+ nextManifest = nextManifest.removeEntry(outcome.item.path);
367
+ changed.push(outcome.item);
368
+ continue;
369
+ }
355
370
  if (outcome.needsBackup) {
356
371
  backupDir ??= join(input.projectRoot, ".phasegate", "backups", backupStamp);
357
372
  await this.backup(input.projectRoot, outcome.item.path, backupDir);
@@ -616,6 +631,59 @@ export class RunReconcileUseCase {
616
631
  };
617
632
  }
618
633
 
634
+ private planOrphanSkills(input: RunReconcileInput, manifest: DeploymentManifest): Array<{
635
+ readonly item: ReconcilePlanItem;
636
+ readonly needsBackup: boolean;
637
+ readonly prune: boolean;
638
+ readonly apply: () => Promise<string | null>;
639
+ }> {
640
+ const allowed = new Set(getBundledSkillsForSet("all"));
641
+ const outcomes: Array<{
642
+ readonly item: ReconcilePlanItem;
643
+ readonly needsBackup: boolean;
644
+ readonly prune: boolean;
645
+ readonly apply: () => Promise<string | null>;
646
+ }> = [];
647
+ for (const entry of manifest.entries) {
648
+ if (entry.mode !== "created") continue;
649
+ const skillName = this.orphanSkillName(entry.path, allowed);
650
+ if (skillName === null) continue;
651
+ const absolutePath = this.resolveProjectPath(input.projectRoot, entry.path);
652
+ outcomes.push({
653
+ item: this.item(
654
+ entry.path,
655
+ "prune",
656
+ "mechanical",
657
+ "copy-dir",
658
+ true,
659
+ `${entry.path}: prune orphan skill (not in current bundle)`,
660
+ "- skill directory",
661
+ null,
662
+ ),
663
+ needsBackup: false,
664
+ prune: true,
665
+ apply: async () => {
666
+ await rm(absolutePath, { recursive: true, force: true });
667
+ return null;
668
+ },
669
+ });
670
+ }
671
+ return outcomes;
672
+ }
673
+
674
+ private orphanSkillName(entryPath: string, allowed: ReadonlySet<string>): string | null {
675
+ for (const prefix of SKILL_ROOT_PREFIXES) {
676
+ const marker = `${prefix}/`;
677
+ if (!entryPath.startsWith(marker)) continue;
678
+ const remainder = entryPath.slice(marker.length);
679
+ if (remainder.length === 0 || remainder.includes("/")) return null;
680
+ if (remainder === HARNESS_VERSION_BASENAME) return null;
681
+ if (allowed.has(remainder)) return null;
682
+ return remainder;
683
+ }
684
+ return null;
685
+ }
686
+
619
687
  private createdEntry(path: string, hashInput: string): DeploymentEntry {
620
688
  return DeploymentEntry.create({
621
689
  path,
@@ -8,10 +8,12 @@
8
8
 
9
9
  import type { AcBoundCoveragePolicyPort } from "../../domain/ports/ac-bound-coverage-policy-port.js";
10
10
  import type { AcCoveragePolicyPort } from "../../domain/ports/ac-coverage-policy-port.js";
11
+ import type { CoverageAttestationVerificationPolicyPort } from "../../domain/ports/coverage-attestation-verification-policy-port.js";
11
12
  import type { InjectionScanPolicyPort } from "../../domain/ports/injection-scan-policy-port.js";
12
13
  import type { PerformanceScannerPort } from "../../domain/ports/performance-scanner-port.js";
13
14
  import type { SecurityPatternScannerPort } from "../../domain/ports/security-pattern-scanner-port.js";
14
15
  import type { ValidatorConfigPort } from "../../domain/ports/validator-config-port.js";
16
+ import { CoverageAttestationVerificationService } from "../../domain/services/coverage-attestation-verification-service.js";
15
17
  import { InjectionPatternScanService } from "../../domain/services/injection-pattern-scan-service.js";
16
18
  import {
17
19
  ValidatorExecutionError,
@@ -50,6 +52,8 @@ export interface RunL3ValidatorsUseCaseDeps {
50
52
  performanceScannerPort?: PerformanceScannerPort;
51
53
  /** WI-259 / ADR-030 §Decision.3.④: L3-006 (injection-scan, advisory) 用ポート。 */
52
54
  injectionScanPolicyPort?: InjectionScanPolicyPort;
55
+ /** WI-268 / ADR-030 §Decision.1・§Decision.3.② 第2段: L3-007 (coverage-attestation-verification, fail-closed) 用ポート。 */
56
+ coverageAttestationVerificationPolicyPort?: CoverageAttestationVerificationPolicyPort;
53
57
  /** L3-005 のスコープ対象 story-id(config layers.L3.acBoundStories 由来。既定 [])。 */
54
58
  acBoundStories?: readonly string[];
55
59
  }
@@ -65,9 +69,11 @@ export class RunL3ValidatorsUseCase {
65
69
  private readonly securityScannerPort?: SecurityPatternScannerPort;
66
70
  private readonly performanceScannerPort?: PerformanceScannerPort;
67
71
  private readonly injectionScanPolicyPort?: InjectionScanPolicyPort;
72
+ private readonly coverageAttestationVerificationPolicyPort?: CoverageAttestationVerificationPolicyPort;
68
73
  private readonly acBoundStories: readonly string[];
69
74
  private readonly languageCapabilityService = new ValidatorLanguageCapabilityService();
70
75
  private readonly injectionScanService = new InjectionPatternScanService();
76
+ private readonly coverageAttestationVerificationService = new CoverageAttestationVerificationService();
71
77
 
72
78
  constructor(deps: RunL3ValidatorsUseCaseDeps) {
73
79
  this.registry = deps.validatorRegistry;
@@ -80,6 +86,7 @@ export class RunL3ValidatorsUseCase {
80
86
  this.securityScannerPort = deps.securityScannerPort;
81
87
  this.performanceScannerPort = deps.performanceScannerPort;
82
88
  this.injectionScanPolicyPort = deps.injectionScanPolicyPort;
89
+ this.coverageAttestationVerificationPolicyPort = deps.coverageAttestationVerificationPolicyPort;
83
90
  this.acBoundStories = deps.acBoundStories ?? [];
84
91
  }
85
92
 
@@ -254,6 +261,50 @@ export class RunL3ValidatorsUseCase {
254
261
  }
255
262
  }
256
263
 
264
+ // WI-268 / ADR-030 §Decision.1・§Decision.3.② 第2段: L3-007 coverage-attestation-verification。
265
+ // coverage_report の @attestation 参照を requirement-test-matrix に突合し、解決不能な参照
266
+ // (空手形の attestation)を fail-closed の error として遮断する(L2-016 の authoritative 相棒)。
267
+ // 参照ありで matrix を読めなければ fail-closed で FAIL。default-OFF/skip 時は override しない。
268
+ if (this.coverageAttestationVerificationPolicyPort) {
269
+ const l3007Result = overrideMap.get("L3-007");
270
+ if (l3007Result && !l3007Result.skipped) {
271
+ const l3007Id = ValidatorId.create("L3-007");
272
+ const collected = await this.coverageAttestationVerificationPolicyPort.collect();
273
+ if (collected.matrixError !== null) {
274
+ overrideMap.set(
275
+ "L3-007",
276
+ ValidationResult.fail(
277
+ l3007Id,
278
+ [
279
+ {
280
+ code: { value: "L3-007", toString: () => "L3-007" },
281
+ severity: { value: "error", toString: () => "error" },
282
+ message: collected.matrixError,
283
+ suggestion:
284
+ ".harness/requirement-test-matrix.json を生成してください(phasegate:generate-matrix)。パスは config の layers.L3.requirementMatrixPath で変更できます。",
285
+ },
286
+ ],
287
+ 0,
288
+ ),
289
+ );
290
+ } else {
291
+ const report = this.coverageAttestationVerificationService.verify(collected.references, collected.evidence);
292
+ if (report.hasFindings()) {
293
+ const errors: HarnessErrorLike[] = report.findings.map((finding) => ({
294
+ code: { value: "L3-007", toString: () => "L3-007" },
295
+ severity: { value: "error", toString: () => "error" },
296
+ message: finding.message,
297
+ suggestion: finding.suggestion,
298
+ sourcePath: finding.sourcePath,
299
+ }));
300
+ overrideMap.set("L3-007", ValidationResult.fail(l3007Id, errors, 0));
301
+ } else {
302
+ overrideMap.set("L3-007", ValidationResult.pass(l3007Id, 0));
303
+ }
304
+ }
305
+ }
306
+ }
307
+
257
308
  const finalResults = definitions.map(
258
309
  (definition) => overrideMap.get(definition.validatorId.value) ?? ValidationResult.skip(definition.validatorId),
259
310
  );
@@ -38,6 +38,7 @@ import { E2eTestFileRegistryAdapter } from "./infrastructure/adapters/e2e-test-f
38
38
  import { FileSystemArchitectureSemanticSourceAdapter } from "./infrastructure/adapters/file-system-architecture-semantic-source-adapter.js";
39
39
  import { FileSystemContractTraceabilityPolicyAdapter } from "./infrastructure/adapters/file-system-contract-traceability-policy-adapter.js";
40
40
  import { FileSystemCoverageAttestationGatingAdapter } from "./infrastructure/adapters/file-system-coverage-attestation-gating-adapter.js";
41
+ import { FileSystemCoverageAttestationVerificationAdapter } from "./infrastructure/adapters/file-system-coverage-attestation-verification-adapter.js";
41
42
  import { FileSystemInjectionScanAdapter } from "./infrastructure/adapters/file-system-injection-scan-adapter.js";
42
43
  import { FileSystemSecurityPatternScannerAdapter } from "./infrastructure/adapters/file-system-security-pattern-scanner-adapter.js";
43
44
  import { FileSystemSkillCatalogDriftAdapter } from "./infrastructure/adapters/file-system-skill-catalog-drift-adapter.js";
@@ -65,7 +66,7 @@ const DEFAULT_CONFIG = {
65
66
  L2: { enabled: true, validators: ["L2-001", "L2-002", "L2-003", "L2-013", "L2-014", "L2-015", "L2-016"] },
66
67
  L3: {
67
68
  enabled: true,
68
- validators: ["L3-001", "L3-002", "L3-003", "L3-004", "L3-006"],
69
+ validators: ["L3-001", "L3-002", "L3-003", "L3-004", "L3-006", "L3-007"],
69
70
  coverageThreshold: 90,
70
71
  bundleSizeLimit: 512000,
71
72
  requirementMatrixPath: ".harness/requirement-test-matrix.json",
@@ -142,6 +143,9 @@ export function buildDefaultRegistry(): ValidatorRegistry {
142
143
  createDef("L3-005", "L3", "always", "AcBoundCoveragePolicyPort"),
143
144
  // WI-259 / ADR-030 §Decision.3.④: L3-006 (injection-scan) は advisory (warning-only, default-ON)。
144
145
  createDef("L3-006", "L3", "always", "InjectionScanPolicyPort"),
146
+ // WI-268 / ADR-030 §Decision.1・§Decision.3.② 第2段: L3-007 (coverage-attestation-verification)
147
+ // は fail-closed / default-ON。L2-016 の authoritative 相棒。
148
+ createDef("L3-007", "L3", "always", "CoverageAttestationVerificationPolicyPort"),
145
149
  createDef("L4-001", "L4", "always"),
146
150
  createDef("L4-002", "L4", "always"),
147
151
  createDef("L4-003", "L4", "strictOnly"),
@@ -201,6 +205,13 @@ export function createValidatorSystemModule(config?: object): ValidatorSystemMod
201
205
  // 指示搭載ファイル群(skills/**​/SKILL.md / CLAUDE.md / AGENTS.md / agent-context / .claude/settings.json)を
202
206
  // cwd 起点で走査する(targetPaths 非依存)。
203
207
  const injectionScanPolicyPort = new FileSystemInjectionScanAdapter(process.cwd());
208
+ // WI-268 / ADR-030 §Decision.1・§Decision.3.② 第2段: L3-007 (coverage-attestation-verification) 用アダプタ。
209
+ // coverage_report の @attestation 参照を requirement-test-matrix に突合する(fail-closed)。
210
+ const coverageAttestationVerificationPolicyPort = new FileSystemCoverageAttestationVerificationAdapter(
211
+ process.cwd(),
212
+ (configData.layers?.L3 as { requirementMatrixPath?: string } | undefined)?.requirementMatrixPath ??
213
+ ".harness/requirement-test-matrix.json",
214
+ );
204
215
 
205
216
  const cwd = process.cwd();
206
217
  const designDocsRoot = configData.paths?.designDocs ?? "docs/product/construction";
@@ -255,6 +266,7 @@ export function createValidatorSystemModule(config?: object): ValidatorSystemMod
255
266
  securityScannerPort,
256
267
  performanceScannerPort,
257
268
  injectionScanPolicyPort,
269
+ coverageAttestationVerificationPolicyPort,
258
270
  acBoundStories,
259
271
  });
260
272
 
@@ -0,0 +1,30 @@
1
+ // @unit validator-system
2
+ // @layer domain
3
+ // @work-item-id WI-268
4
+
5
+ import type {
6
+ AttestationReference,
7
+ AttestationScopeEvidence,
8
+ } from "../value-objects/attestation-verification-report.js";
9
+
10
+ /**
11
+ * WI-268 / ADR-030 §Decision.1・§Decision.3.②(第2段) — L3-007 の走査・解決結果供給ポート。
12
+ *
13
+ * 参照走査(ungated-legacy 免除込み)と matrix 由来の解決可能スコープ解決は infrastructure が担う
14
+ * (cwd 起点・targetPaths 非依存の corpus 走査)。
15
+ */
16
+ export interface CoverageAttestationVerificationCollectResult {
17
+ /** ungated-legacy を除いた coverage_report から抽出した attestation 参照。 */
18
+ readonly references: readonly AttestationReference[];
19
+ /** matrix 由来の解決可能スコープ。references が空のときは空集合でよい(matrix 未読)。 */
20
+ readonly evidence: AttestationScopeEvidence;
21
+ /**
22
+ * 参照ありで matrix を読めなかった fail-closed シグナル。
23
+ * null なら matrix 読み込みは問題なし(または参照 0 件で matrix 未読)。
24
+ */
25
+ readonly matrixError: string | null;
26
+ }
27
+
28
+ export interface CoverageAttestationVerificationPolicyPort {
29
+ collect(): Promise<CoverageAttestationVerificationCollectResult>;
30
+ }
@@ -0,0 +1,45 @@
1
+ // @unit validator-system
2
+ // @layer domain
3
+ // @work-item-id WI-268
4
+
5
+ /**
6
+ * WI-268 / ADR-030 §Decision.1・§Decision.3.②(第2段) — coverage-attestation-verification
7
+ * (L3-007) の突合ドメインサービス。
8
+ *
9
+ * INV-A: 各 reference の id が evidence.resolvableScopeIds に含まれなければ error finding を生成
10
+ * (fail-closed。空手形の attestation を遮断)。
11
+ * INV-B: references が空なら report は空(検査対象なし → pass)。
12
+ * INV-C: 生成 finding は必ず severity='error'(advisory ではなく blocking tier)。
13
+ */
14
+
15
+ import {
16
+ type AttestationReference,
17
+ type AttestationScopeEvidence,
18
+ type AttestationVerificationFinding,
19
+ AttestationVerificationReport,
20
+ } from "../value-objects/attestation-verification-report.js";
21
+
22
+ export class CoverageAttestationVerificationService {
23
+ verify(
24
+ references: readonly AttestationReference[],
25
+ evidence: AttestationScopeEvidence,
26
+ ): AttestationVerificationReport {
27
+ const findings: AttestationVerificationFinding[] = [];
28
+
29
+ for (const reference of references) {
30
+ // INV-A: matrix 由来の解決可能スコープに存在しない参照は fail-closed の error。
31
+ if (!evidence.resolvableScopeIds.has(reference.id)) {
32
+ findings.push({
33
+ severity: "error",
34
+ sourcePath: reference.sourcePath,
35
+ lineNumber: reference.lineNumber,
36
+ message: `${reference.sourcePath}:${reference.lineNumber} の @attestation "${reference.id}" は requirement-test-matrix 上のテスト参照に解決できません(空手形 attestation は fail-closed)。`,
37
+ suggestion:
38
+ "@attestation <id> の id を、matrix 上に存在し testReferences を 1 件以上持つ story-id にしてください(phasegate:generate-matrix で最新化)。返済前なら <!-- @coverage-gating: ungated-legacy --> マーカーで免除(負債可視化)すること。",
39
+ });
40
+ }
41
+ }
42
+
43
+ return AttestationVerificationReport.create(findings);
44
+ }
45
+ }
@@ -0,0 +1,59 @@
1
+ // @unit validator-system
2
+ // @layer domain
3
+ // @work-item-id WI-268
4
+
5
+ /**
6
+ * WI-268 / ADR-030 §Decision.1・§Decision.3.②(第2段) — coverage_report の attestation
7
+ * 参照を requirement-test-matrix に対して authoritative に突合する L3-007 用ドメインモデル。
8
+ *
9
+ * L2-016(bare ✅ の遮断=参照の形状のみ)の authoritative 相棒。参照 id が本ランのテスト
10
+ * corpus から再生成された matrix 上に実在し、テスト参照を持つことを機械検証する。解決不能な
11
+ * 参照(空手形の attestation)は fail-closed の error として遮断する。
12
+ */
13
+
14
+ /** coverage_report 内の 1 件の `<!-- @attestation <id> -->` 参照。 */
15
+ export interface AttestationReference {
16
+ /** 参照 id(story-id 形式を期待。例 `H05-02`)。 */
17
+ readonly id: string;
18
+ /** 参照を含む coverage_report の project-relative パス。 */
19
+ readonly sourcePath: string;
20
+ /** 参照行(1 起点)。 */
21
+ readonly lineNumber: number;
22
+ }
23
+
24
+ /**
25
+ * matrix 由来の「解決可能なスコープ」集合。
26
+ * resolvableScopeIds = matrix 上に存在し、かつ testReferences を 1 件以上持つ story-id 集合。
27
+ */
28
+ export interface AttestationScopeEvidence {
29
+ readonly resolvableScopeIds: ReadonlySet<string>;
30
+ }
31
+
32
+ /** 1 件の突合違反(解決不能な参照)。severity は常に 'error'(fail-closed / blocking tier)。 */
33
+ export interface AttestationVerificationFinding {
34
+ readonly severity: "error";
35
+ readonly sourcePath: string;
36
+ readonly lineNumber: number;
37
+ readonly message: string;
38
+ readonly suggestion: string;
39
+ }
40
+
41
+ /**
42
+ * 突合判定レポート。findings はすべて error(fail-closed)。
43
+ */
44
+ export class AttestationVerificationReport {
45
+ readonly findings: readonly AttestationVerificationFinding[];
46
+
47
+ private constructor(findings: readonly AttestationVerificationFinding[]) {
48
+ this.findings = Object.freeze([...findings]);
49
+ Object.freeze(this);
50
+ }
51
+
52
+ static create(findings: readonly AttestationVerificationFinding[]): AttestationVerificationReport {
53
+ return new AttestationVerificationReport(findings);
54
+ }
55
+
56
+ hasFindings(): boolean {
57
+ return this.findings.length > 0;
58
+ }
59
+ }
@@ -13,6 +13,7 @@
13
13
  * WI-227 (H16-03) で L3-005(ac-bound-coverage, default-OFF fail-closed)を追加
14
14
  * WI-258 (ADR-030 §Decision.3.②) で L2-016(coverage-attestation-gating, fail-closed)を追加
15
15
  * WI-259 (ADR-030 §Decision.3.④) で L3-006(injection-scan, advisory warning-only)を追加
16
+ * WI-268 (ADR-030 §Decision.1・§Decision.3.② 第2段) で L3-007(coverage-attestation-verification, fail-closed)を追加
16
17
  */
17
18
 
18
19
  export class InvalidValidatorIdError extends Error {
@@ -43,6 +44,7 @@ const VALIDATOR_NAME_MAP: Record<string, string> = {
43
44
  "L3-004": "nyquist",
44
45
  "L3-005": "ac-bound-coverage",
45
46
  "L3-006": "injection-scan",
47
+ "L3-007": "coverage-attestation-verification",
46
48
  "L4-001": "drift-detect",
47
49
  "L4-002": "consistency-check",
48
50
  "L4-003": "dead-code",
@@ -0,0 +1,165 @@
1
+ // @unit validator-system
2
+ // @layer infrastructure
3
+ // @work-item-id WI-268
4
+
5
+ import { readdir, readFile } from "node:fs/promises";
6
+ import { isAbsolute, join } from "node:path";
7
+ import type {
8
+ CoverageAttestationVerificationCollectResult,
9
+ CoverageAttestationVerificationPolicyPort,
10
+ } from "../../domain/ports/coverage-attestation-verification-policy-port.js";
11
+ import type { AttestationReference } from "../../domain/value-objects/attestation-verification-report.js";
12
+
13
+ const COVERAGE_REPORT_FILE = "coverage_report.md";
14
+ const CONSTRUCTION_REL = join("docs", "product", "construction");
15
+ const LEGACY_MARKER = /<!--\s*@coverage-gating:\s*ungated-legacy\s*-->/;
16
+ /** `<!-- @attestation <id> -->` の id を捕捉する(1 行内複数を許容)。 */
17
+ const ATTESTATION_REF_GLOBAL = /<!--\s*@attestation\s+([^\s>-][^>]*?)\s*-->/g;
18
+ const DEFAULT_MATRIX_PATH = ".harness/requirement-test-matrix.json";
19
+
20
+ interface MatrixTestReference {
21
+ readonly filePath?: string;
22
+ }
23
+
24
+ interface MatrixAcMapping {
25
+ readonly testReferences?: readonly MatrixTestReference[];
26
+ }
27
+
28
+ interface MatrixStory {
29
+ readonly storyId?: string;
30
+ readonly storyMappings?: readonly MatrixAcMapping[];
31
+ readonly acMappings?: readonly MatrixAcMapping[];
32
+ }
33
+
34
+ /**
35
+ * WI-268 / ADR-030 §Decision.1・§Decision.3.②(第2段) — L3-007 の走査・解決アダプタ。
36
+ *
37
+ * `docs/product/construction/*​/coverage_report.md` を cwd 起点で走査し(targetPaths 非依存の
38
+ * corpus 走査。L2-016 と同様に自前でファイル探索する)、ungated-legacy マーカー付きファイルを除外して
39
+ * `<!-- @attestation <id> -->` 参照を抽出する。
40
+ *
41
+ * 参照が 1 件以上ある場合のみ requirement-test-matrix を読み、resolvable scope
42
+ * (storyId 存在 かつ testReferences >= 1 の story-id 集合)を解決する。matrix 不在・parse 不能は
43
+ * fail-closed(matrixError を返す)。参照 0 件なら matrix を読まず空 evidence + matrixError=null。
44
+ */
45
+ export class FileSystemCoverageAttestationVerificationAdapter
46
+ implements CoverageAttestationVerificationPolicyPort
47
+ {
48
+ constructor(
49
+ private readonly projectRoot: string,
50
+ private readonly matrixFilePath: string = DEFAULT_MATRIX_PATH,
51
+ ) {}
52
+
53
+ async collect(): Promise<CoverageAttestationVerificationCollectResult> {
54
+ const references = await this.collectReferences();
55
+
56
+ // 参照が 1 件も無ければ matrix を読みに行かず PASS(最小副作用)。
57
+ if (references.length === 0) {
58
+ return Object.freeze({
59
+ references: Object.freeze([]),
60
+ evidence: { resolvableScopeIds: new Set<string>() },
61
+ matrixError: null,
62
+ });
63
+ }
64
+
65
+ const resolved = await this.resolveScopeEvidence();
66
+ if (resolved.error !== null) {
67
+ // 参照ありで matrix を読めない → fail-closed。
68
+ return Object.freeze({
69
+ references: Object.freeze(references),
70
+ evidence: { resolvableScopeIds: new Set<string>() },
71
+ matrixError: resolved.error,
72
+ });
73
+ }
74
+
75
+ return Object.freeze({
76
+ references: Object.freeze(references),
77
+ evidence: { resolvableScopeIds: resolved.scopeIds },
78
+ matrixError: null,
79
+ });
80
+ }
81
+
82
+ private async collectReferences(): Promise<AttestationReference[]> {
83
+ const constructionRoot = join(this.projectRoot, CONSTRUCTION_REL);
84
+ let unitDirs: string[];
85
+ try {
86
+ const entries = await readdir(constructionRoot, { withFileTypes: true });
87
+ unitDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
88
+ } catch {
89
+ return [];
90
+ }
91
+
92
+ const references: AttestationReference[] = [];
93
+ for (const unitDir of unitDirs) {
94
+ const filePath = join(constructionRoot, unitDir, COVERAGE_REPORT_FILE);
95
+ let content: string;
96
+ try {
97
+ content = await readFile(filePath, "utf-8");
98
+ } catch {
99
+ continue;
100
+ }
101
+ const relPath = `${CONSTRUCTION_REL.replace(/\\/g, "/")}/${unitDir}/${COVERAGE_REPORT_FILE}`;
102
+ this.parseReferences(relPath, content, references);
103
+ }
104
+ return references;
105
+ }
106
+
107
+ /** ungated-legacy マーカー付きファイルは免除。それ以外は各 @attestation 参照を抽出する。 */
108
+ private parseReferences(path: string, content: string, out: AttestationReference[]): void {
109
+ const lines = content.split(/\r?\n/);
110
+ if (lines.some((line) => LEGACY_MARKER.test(line))) return;
111
+
112
+ for (let i = 0; i < lines.length; i++) {
113
+ const line = lines[i];
114
+ ATTESTATION_REF_GLOBAL.lastIndex = 0;
115
+ let match: RegExpExecArray | null = ATTESTATION_REF_GLOBAL.exec(line);
116
+ while (match !== null) {
117
+ const id = match[1].trim();
118
+ if (id.length > 0) {
119
+ out.push({ id, sourcePath: path, lineNumber: i + 1 });
120
+ }
121
+ match = ATTESTATION_REF_GLOBAL.exec(line);
122
+ }
123
+ }
124
+ }
125
+
126
+ private async resolveScopeEvidence(): Promise<{ scopeIds: Set<string>; error: string | null }> {
127
+ const relativeOrAbsolute = this.matrixFilePath.length > 0 ? this.matrixFilePath : DEFAULT_MATRIX_PATH;
128
+ const absPath = isAbsolute(relativeOrAbsolute)
129
+ ? relativeOrAbsolute
130
+ : join(this.projectRoot, relativeOrAbsolute);
131
+
132
+ let parsed: unknown;
133
+ try {
134
+ const raw = await readFile(absPath, "utf-8");
135
+ parsed = JSON.parse(raw);
136
+ } catch (error) {
137
+ const message = error instanceof Error ? error.message : String(error);
138
+ return {
139
+ scopeIds: new Set<string>(),
140
+ error: `requirement-test-matrix を読み込めません(L3-007 は fail-closed): ${relativeOrAbsolute}: ${message}`,
141
+ };
142
+ }
143
+
144
+ const scopeIds = new Set<string>();
145
+ for (const story of this.extractStories(parsed)) {
146
+ const storyId = story.storyId;
147
+ if (!storyId) continue;
148
+ const mappings = story.storyMappings ?? story.acMappings ?? [];
149
+ const hasTestRef = mappings.some((ac) => (ac.testReferences?.length ?? 0) > 0);
150
+ if (hasTestRef) scopeIds.add(storyId);
151
+ }
152
+ return { scopeIds, error: null };
153
+ }
154
+
155
+ private extractStories(parsed: unknown): readonly MatrixStory[] {
156
+ if (typeof parsed !== "object" || parsed === null) return [];
157
+ const obj = parsed as { stories?: unknown; storyMappings?: unknown };
158
+ const raw = Array.isArray(obj.stories)
159
+ ? obj.stories
160
+ : Array.isArray(obj.storyMappings)
161
+ ? obj.storyMappings
162
+ : [];
163
+ return raw as readonly MatrixStory[];
164
+ }
165
+ }
@@ -90,11 +90,271 @@ glob_to_regex() {
90
90
  printf '%s' "$1" | sed 's/\*/.*/g'
91
91
  }
92
92
 
93
+ # --- git subcommand allowlist (default-deny) ---------------------------------
94
+ # Enumerated deny lists for git always leak (e.g. `git switch` slipped past the
95
+ # `git checkout*` / `git reset*` deny rules). We therefore invert the policy for
96
+ # git: only the subcommands below are permitted; every other git subcommand is
97
+ # denied by default. To grant a new git subcommand, a human adds it here.
98
+ #
99
+ # Rationale for the set: read-only inspection, staging/commit/tag creation, and
100
+ # worktree/fetch operations that agents legitimately use. History- and
101
+ # working-tree-mutating subcommands (checkout, switch, reset, rebase, merge,
102
+ # cherry-pick, revert, stash, clean, update-ref, reflog, filter-branch,
103
+ # replace, am, ...) are intentionally absent so they fail closed.
104
+ GIT_ALLOWED_SUBCOMMANDS=(
105
+ status log show diff add commit tag restore rev-parse rev-list
106
+ merge-base branch worktree fetch grep cat-file ls-files ls-tree
107
+ ls-remote init remote describe blame shortlog
108
+ for-each-ref name-rev check-ignore check-attr
109
+ stripspace var help version whatchanged push
110
+ )
111
+
112
+ # `symbolic-ref` is deliberately NOT in the allowlist above: its write form
113
+ # (`git symbolic-ref HEAD refs/heads/<branch>` or `git symbolic-ref -d HEAD`)
114
+ # re-points HEAD, i.e. it is a checkout-equivalent HEAD/history mutation that the
115
+ # default-deny policy exists to block. Only the read form (reporting the ref HEAD
116
+ # points at, e.g. `git symbolic-ref HEAD` / `git symbolic-ref --short HEAD`) is
117
+ # state-preserving and therefore permitted. This guard, checked before the plain
118
+ # allowlist, allows the read form and denies every write form.
119
+ check_symbolic_ref() {
120
+ local segment="$1"
121
+ local sub
122
+ sub=$(extract_git_subcommand "$segment")
123
+ [[ "$sub" != "symbolic-ref" ]] && return 0
124
+
125
+ # Re-tokenize and walk to the subcommand, then inspect its arguments.
126
+ local -a tokens
127
+ read -ra tokens <<< "$segment"
128
+ local i=1
129
+ local n=${#tokens[@]}
130
+ # Advance past global options to the `symbolic-ref` token (mirrors
131
+ # extract_git_subcommand's flag handling so flag-stuffing cannot evade this).
132
+ while (( i < n )); do
133
+ case "${tokens[$i]}" in
134
+ -C|-c|--git-dir|--work-tree|--namespace|--exec-path|--config-env)
135
+ i=$(( i + 2 )) ;;
136
+ --git-dir=*|--work-tree=*|--namespace=*|--exec-path=*|--config-env=*)
137
+ i=$(( i + 1 )) ;;
138
+ --no-pager|--paginate|--no-replace-objects|--bare|--literal-pathspecs|--no-optional-locks|--html-path|--man-path|--info-path)
139
+ i=$(( i + 1 )) ;;
140
+ symbolic-ref)
141
+ break ;;
142
+ -*)
143
+ i=$(( i + 1 )) ;;
144
+ *)
145
+ break ;;
146
+ esac
147
+ done
148
+ # Skip the `symbolic-ref` token itself.
149
+ i=$(( i + 1 ))
150
+
151
+ # Count positional (non-flag) arguments after the subcommand. A read is
152
+ # `symbolic-ref [--short|-q] <name>` (<= 1 positional, no delete). A write is
153
+ # `symbolic-ref <name> <ref>` (>= 2 positionals) or `symbolic-ref -d <name>`.
154
+ local positional=0
155
+ while (( i < n )); do
156
+ local arg="${tokens[$i]}"
157
+ case "$arg" in
158
+ -d|--delete)
159
+ debug_log "BLOCKED git symbolic-ref delete form (segment '$segment')"
160
+ echo "Security policy violation: 'git symbolic-ref' delete form is denied (it mutates HEAD; only the read form is permitted). Segment: '$segment'." >&2
161
+ exit 2 ;;
162
+ -m|--reason)
163
+ # `-m <reason>` accompanies a write; the reason value consumes one token.
164
+ i=$(( i + 2 )); continue ;;
165
+ --short|-q|--quiet)
166
+ # Read-only modifiers; do not count as positionals.
167
+ : ;;
168
+ --)
169
+ : ;;
170
+ -*)
171
+ : ;;
172
+ *)
173
+ positional=$(( positional + 1 )) ;;
174
+ esac
175
+ i=$(( i + 1 ))
176
+ done
177
+
178
+ if (( positional >= 2 )); then
179
+ debug_log "BLOCKED git symbolic-ref write form (segment '$segment')"
180
+ echo "Security policy violation: 'git symbolic-ref' write form (re-pointing HEAD) is denied; it is checkout-equivalent HEAD mutation. Only the read form (e.g. 'git symbolic-ref HEAD') is permitted. Segment: '$segment'." >&2
181
+ exit 2
182
+ fi
183
+ # <= 1 positional and no delete: read form. Allowed.
184
+ return 0
185
+ }
186
+
187
+ # `config` is deliberately NOT in the allowlist above: its write form
188
+ # (`git config <key> <value>`, `--unset`, `--add`, `--edit`, ...) can re-point
189
+ # the hook path itself (`git config core.hooksPath <dir>`), which would disable
190
+ # the entire L0 defence layer. Only read forms (`--get*`, `--list`/`-l`, or a
191
+ # single <key> positional with no value) are state-preserving and therefore
192
+ # permitted. Ambiguous invocations fail closed; read forms combined with scope
193
+ # flags (`--global --list`, `--local --get <key>`, ...) are legitimate and pass.
194
+ check_git_config() {
195
+ local segment="$1"
196
+ local sub
197
+ sub=$(extract_git_subcommand "$segment")
198
+ [[ "$sub" != "config" ]] && return 0
199
+
200
+ # Re-tokenize and walk to the subcommand, then inspect its arguments.
201
+ local -a tokens
202
+ read -ra tokens <<< "$segment"
203
+ local i=1
204
+ local n=${#tokens[@]}
205
+ # Advance past global options to the `config` token (mirrors
206
+ # extract_git_subcommand's flag handling so flag-stuffing cannot evade this).
207
+ while (( i < n )); do
208
+ case "${tokens[$i]}" in
209
+ -C|-c|--git-dir|--work-tree|--namespace|--exec-path|--config-env)
210
+ i=$(( i + 2 )) ;;
211
+ --git-dir=*|--work-tree=*|--namespace=*|--exec-path=*|--config-env=*)
212
+ i=$(( i + 1 )) ;;
213
+ --no-pager|--paginate|--no-replace-objects|--bare|--literal-pathspecs|--no-optional-locks|--html-path|--man-path|--info-path)
214
+ i=$(( i + 1 )) ;;
215
+ config)
216
+ break ;;
217
+ -*)
218
+ i=$(( i + 1 )) ;;
219
+ *)
220
+ break ;;
221
+ esac
222
+ done
223
+ # Skip the `config` token itself.
224
+ i=$(( i + 1 ))
225
+
226
+ # Classify the arguments after the subcommand.
227
+ # read flags -> explicitly allowed (`--get`, `--list`, ...)
228
+ # write flags -> explicitly denied (`--unset`, `--add`, `--edit`, ...)
229
+ # positionals -> counted: 1 positional with no write indicator is the
230
+ # `git config <key>` read; >= 2 positionals is the
231
+ # `git config <key> <value>` write (fail closed).
232
+ # Scope flags (`--global`, `--system`, `--local`, `--worktree`) and other
233
+ # modifiers are neutral: the verdict is driven by read/write flags and the
234
+ # positional count, so `--global --list` passes and `--global k v` fails.
235
+ local read_flag=0
236
+ local positional=0
237
+ while (( i < n )); do
238
+ local arg="${tokens[$i]}"
239
+ case "$arg" in
240
+ --unset|--unset-all|--add|--replace-all|--edit|-e|--remove-section|--rename-section|--set*)
241
+ debug_log "BLOCKED git config write flag '$arg' (segment '$segment')"
242
+ echo "Security policy violation: 'git config' write form ('$arg') is denied; config writes can re-point hooks (core.hooksPath) and disable the L0 defence layer. Only read forms (--get/--get-all/--get-regexp/--list/-l or a bare <key>) are permitted. Segment: '$segment'." >&2
243
+ exit 2 ;;
244
+ --get|--get-all|--get-regexp|--get-urlmatch|--get-color|--get-colorbool|--list|-l)
245
+ read_flag=1 ;;
246
+ --file|-f|--blob|--default|--type)
247
+ # Neutral flags that consume a separate value token.
248
+ i=$(( i + 2 )); continue ;;
249
+ --file=*|--blob=*|--default=*|--type=*)
250
+ : ;;
251
+ --)
252
+ : ;;
253
+ -*)
254
+ # Scope flags and other modifiers: neutral, do not count.
255
+ : ;;
256
+ *)
257
+ # New-style verb subcommands (git >= 2.46) that mutate config.
258
+ if (( positional == 0 )); then
259
+ case "$arg" in
260
+ set|unset|edit|rename-section|remove-section)
261
+ debug_log "BLOCKED git config verb '$arg' (segment '$segment')"
262
+ echo "Security policy violation: 'git config $arg' is a config write form and is denied; config writes can re-point hooks (core.hooksPath) and disable the L0 defence layer. Segment: '$segment'." >&2
263
+ exit 2 ;;
264
+ esac
265
+ fi
266
+ positional=$(( positional + 1 )) ;;
267
+ esac
268
+ i=$(( i + 1 ))
269
+ done
270
+
271
+ # Explicit read flag: allowed regardless of positional count
272
+ # (`--get <key>`, `--get-regexp <pattern>`, `--get-urlmatch <key> <url>`).
273
+ (( read_flag == 1 )) && return 0
274
+
275
+ if (( positional >= 2 )); then
276
+ debug_log "BLOCKED git config write form (segment '$segment')"
277
+ echo "Security policy violation: 'git config' write form (<key> <value>) is denied; config writes can re-point hooks (core.hooksPath) and disable the L0 defence layer. Only read forms (--get/--get-all/--get-regexp/--list/-l or a bare <key>) are permitted. Segment: '$segment'." >&2
278
+ exit 2
279
+ fi
280
+ # <= 1 positional and no write indicator: value read (`git config <key>`)
281
+ # or a no-op. Allowed.
282
+ return 0
283
+ }
284
+
285
+ # Extract the git subcommand from a segment, skipping the `git` binary and any
286
+ # global options that may precede the subcommand:
287
+ # git -C <path> <sub> git --no-pager <sub>
288
+ # git -c key=val <sub> git --git-dir=<dir> <sub>
289
+ # git --work-tree <dir> <sub>
290
+ # Prints the subcommand (or empty string if none / not a git command).
291
+ extract_git_subcommand() {
292
+ # Tokenize on whitespace.
293
+ local -a tokens
294
+ read -ra tokens <<< "$1"
295
+ [[ "${tokens[0]}" != "git" ]] && return 0
296
+ local i=1
297
+ local n=${#tokens[@]}
298
+ while (( i < n )); do
299
+ local tok="${tokens[$i]}"
300
+ case "$tok" in
301
+ # Global flags that take a separate argument.
302
+ -C|-c|--git-dir|--work-tree|--namespace|--exec-path|--config-env)
303
+ i=$(( i + 2 ))
304
+ ;;
305
+ # Global flags bundled with their value (=), or standalone toggles.
306
+ --git-dir=*|--work-tree=*|--namespace=*|--exec-path=*|--config-env=*)
307
+ i=$(( i + 1 ))
308
+ ;;
309
+ --no-pager|--paginate|--no-replace-objects|--bare|--literal-pathspecs|--no-optional-locks|--html-path|--man-path|--info-path)
310
+ i=$(( i + 1 ))
311
+ ;;
312
+ -*)
313
+ # Unknown global flag; skip conservatively.
314
+ i=$(( i + 1 ))
315
+ ;;
316
+ *)
317
+ printf '%s' "$tok"
318
+ return 0
319
+ ;;
320
+ esac
321
+ done
322
+ return 0
323
+ }
324
+
325
+ check_git_allowlist() {
326
+ local segment="$1"
327
+ local sub
328
+ sub=$(extract_git_subcommand "$segment")
329
+ # Not a git command, or `git` with no subcommand (e.g. `git`, `git --help`).
330
+ [[ -z "$sub" ]] && return 0
331
+ # `symbolic-ref` is adjudicated by check_symbolic_ref (read form allowed,
332
+ # write form denied); do not treat its absence from the allowlist as a deny.
333
+ [[ "$sub" == "symbolic-ref" ]] && return 0
334
+ # `config` is adjudicated by check_git_config (read forms allowed, write
335
+ # forms denied); do not treat its absence from the allowlist as a deny.
336
+ [[ "$sub" == "config" ]] && return 0
337
+ local allowed
338
+ for allowed in "${GIT_ALLOWED_SUBCOMMANDS[@]}"; do
339
+ [[ "$sub" == "$allowed" ]] && return 0
340
+ done
341
+ debug_log "BLOCKED git subcommand '$sub' not in allowlist (segment '$segment')"
342
+ echo "Security policy violation: git subcommand '$sub' is not in the agent allowlist (default-deny for git). Segment: '$segment'. If this subcommand is genuinely needed, a human must add it to GIT_ALLOWED_SUBCOMMANDS in .claude/scripts/deny-check.sh." >&2
343
+ exit 2
344
+ }
345
+
93
346
  check_segment() {
94
347
  local segment="$1"
95
348
  # Strip leading whitespace so "^pattern" anchors match after operators.
96
349
  segment="${segment#"${segment%%[![:space:]]*}"}"
97
350
  [[ -z "$segment" ]] && return 0
351
+ # git subcommands are default-deny (allowlist); check that first.
352
+ # symbolic-ref and config get a dedicated read-vs-write adjudication before
353
+ # the plain allowlist (their write forms mutate HEAD / the hook path and
354
+ # must fail closed).
355
+ check_symbolic_ref "$segment"
356
+ check_git_config "$segment"
357
+ check_git_allowlist "$segment"
98
358
  for pattern in "${DENY_PATTERNS[@]}"; do
99
359
  local regex_pattern
100
360
  regex_pattern=$(glob_to_regex "$pattern")