forge-workflow 0.1.0-beta.4 → 0.1.0-beta.6
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/AGENTS.md +18 -7
- package/CHANGELOG.md +79 -1
- package/CLAUDE.md +0 -12
- package/CODING_STANDARDS.md +72 -0
- package/README.md +6 -2
- package/bin/forge-cmd.js +20 -0
- package/bin/forge.js +28 -375
- package/docs/INDEX.md +1 -1
- package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
- package/docs/guides/MIGRATION.md +4 -4
- package/docs/guides/SETUP.md +16 -16
- package/docs/reference/COMMANDS.md +8 -5
- package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/INSTALL.md +4 -0
- package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/github-accounts.md +134 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +114 -35
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/pr-state-adapter.js +359 -144
- package/lib/audit-evidence.js +71 -110
- package/lib/base-remote.js +138 -0
- package/lib/beta5-compatibility-evidence.js +1093 -0
- package/lib/bun-lockfile-proof.js +413 -0
- package/lib/bun-workflow-pins.js +461 -0
- package/lib/capabilities/index.js +9 -0
- package/lib/capabilities/model.js +141 -0
- package/lib/capabilities/probes.js +347 -0
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/codex-skills.js +2 -2
- package/lib/commands/_manifest.js +1 -0
- package/lib/commands/_registry.js +50 -20
- package/lib/commands/clean.js +252 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/doctor.js +37 -6
- package/lib/commands/gate.js +197 -27
- package/lib/commands/github.js +215 -0
- package/lib/commands/hooks.js +276 -30
- package/lib/commands/insights.js +8 -3
- package/lib/commands/memory.js +66 -2
- package/lib/commands/merge.js +1265 -58
- package/lib/commands/plan.js +33 -2
- package/lib/commands/pr.js +3 -1
- package/lib/commands/preflight.js +21 -4
- package/lib/commands/prime.js +21 -8
- package/lib/commands/push.js +146 -54
- package/lib/commands/recall.js +127 -49
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +39 -3
- package/lib/commands/remember.js +28 -4
- package/lib/commands/serve.js +26 -9
- package/lib/commands/setup.js +323 -98
- package/lib/commands/shepherd.js +591 -73
- package/lib/commands/ship.js +36 -91
- package/lib/commands/skill.js +127 -11
- package/lib/commands/status.js +17 -1
- package/lib/commands/team.js +47 -8
- package/lib/commands/test.js +187 -38
- package/lib/commands/validate.js +65 -21
- package/lib/commands/worktree.js +359 -45
- package/lib/core/runtime-graph.js +1 -1
- package/lib/doc-assertions.js +297 -0
- package/lib/existing-tdd-gate.js +253 -0
- package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
- package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
- package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
- package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
- package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
- package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
- package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
- package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
- package/lib/forge-context.js +1 -4
- package/lib/forge-issues.js +134 -32
- package/lib/gate-events.js +98 -10
- package/lib/git-defaults.js +56 -0
- package/lib/github-context.js +308 -0
- package/lib/global-flags.js +1 -0
- package/lib/harness-capability-matrix.js +3 -3
- package/lib/hook-renderer.js +122 -5
- package/lib/insights.js +96 -80
- package/lib/issue-render.js +19 -0
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +739 -31
- package/lib/kernel/claim-reconciler.js +238 -0
- package/lib/kernel/cli-broker-factory.js +12 -1
- package/lib/kernel/close-on-merge.js +154 -0
- package/lib/kernel/fs-class.js +42 -25
- package/lib/kernel/lease-enforcer.js +9 -4
- package/lib/kernel/legacy-claim-repair.js +442 -0
- package/lib/kernel/live-claim-projection.js +26 -0
- package/lib/kernel/migrations.js +118 -3
- package/lib/kernel/readiness-model.js +184 -12
- package/lib/kernel/schema.js +49 -1
- package/lib/kernel/sqlite-driver.js +3435 -172
- package/lib/kernel/taxonomy-validator.js +4 -1
- package/lib/kernel/windows-private-acl.js +239 -0
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/hygiene.js +191 -0
- package/lib/memory/router.js +110 -28
- package/lib/memory/usage-evidence.js +4 -0
- package/lib/memory-digest.js +106 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +71 -10
- package/lib/merge-rules.js +143 -21
- package/lib/npm-publish-workflow.js +465 -0
- package/lib/orientation.js +68 -43
- package/lib/package-root.js +2 -0
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +5 -6
- package/lib/pr-monitor/auto-actions.js +169 -28
- package/lib/pr-monitor/differ.js +110 -4
- package/lib/pr-monitor/events.js +0 -0
- package/lib/pr-monitor/flow-monitor.js +1424 -0
- package/lib/pr-monitor/gather.js +251 -44
- package/lib/pr-monitor/journal.js +18 -39
- package/lib/pr-monitor/monitor.js +117 -10
- package/lib/pr-monitor/process-identity.js +117 -0
- package/lib/pr-monitor/reconcile-executor.js +1129 -470
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +293 -0
- package/lib/pr-monitor/review-preflight.js +269 -0
- package/lib/pr-monitor/shepherd-lease.js +38 -20
- package/lib/pr-monitor/verdict.js +438 -0
- package/lib/pr-monitor/watch-lifecycle.js +145 -27
- package/lib/pr-monitor/watch-owner.js +1414 -0
- package/lib/pr-monitor/watch.js +129 -58
- package/lib/pr-pull.js +33 -14
- package/lib/pr-shepherd.js +51 -11
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +178 -4
- package/lib/protected-state-authority.js +1100 -0
- package/lib/protected-state-surfaces.js +243 -45
- package/lib/release-readiness.js +53 -7
- package/lib/review-adapter.js +65 -0
- package/lib/shell-utils.js +1 -1
- package/lib/skills-sync.js +71 -35
- package/lib/smart-merge.js +28 -4
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +39 -0
- package/lib/using-forge.js +19 -6
- package/lib/validation/risk-manifest.js +339 -0
- package/lib/workflow/enforce-stage.js +44 -0
- package/lib/workflow/plan-authority.js +225 -0
- package/package.json +12 -9
- package/scripts/commitlint.js +13 -15
- package/scripts/doc-asserting-tests.js +158 -0
- package/scripts/generate-risk-manifest.js +91 -0
- package/scripts/github-context-bridge.sh +10 -0
- package/scripts/legacy-claim-repair.js +145 -0
- package/scripts/lib/behavioral-eval-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +457 -0
- package/scripts/lib/eval-evidence.js +328 -0
- package/scripts/lib/eval-runner.js +81 -41
- package/scripts/lib/immutable-eval-corpus.js +309 -0
- package/scripts/lib/promotion-evidence-loader.js +94 -0
- package/scripts/lib/promotion-scorecard.js +314 -0
- package/scripts/npm-release-receipt.js +134 -0
- package/scripts/process-tree.js +773 -0
- package/scripts/protected-state-check.js +479 -31
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-agent-skills.js +333 -34
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +935 -37
- package/scripts/test-profile.js +13 -3
- package/scripts/test.js +271 -57
- package/skills/coverage.json +1 -0
- package/skills/review/SKILL.md +6 -11
- package/skills/review/evals/scorecard.json +4 -4
- package/skills/rollback/SKILL.md +4 -11
- package/skills/rollback/evals/scorecard.json +3 -3
- package/skills/setup/SKILL.md +18 -0
- package/skills/setup/evals/scorecard.json +3 -3
- package/skills/shepherd/SKILL.md +39 -16
- package/skills/shepherd/evals/scorecard.json +4 -4
- package/skills/ship/SKILL.md +4 -12
- package/skills/ship/evals/scorecard.json +3 -3
- package/skills/validate/SKILL.md +3 -0
- package/skills/validate/evals/scorecard.json +1 -1
- package/skills/worktree/SKILL.md +6 -1
- package/skills/worktree/evals/scorecard.json +2 -2
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -206
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- package/scripts/beads-context.sh +0 -577
- package/scripts/beads-migrate-to-dolt.sh +0 -7
- package/scripts/beads-upgrade-smoke.sh +0 -284
- package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
const fs = require('node:fs');
|
|
2
2
|
const path = require('node:path');
|
|
3
|
-
const
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
4
|
const { redact } = require('./audit-evidence');
|
|
5
|
+
const { appendCappedJsonlRecord } = require('./capped-jsonl-log');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Protected-state decisions are logged locally rather than to the kernel event
|
|
9
|
+
* stream: kernel events are issue-scoped and async, and this runs inside a
|
|
10
|
+
* synchronous pre-commit hook that has no issue id and must not open the kernel
|
|
11
|
+
* SQLite handle. Capped so a repeated blocked commit cannot grow it unbounded.
|
|
12
|
+
*/
|
|
13
|
+
const PROTECTED_STATE_AUDIT_LOG = '.forge/protected-state-audit.jsonl';
|
|
14
|
+
const PROTECTED_STATE_AUDIT_MAX_RECORDS = 500;
|
|
5
15
|
|
|
6
16
|
function normalizeRepoPath(filePath) {
|
|
7
17
|
return String(filePath || '')
|
|
@@ -10,6 +20,40 @@ function normalizeRepoPath(filePath) {
|
|
|
10
20
|
.replace(/\/+/g, '/');
|
|
11
21
|
}
|
|
12
22
|
|
|
23
|
+
function hashProtectedContent(content) {
|
|
24
|
+
const bytes = Buffer.isBuffer(content) ? content : Buffer.from(String(content), 'utf8');
|
|
25
|
+
return `sha256:${crypto.createHash('sha256').update(bytes).digest('hex')}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function createProtectedStateAuditRecord({
|
|
29
|
+
actor,
|
|
30
|
+
surface,
|
|
31
|
+
path: filePath,
|
|
32
|
+
content,
|
|
33
|
+
operation = 'generate',
|
|
34
|
+
viaForgeApi = false,
|
|
35
|
+
sourceHead,
|
|
36
|
+
}) {
|
|
37
|
+
if (!actor || !surface || !filePath || content === undefined) {
|
|
38
|
+
throw new TypeError('Protected state audit record requires actor, surface, path, and content');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
kind: 'protected_state_write',
|
|
43
|
+
actor,
|
|
44
|
+
path: normalizeRepoPath(filePath),
|
|
45
|
+
decision: 'allowed',
|
|
46
|
+
requiredSurface: surface,
|
|
47
|
+
declaredSurface: surface,
|
|
48
|
+
operation,
|
|
49
|
+
viaForgeApi: viaForgeApi === true,
|
|
50
|
+
...(sourceHead ? { sourceHead } : {}),
|
|
51
|
+
contentHash: hashProtectedContent(content),
|
|
52
|
+
reason: `Forge API generated content for protected surface: ${surface}.`,
|
|
53
|
+
repairHint: null,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
13
57
|
function startsWithAny(filePath, prefixes) {
|
|
14
58
|
return prefixes.some(prefix => filePath === prefix || filePath.startsWith(`${prefix}/`));
|
|
15
59
|
}
|
|
@@ -42,13 +86,13 @@ const PROTECTED_SURFACES = [
|
|
|
42
86
|
'.forge/agent-log.ndjson',
|
|
43
87
|
'.beads/interactions.jsonl',
|
|
44
88
|
].includes(filePath),
|
|
45
|
-
repairHint: 'append-only logs must be written by the Forge
|
|
89
|
+
repairHint: 'append-only logs must be written by the Forge audit writer; do not rewrite existing log content.',
|
|
46
90
|
},
|
|
47
91
|
{
|
|
48
92
|
id: 'beads_state',
|
|
49
|
-
label: 'Beads state',
|
|
93
|
+
label: 'Legacy Beads state',
|
|
50
94
|
matches: filePath => startsWithAny(filePath, ['.beads']),
|
|
51
|
-
repairHint: '
|
|
95
|
+
repairHint: 'A legacy .beads directory is import-only state. Import it with `forge migrate --from beads`, then use Forge issue commands such as forge ready or forge close.',
|
|
52
96
|
},
|
|
53
97
|
{
|
|
54
98
|
id: 'forge_config',
|
|
@@ -164,8 +208,12 @@ function nearestExistingPath(candidate) {
|
|
|
164
208
|
return current;
|
|
165
209
|
}
|
|
166
210
|
|
|
211
|
+
function realpathNearestExistingPath(candidate) {
|
|
212
|
+
return fs.realpathSync(nearestExistingPath(candidate));
|
|
213
|
+
}
|
|
214
|
+
|
|
167
215
|
function assertNoAncestorSymlinkEscape(root, target) {
|
|
168
|
-
const realRoot =
|
|
216
|
+
const realRoot = realpathNearestExistingPath(root);
|
|
169
217
|
const existing = nearestExistingPath(path.dirname(target));
|
|
170
218
|
const existingStat = lstatIfPresent(existing);
|
|
171
219
|
if (existing !== root && existingStat?.isSymbolicLink()) {
|
|
@@ -177,7 +225,7 @@ function assertNoAncestorSymlinkEscape(root, target) {
|
|
|
177
225
|
};
|
|
178
226
|
}
|
|
179
227
|
|
|
180
|
-
const realExisting =
|
|
228
|
+
const realExisting = realpathNearestExistingPath(existing);
|
|
181
229
|
if (!pathStaysInsideRoot(realRoot, realExisting)) {
|
|
182
230
|
return {
|
|
183
231
|
allowed: false,
|
|
@@ -191,8 +239,8 @@ function assertNoAncestorSymlinkEscape(root, target) {
|
|
|
191
239
|
|
|
192
240
|
function assertNoSymlinkEscape(root, target) {
|
|
193
241
|
const parent = path.dirname(target);
|
|
194
|
-
const realRoot =
|
|
195
|
-
const existingParent =
|
|
242
|
+
const realRoot = realpathNearestExistingPath(root);
|
|
243
|
+
const existingParent = realpathNearestExistingPath(parent);
|
|
196
244
|
if (!pathStaysInsideRoot(realRoot, existingParent)) {
|
|
197
245
|
return {
|
|
198
246
|
allowed: false,
|
|
@@ -258,6 +306,89 @@ function assertProtectedWriteAllowed(filePath, options = {}) {
|
|
|
258
306
|
};
|
|
259
307
|
}
|
|
260
308
|
|
|
309
|
+
function fileIdentity(stat) {
|
|
310
|
+
return `${stat.dev}:${stat.ino}`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function blockedAtomicDecision(decision, reason) {
|
|
314
|
+
return {
|
|
315
|
+
...decision,
|
|
316
|
+
allowed: false,
|
|
317
|
+
decision: 'blocked',
|
|
318
|
+
reason,
|
|
319
|
+
repairHint: 'Retry through the owning Forge command after confirming the protected path is unchanged.',
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function captureParentBoundary(root, target) {
|
|
324
|
+
const parent = path.dirname(target);
|
|
325
|
+
const stat = lstatIfPresent(parent);
|
|
326
|
+
if (!stat?.isDirectory() || stat.isSymbolicLink()) {
|
|
327
|
+
return { ok: false, reason: `Protected write parent boundary changed: ${parent}` };
|
|
328
|
+
}
|
|
329
|
+
const realRoot = fs.realpathSync(root);
|
|
330
|
+
const realParent = fs.realpathSync(parent);
|
|
331
|
+
if (!pathStaysInsideRoot(realRoot, realParent)) {
|
|
332
|
+
return { ok: false, reason: `Protected write parent boundary changed outside project root: ${parent}` };
|
|
333
|
+
}
|
|
334
|
+
return { ok: true, parent, realParent, identity: fileIdentity(stat) };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function captureTargetState(target) {
|
|
338
|
+
const before = lstatIfPresent(target);
|
|
339
|
+
if (!before) return { ok: true, exists: false };
|
|
340
|
+
if (!before.isFile() || before.isSymbolicLink()) {
|
|
341
|
+
return { ok: false, reason: `Protected write target boundary changed: ${target}` };
|
|
342
|
+
}
|
|
343
|
+
const content = fs.readFileSync(target);
|
|
344
|
+
const after = fs.lstatSync(target);
|
|
345
|
+
if (fileIdentity(before) !== fileIdentity(after) || after.isSymbolicLink()) {
|
|
346
|
+
return { ok: false, reason: `Protected write target boundary changed while reading: ${target}` };
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
ok: true,
|
|
350
|
+
exists: true,
|
|
351
|
+
identity: fileIdentity(after),
|
|
352
|
+
content,
|
|
353
|
+
contentHash: hashProtectedContent(content),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function sameParentBoundary(left, right) {
|
|
358
|
+
return left.ok && right.ok && left.realParent === right.realParent && left.identity === right.identity;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function sameTargetState(left, right) {
|
|
362
|
+
if (!left.ok || !right.ok || left.exists !== right.exists) return false;
|
|
363
|
+
if (!left.exists) return true;
|
|
364
|
+
return left.identity === right.identity && left.contentHash === right.contentHash;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function expectedTargetMatches(state, expectedContent) {
|
|
368
|
+
if (expectedContent === null) return !state.exists;
|
|
369
|
+
if (!state.exists) return false;
|
|
370
|
+
const expected = Buffer.isBuffer(expectedContent)
|
|
371
|
+
? expectedContent
|
|
372
|
+
: Buffer.from(String(expectedContent), 'utf8');
|
|
373
|
+
return state.content.equals(expected);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function expectedTargetChanged(state, options) {
|
|
377
|
+
return Object.prototype.hasOwnProperty.call(options, 'expectedContent')
|
|
378
|
+
&& !expectedTargetMatches(state, options.expectedContent);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function cleanupOwnedTemp(tempPath, identity) {
|
|
382
|
+
try {
|
|
383
|
+
const current = fs.lstatSync(tempPath);
|
|
384
|
+
if (current.isFile() && !current.isSymbolicLink() && fileIdentity(current) === identity) {
|
|
385
|
+
fs.unlinkSync(tempPath);
|
|
386
|
+
}
|
|
387
|
+
} catch (error) {
|
|
388
|
+
if (error.code !== 'ENOENT') throw error;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
261
392
|
function writeProtectedFile(projectRoot, filePath, content, options = {}) {
|
|
262
393
|
const resolved = resolveRepoRelativePath(projectRoot, filePath);
|
|
263
394
|
if (!resolved.insideRoot) {
|
|
@@ -296,8 +427,88 @@ function writeProtectedFile(projectRoot, filePath, content, options = {}) {
|
|
|
296
427
|
};
|
|
297
428
|
}
|
|
298
429
|
|
|
299
|
-
|
|
300
|
-
return
|
|
430
|
+
const initialParent = captureParentBoundary(resolved.root, resolved.target);
|
|
431
|
+
if (!initialParent.ok) return blockedAtomicDecision(decision, initialParent.reason);
|
|
432
|
+
const initialTarget = captureTargetState(resolved.target);
|
|
433
|
+
if (!initialTarget.ok) return blockedAtomicDecision(decision, initialTarget.reason);
|
|
434
|
+
if (expectedTargetChanged(initialTarget, options)) {
|
|
435
|
+
return blockedAtomicDecision(decision, 'Protected workflow has a concurrent content change; refusing to overwrite it.');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const bytes = Buffer.isBuffer(content) ? content : Buffer.from(String(content), options.encoding || 'utf8');
|
|
439
|
+
const tempPath = path.join(
|
|
440
|
+
initialParent.parent,
|
|
441
|
+
`.${path.basename(resolved.target)}.forge-${process.pid}-${crypto.randomUUID()}.tmp`,
|
|
442
|
+
);
|
|
443
|
+
let tempIdentity;
|
|
444
|
+
try {
|
|
445
|
+
const flags = fs.constants.O_NOFOLLOW
|
|
446
|
+
? fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW
|
|
447
|
+
: 'wx';
|
|
448
|
+
const fd = fs.openSync(tempPath, flags, 0o600);
|
|
449
|
+
try {
|
|
450
|
+
fs.writeFileSync(fd, bytes);
|
|
451
|
+
fs.fsyncSync(fd);
|
|
452
|
+
tempIdentity = fileIdentity(fs.fstatSync(fd));
|
|
453
|
+
} finally {
|
|
454
|
+
fs.closeSync(fd);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (typeof options.beforeAtomicCommit === 'function') {
|
|
458
|
+
options.beforeAtomicCommit({ parent: initialParent.parent, target: resolved.target, tempPath });
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const finalParent = captureParentBoundary(resolved.root, resolved.target);
|
|
462
|
+
const finalTarget = captureTargetState(resolved.target);
|
|
463
|
+
if (!sameParentBoundary(initialParent, finalParent) || !sameTargetState(initialTarget, finalTarget)) {
|
|
464
|
+
return blockedAtomicDecision(decision, 'Protected write boundary changed before atomic replacement.');
|
|
465
|
+
}
|
|
466
|
+
if (expectedTargetChanged(finalTarget, options)) {
|
|
467
|
+
return blockedAtomicDecision(decision, 'Protected workflow has a concurrent content change; refusing to overwrite it.');
|
|
468
|
+
}
|
|
469
|
+
const tempState = fs.lstatSync(tempPath);
|
|
470
|
+
if (!tempState.isFile() || tempState.isSymbolicLink() || fileIdentity(tempState) !== tempIdentity) {
|
|
471
|
+
return blockedAtomicDecision(decision, 'Protected write temporary-file identity changed before atomic replacement.');
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
fs.renameSync(tempPath, resolved.target);
|
|
475
|
+
tempIdentity = null;
|
|
476
|
+
return { ...decision, contentHash: hashProtectedContent(bytes), fullPath: resolved.target };
|
|
477
|
+
} catch (error) {
|
|
478
|
+
return blockedAtomicDecision(decision, `Protected atomic write failed: ${error.message}`);
|
|
479
|
+
} finally {
|
|
480
|
+
if (tempIdentity) cleanupOwnedTemp(tempPath, tempIdentity);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function removeProtectedFile(projectRoot, filePath, options = {}) {
|
|
485
|
+
const resolved = resolveRepoRelativePath(projectRoot, filePath);
|
|
486
|
+
if (!resolved.insideRoot) {
|
|
487
|
+
return blockedAtomicDecision({}, `Protected remove path escapes project root: ${filePath}`);
|
|
488
|
+
}
|
|
489
|
+
const decision = assertProtectedWriteAllowed(resolved.relativePath, {
|
|
490
|
+
...options,
|
|
491
|
+
operation: options.operation || 'remove',
|
|
492
|
+
});
|
|
493
|
+
if (!decision.allowed) return decision;
|
|
494
|
+
const initialParent = captureParentBoundary(resolved.root, resolved.target);
|
|
495
|
+
const initialTarget = captureTargetState(resolved.target);
|
|
496
|
+
if (!initialParent.ok || !initialTarget.ok) {
|
|
497
|
+
return blockedAtomicDecision(decision, initialParent.reason || initialTarget.reason);
|
|
498
|
+
}
|
|
499
|
+
if (!Object.prototype.hasOwnProperty.call(options, 'expectedContent') || !expectedTargetMatches(initialTarget, options.expectedContent)) {
|
|
500
|
+
return blockedAtomicDecision(decision, 'Protected workflow has a concurrent content change; refusing to remove it.');
|
|
501
|
+
}
|
|
502
|
+
if (typeof options.beforeAtomicCommit === 'function') {
|
|
503
|
+
options.beforeAtomicCommit({ parent: initialParent.parent, target: resolved.target });
|
|
504
|
+
}
|
|
505
|
+
const finalParent = captureParentBoundary(resolved.root, resolved.target);
|
|
506
|
+
const finalTarget = captureTargetState(resolved.target);
|
|
507
|
+
if (!sameParentBoundary(initialParent, finalParent) || !sameTargetState(initialTarget, finalTarget)) {
|
|
508
|
+
return blockedAtomicDecision(decision, 'Protected remove boundary changed before exact-content removal.');
|
|
509
|
+
}
|
|
510
|
+
fs.unlinkSync(resolved.target);
|
|
511
|
+
return { ...decision, removed: true, fullPath: resolved.target };
|
|
301
512
|
}
|
|
302
513
|
|
|
303
514
|
function buildProtectedStateAuditEvent(decision) {
|
|
@@ -313,12 +524,18 @@ function buildProtectedStateAuditEvent(decision) {
|
|
|
313
524
|
requiredSurface: decision.requiredSurface,
|
|
314
525
|
declaredSurface: decision.declaredSurface || null,
|
|
315
526
|
operation: decision.operation || 'write',
|
|
527
|
+
viaForgeApi: decision.viaForgeApi === true,
|
|
528
|
+
...(decision.sourceHead ? { sourceHead: decision.sourceHead } : {}),
|
|
529
|
+
contentHash: decision.contentHash || null,
|
|
316
530
|
reason: decision.reason,
|
|
317
531
|
repairHint: decision.repairHint,
|
|
318
532
|
metadata: {
|
|
319
533
|
actor: decision.actor || 'unknown',
|
|
320
534
|
path: decision.path,
|
|
321
535
|
operation: decision.operation || 'write',
|
|
536
|
+
viaForgeApi: decision.viaForgeApi === true,
|
|
537
|
+
...(decision.sourceHead ? { sourceHead: decision.sourceHead } : {}),
|
|
538
|
+
contentHash: decision.contentHash || null,
|
|
322
539
|
requiredSurface: decision.requiredSurface,
|
|
323
540
|
declaredSurface: decision.declaredSurface || null,
|
|
324
541
|
decision: decision.decision,
|
|
@@ -330,51 +547,31 @@ function buildProtectedStateAuditEvent(decision) {
|
|
|
330
547
|
return redact(event);
|
|
331
548
|
}
|
|
332
549
|
|
|
550
|
+
/**
|
|
551
|
+
* Best-effort: a failed audit write is reported to the caller, never thrown, so
|
|
552
|
+
* the protected-state decision itself still stands.
|
|
553
|
+
*/
|
|
333
554
|
function recordProtectedStateAuditEvent(decision, options = {}) {
|
|
334
555
|
const event = buildProtectedStateAuditEvent(decision);
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
const args = [
|
|
338
|
-
'audit',
|
|
339
|
-
'record',
|
|
340
|
-
'--json',
|
|
341
|
-
'--kind',
|
|
342
|
-
'protected_state_write',
|
|
343
|
-
'--model',
|
|
344
|
-
'forge-protected-state',
|
|
345
|
-
'--prompt',
|
|
346
|
-
JSON.stringify({
|
|
347
|
-
actor: event.actor,
|
|
348
|
-
path: event.path,
|
|
349
|
-
operation: event.operation,
|
|
350
|
-
}),
|
|
351
|
-
'--response',
|
|
352
|
-
JSON.stringify({
|
|
353
|
-
decision: event.decision,
|
|
354
|
-
requiredSurface: event.requiredSurface,
|
|
355
|
-
repairHint: event.repairHint,
|
|
356
|
-
}),
|
|
357
|
-
];
|
|
358
|
-
|
|
359
|
-
if (event.metadata) {
|
|
360
|
-
args.push('--meta-json', JSON.stringify(event.metadata));
|
|
361
|
-
}
|
|
556
|
+
const logPath = path.resolve(options.cwd || process.cwd(), PROTECTED_STATE_AUDIT_LOG);
|
|
557
|
+
const appendRecord = options.appendRecord || appendCappedJsonlRecord;
|
|
362
558
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
timeout: options.timeoutMs || 5000,
|
|
368
|
-
});
|
|
369
|
-
return { success: true, event, output };
|
|
559
|
+
try {
|
|
560
|
+
const record = { ...event, recordedAt: options.now || new Date().toISOString() };
|
|
561
|
+
appendRecord(logPath, record, options.maxRecords || PROTECTED_STATE_AUDIT_MAX_RECORDS);
|
|
562
|
+
return { success: true, event, logPath };
|
|
370
563
|
} catch (error) {
|
|
371
|
-
return { success: false, event, error: error.message };
|
|
564
|
+
return { success: false, event, logPath, error: error.message };
|
|
372
565
|
}
|
|
373
566
|
}
|
|
374
567
|
|
|
375
568
|
module.exports = {
|
|
376
569
|
PROTECTED_SURFACES,
|
|
570
|
+
PROTECTED_STATE_AUDIT_LOG,
|
|
571
|
+
PROTECTED_STATE_AUDIT_MAX_RECORDS,
|
|
377
572
|
normalizeRepoPath,
|
|
573
|
+
hashProtectedContent,
|
|
574
|
+
createProtectedStateAuditRecord,
|
|
378
575
|
resolveRepoRelativePath,
|
|
379
576
|
lstatIfPresent,
|
|
380
577
|
assertNoAncestorSymlinkEscape,
|
|
@@ -382,6 +579,7 @@ module.exports = {
|
|
|
382
579
|
classifyProtectedPath,
|
|
383
580
|
assertProtectedWriteAllowed,
|
|
384
581
|
writeProtectedFile,
|
|
582
|
+
removeProtectedFile,
|
|
385
583
|
buildProtectedStateAuditEvent,
|
|
386
584
|
recordProtectedStateAuditEvent,
|
|
387
585
|
};
|
package/lib/release-readiness.js
CHANGED
|
@@ -413,13 +413,27 @@ function readSyncManifestScanRoots(projectRoot) {
|
|
|
413
413
|
}
|
|
414
414
|
}
|
|
415
415
|
|
|
416
|
+
// Scan roots reach here in whatever spelling their source used — a plugin manifest
|
|
417
|
+
// directory entry, a sync-manifest file, an explicit scanRoots option — so they are
|
|
418
|
+
// normalized once, at the single place they are resolved. walkFiles would join a trailing
|
|
419
|
+
// separator away and scan the tree regardless, while isBdCensusPath would build `root//`
|
|
420
|
+
// and match nothing under it; normalizing here is what keeps the census and the
|
|
421
|
+
// pre-commit predicate from disagreeing about which files count.
|
|
422
|
+
function normalizeScanRoot(root) {
|
|
423
|
+
if (typeof root !== 'string' || root.length === 0) {
|
|
424
|
+
return '';
|
|
425
|
+
}
|
|
426
|
+
return normalizeRepoPath(root).replace(/[/\\]+$/, '');
|
|
427
|
+
}
|
|
428
|
+
|
|
416
429
|
function getScanRoots(projectRoot, options = {}) {
|
|
417
430
|
const roots = options.scanRoots || getDefaultScanRoots(projectRoot);
|
|
418
431
|
return [
|
|
419
|
-
...new Set(
|
|
420
|
-
...roots,
|
|
421
|
-
|
|
422
|
-
|
|
432
|
+
...new Set(
|
|
433
|
+
[...roots, ...readSyncManifestScanRoots(projectRoot)]
|
|
434
|
+
.map(normalizeScanRoot)
|
|
435
|
+
.filter(Boolean),
|
|
436
|
+
),
|
|
423
437
|
];
|
|
424
438
|
}
|
|
425
439
|
|
|
@@ -496,9 +510,8 @@ function addCallSites(groups, group, relativePath, lineEntries) {
|
|
|
496
510
|
function trackedRepoFiles(projectRoot) {
|
|
497
511
|
let output;
|
|
498
512
|
try {
|
|
499
|
-
//
|
|
500
|
-
|
|
501
|
-
output = execFileSync('git', ['-C', projectRoot, 'ls-files', '-z'], {
|
|
513
|
+
// `git` is resolved from PATH by design; Forge cannot hardcode a portable absolute path.
|
|
514
|
+
output = execFileSync('git', ['-C', projectRoot, 'ls-files', '-z'], { // NOSONAR S4036 -- Fixed local Git executable with structured non-shell arguments is the trusted repository toolchain boundary.
|
|
502
515
|
encoding: 'utf8',
|
|
503
516
|
maxBuffer: 64 * 1024 * 1024,
|
|
504
517
|
});
|
|
@@ -557,6 +570,38 @@ function auditBdCallSites(projectRoot, options = {}) {
|
|
|
557
570
|
};
|
|
558
571
|
}
|
|
559
572
|
|
|
573
|
+
// Would a change to this repo-relative path shift the bd call-site census?
|
|
574
|
+
//
|
|
575
|
+
// The pre-commit auto-heal (scripts/sync-d20-audit.js) asks this before doing any work,
|
|
576
|
+
// so it must answer exactly what auditBdCallSites would scan. It is therefore assembled
|
|
577
|
+
// from that walk's own pieces — getScanRoots, shouldSkipRelativePath, isTextFile,
|
|
578
|
+
// SKIP_DIR_NAMES — rather than a parallel list that could drift from the gate.
|
|
579
|
+
function isBdCensusPath(projectRoot, filePath, options = {}) {
|
|
580
|
+
const relativePath = safeNormalizeRepoPath(projectRoot, filePath);
|
|
581
|
+
if (!relativePath || shouldSkipRelativePath(relativePath) || !isTextFile(relativePath, projectRoot)) {
|
|
582
|
+
return false;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// getScanRoots already returns normalized roots — re-normalizing here would be a second
|
|
586
|
+
// copy of that rule, free to drift from the one auditBdCallSites walks.
|
|
587
|
+
return getScanRoots(projectRoot, options)
|
|
588
|
+
.some(root => {
|
|
589
|
+
// walkFiles applies no directory skips to a root that is itself a file.
|
|
590
|
+
if (relativePath === root) {
|
|
591
|
+
return true;
|
|
592
|
+
}
|
|
593
|
+
if (!relativePath.startsWith(`${root}/`)) {
|
|
594
|
+
return false;
|
|
595
|
+
}
|
|
596
|
+
// Below a directory root it refuses to descend into skipped directories.
|
|
597
|
+
return !relativePath
|
|
598
|
+
.slice(root.length + 1)
|
|
599
|
+
.split('/')
|
|
600
|
+
.slice(0, -1)
|
|
601
|
+
.some(directory => SKIP_DIR_NAMES.has(directory));
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
|
|
560
605
|
function readRepoFile(projectRoot, relativePath) {
|
|
561
606
|
const fullPath = absolutePath(projectRoot, relativePath);
|
|
562
607
|
return fs.existsSync(fullPath) ? fs.readFileSync(fullPath, 'utf8') : '';
|
|
@@ -2096,6 +2141,7 @@ module.exports = {
|
|
|
2096
2141
|
auditBdCallSites,
|
|
2097
2142
|
buildReadinessReport,
|
|
2098
2143
|
canonicalizeAuditArtifact,
|
|
2144
|
+
isBdCensusPath,
|
|
2099
2145
|
renderBdCallSiteAuditMarkdown,
|
|
2100
2146
|
renderReadinessReport,
|
|
2101
2147
|
writeAuditArtifact,
|
package/lib/review-adapter.js
CHANGED
|
@@ -8,6 +8,68 @@ const REQUIRED_REVIEW_ADAPTER_METHODS = [
|
|
|
8
8
|
'score',
|
|
9
9
|
];
|
|
10
10
|
|
|
11
|
+
const REVIEW_EVIDENCE_LIMITS = Object.freeze({
|
|
12
|
+
maxTextChars: 256,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const SECRET_PATTERNS = [
|
|
16
|
+
/\bBearer\s+\S+/gi,
|
|
17
|
+
/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g,
|
|
18
|
+
/\bgithub_pat_\w{20,}\b/gi,
|
|
19
|
+
/\b(?:sk_(?:live|test)_[A-Za-z0-9]{16,}|sk-[A-Za-z0-9]{16,})\b/g,
|
|
20
|
+
/\bAKIA[0-9A-Z]{16}\b/gi,
|
|
21
|
+
/\b(?:api[_ -]?key|authorization|credential|password|private[_ -]?key|secret|token)\s*[:=]\s*\S{8,}/gi,
|
|
22
|
+
];
|
|
23
|
+
const PRIVATE_PATH_PATTERNS = [
|
|
24
|
+
/[a-z]:\\Users\\[^\\\s]+(?:\\[^\s]*)?/gi,
|
|
25
|
+
/\/(?:Users|home)\/[^/\s]+(?:\/[^\s]*)?/g,
|
|
26
|
+
/\/root\/[^\s"'<>{}[\]]+/g,
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function stripControlCharacters(value) {
|
|
30
|
+
let output = '';
|
|
31
|
+
for (const character of value) {
|
|
32
|
+
const code = character.codePointAt(0);
|
|
33
|
+
const control = (code >= 0 && code <= 8)
|
|
34
|
+
|| code === 11
|
|
35
|
+
|| code === 12
|
|
36
|
+
|| (code >= 14 && code <= 31)
|
|
37
|
+
|| code === 127;
|
|
38
|
+
output += control ? ' ' : character;
|
|
39
|
+
}
|
|
40
|
+
return output;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeEvidenceText(value, options = {}) {
|
|
44
|
+
const requestedLimit = options.maxChars ?? REVIEW_EVIDENCE_LIMITS.maxTextChars;
|
|
45
|
+
const maxChars = Number.isInteger(requestedLimit) && requestedLimit > 0
|
|
46
|
+
? requestedLimit
|
|
47
|
+
: REVIEW_EVIDENCE_LIMITS.maxTextChars;
|
|
48
|
+
let text = stripControlCharacters(String(value ?? ''))
|
|
49
|
+
.replace(/[\r\n\t]+/g, ' ');
|
|
50
|
+
for (const pattern of SECRET_PATTERNS) text = text.replace(pattern, '[REDACTED]');
|
|
51
|
+
for (const pattern of PRIVATE_PATH_PATTERNS) text = text.replace(pattern, '[REDACTED_PATH]');
|
|
52
|
+
text = text.replace(/\s+/g, ' ').trim();
|
|
53
|
+
return text.length <= maxChars ? text : `${text.slice(0, Math.max(0, maxChars - 1))}…`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function classifyReviewActor(value) {
|
|
57
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return 'unknown';
|
|
58
|
+
const typename = value.authorTypename
|
|
59
|
+
|| value.authorType
|
|
60
|
+
|| value.actorTypename
|
|
61
|
+
|| value.actorType
|
|
62
|
+
|| value.author?.__typename;
|
|
63
|
+
const actorKinds = {
|
|
64
|
+
Bot: 'bot',
|
|
65
|
+
User: 'user',
|
|
66
|
+
Organization: 'organization',
|
|
67
|
+
Mannequin: 'mannequin',
|
|
68
|
+
EnterpriseUserAccount: 'enterprise-user',
|
|
69
|
+
};
|
|
70
|
+
return actorKinds[typename] || 'unknown';
|
|
71
|
+
}
|
|
72
|
+
|
|
11
73
|
class ReviewAdapter {
|
|
12
74
|
constructor(options = {}) {
|
|
13
75
|
this.id = options.id || 'review-adapter';
|
|
@@ -62,7 +124,10 @@ function validateReviewAdapter(adapter) {
|
|
|
62
124
|
}
|
|
63
125
|
|
|
64
126
|
module.exports = {
|
|
127
|
+
REVIEW_EVIDENCE_LIMITS,
|
|
65
128
|
ReviewAdapter,
|
|
66
129
|
REQUIRED_REVIEW_ADAPTER_METHODS,
|
|
130
|
+
classifyReviewActor,
|
|
131
|
+
normalizeEvidenceText,
|
|
67
132
|
validateReviewAdapter,
|
|
68
133
|
};
|
package/lib/shell-utils.js
CHANGED
|
@@ -105,7 +105,7 @@ function secureExecFileSync(command, args = [], options = {}) {
|
|
|
105
105
|
// metacharacters, so fold them into a single command line and pass no args array —
|
|
106
106
|
// identical execution through cmd.exe (npm.cmd/npx.cmd shims), no deprecation warning.
|
|
107
107
|
const commandLine = [command, ...args].join(' ');
|
|
108
|
-
return _execFileSync(commandLine, [], { ...execOptions, shell: true });
|
|
108
|
+
return _execFileSync(commandLine, [], { env: process.env, ...execOptions, shell: true });
|
|
109
109
|
}
|
|
110
110
|
return _execFileSync(spec.file, args, execOptions);
|
|
111
111
|
}
|