phasegate 0.212.0 → 0.229.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.
- package/CHANGELOG.md +6 -11
- package/docs/ADR/001-l1-biome-editor-time-enforcement.md +2 -0
- package/docs/ADR/015-architecture-preset.md +2 -0
- package/docs/ADR/030-injection-threat-model-and-trust-root.md +1 -1
- package/docs/guide/hooks-integration.md +53 -2
- package/package.json +1 -1
- package/scripts/harness/biome-ast-engine/application/mappers/build-harness-error-payload-output-mapper.ts +3 -3
- package/scripts/harness/biome-ast-engine/infrastructure/mappers/rule-violation-code-mapper.ts +3 -3
- package/scripts/harness/biome-ast-engine/presentation/cli/harness-lint-command-handler.ts +6 -4
- package/scripts/harness/config-foundation/application/mappers/validator-system-config-mapper.ts +10 -3
- package/scripts/harness/harness-error/infrastructure/registry/l1-error-definitions.ts +9 -9
- package/scripts/harness/installation/application/usecases/run-reconcile.ts +69 -1
- package/scripts/harness/validator-system/application/use-cases/run-l3-validators-usecase.ts +51 -0
- package/scripts/harness/validator-system/composition-root.ts +13 -1
- package/scripts/harness/validator-system/domain/ports/coverage-attestation-verification-policy-port.ts +30 -0
- package/scripts/harness/validator-system/domain/services/coverage-attestation-verification-service.ts +45 -0
- package/scripts/harness/validator-system/domain/value-objects/attestation-verification-report.ts +59 -0
- package/scripts/harness/validator-system/domain/value-objects/validator-id.ts +2 -0
- package/scripts/harness/validator-system/infrastructure/adapters/file-system-coverage-attestation-verification-adapter.ts +165 -0
- 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 (
|
|
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
|
-
|
|
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
|
|
|
@@ -26,6 +26,8 @@ Rust製 Biome の AST 解析を用いて、L1(Editor-Time)で以下の8ル
|
|
|
26
26
|
| no-comment-flood | L1-007 | 過剰なコメント |
|
|
27
27
|
| no-code-duplication | L1-008 | 構造的に重複するコードブロック |
|
|
28
28
|
|
|
29
|
+
> 注記(WI-274, 2026-07-16): 上表の L1-006〜L1-008 のルール名↔コード対応は当時の記述であり、現行の canonical と異なる。現行の正しい対応は `L1-006`=`no-code-duplication` / `L1-007`=`no-ghost-file` / `L1-008`=`no-comment-flood`(WI-265 で是正済み。真実の源は `biome-ast-engine` の `rule-definition-registry.ts`)。本文は歴史記録として保存する。
|
|
30
|
+
|
|
29
31
|
## Consequences
|
|
30
32
|
|
|
31
33
|
- エージェントのプロンプト遵守度に品質が依存しない
|
|
@@ -133,6 +133,8 @@ v0.86.0(ADR-014 適用)以降、`ALLOWED_DEPENDENCIES.presentation` に `'do
|
|
|
133
133
|
| L1-007 no-comment-flood | 有効維持 |
|
|
134
134
|
| L1-008 no-code-duplication | 有効維持 |
|
|
135
135
|
|
|
136
|
+
> 注記(WI-274, 2026-07-16): 上表の L1-006〜L1-008 のルール名↔コード対応は当時の記述であり、現行の canonical と異なる。現行の正しい対応は `L1-006`=`no-code-duplication` / `L1-007`=`no-ghost-file` / `L1-008`=`no-comment-flood`(WI-265 で是正済み。真実の源は `biome-ast-engine` の `rule-definition-registry.ts`)。`flat` preset で「有効維持」となる 3 ルール(重複/ghost/コメント過多)という判断自体は不変で、コード表記のみが当時 drift していた。本文は歴史記録として保存する。
|
|
137
|
+
|
|
136
138
|
### メタデータタグの可変化
|
|
137
139
|
|
|
138
140
|
`@layer` / `@unit` というタグ名自体も `metadataTags` で差し替え可能とする。これにより、社内規約で `@tier` / `@module` 等を使う PJ にも対応できる。
|
|
@@ -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
|
|
159
|
-
|
|
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
|
@@ -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-
|
|
28
|
-
'no-
|
|
29
|
-
'no-
|
|
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 = (
|
package/scripts/harness/biome-ast-engine/infrastructure/mappers/rule-violation-code-mapper.ts
CHANGED
|
@@ -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-
|
|
15
|
-
'no-
|
|
16
|
-
'no-
|
|
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
|
-
|
|
108
|
-
|
|
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 (
|
|
113
|
-
lines.push(` ... and ${
|
|
114
|
+
if (total > displayLimit) {
|
|
115
|
+
lines.push(` ... and ${total - displayLimit} more (shown ${shown.length} of ${total})`);
|
|
114
116
|
}
|
|
115
117
|
}
|
|
116
118
|
|
package/scripts/harness/config-foundation/application/mappers/validator-system-config-mapper.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
69
|
-
|
|
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:
|
|
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: '
|
|
78
|
+
title: '参照されないファイルが残っている',
|
|
79
|
+
category: 'architecture',
|
|
79
80
|
ownerValidatorId: 'architecture',
|
|
80
|
-
defaultFixExample: 'const actual = "
|
|
81
|
+
defaultFixExample: 'export const actual = "shared";',
|
|
81
82
|
}),
|
|
82
83
|
createDefinition({
|
|
83
84
|
code: 'L1-008',
|
|
84
|
-
title: '
|
|
85
|
-
category: '
|
|
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
|
+
}
|
package/scripts/harness/validator-system/domain/value-objects/attestation-verification-report.ts
ADDED
|
@@ -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")
|