opencode-workflow-guard 1.18.1 → 1.19.1
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/README.md +4 -4
- package/docs/installation.md +23 -0
- package/docs/policies.md +31 -3
- package/docs/testing.md +3 -1
- package/package.json +2 -2
- package/src/lib/audit.ts +52 -2
- package/src/lib/custom-tools.ts +154 -3
- package/src/lib/dogfood.ts +147 -0
- package/src/lib/evidence.ts +19 -1
- package/src/lib/guard-dispatcher.ts +134 -13
- package/src/lib/permission-seat.ts +79 -1
- package/src/lib/shell.ts +702 -5
- package/src/lib/state.ts +301 -2
- package/src/lib/types.ts +71 -1
- package/src/lib/v2-plugin.ts +32 -1
- package/src/policies/boundary.ts +307 -24
- package/src/policies/git.ts +144 -22
- package/src/policies/interpreter.ts +93 -16
- package/src/workflow-guard.ts +56 -5
- package/test/test-e2e.mts +4 -1
- package/test/test.mts +1286 -20
package/test/test.mts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdtempSync, writeFileSync, rmSync, symlinkSync, readFileSync, existsSync, mkdirSync, lstatSync, chmodSync, statSync, utimesSync, readdirSync, realpathSync } from "node:fs";
|
|
2
2
|
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join, resolve } from "node:path";
|
|
3
|
+
import { tmpdir, homedir } from "node:os";
|
|
4
|
+
import { join, resolve, dirname } from "node:path";
|
|
5
5
|
import { pathToFileURL } from "node:url";
|
|
6
6
|
import { DatabaseSync } from "node:sqlite";
|
|
7
7
|
import type { PluginModule } from "@opencode-ai/plugin";
|
|
@@ -55,10 +55,24 @@ import {
|
|
|
55
55
|
summarizeInput,
|
|
56
56
|
extractReviewFollowups,
|
|
57
57
|
buildReviewRubric,
|
|
58
|
+
buildWorkflowGuardSystemGuidance,
|
|
58
59
|
recordReviewResult,
|
|
59
60
|
getLastReviewResult,
|
|
61
|
+
recordDogfoodResult,
|
|
62
|
+
getLastDogfoodResultForWorkspace,
|
|
63
|
+
getDogfoodRecordsForWorkspace,
|
|
64
|
+
resetDogfoodState,
|
|
65
|
+
isDogfoodRequired,
|
|
66
|
+
getDogfoodDeployMatrix,
|
|
67
|
+
getDogfoodDepth,
|
|
68
|
+
isDogfoodActor,
|
|
69
|
+
evaluateDogfoodGate,
|
|
70
|
+
getDogfoodCacheFilePath,
|
|
71
|
+
persistDogfoodCache,
|
|
72
|
+
loadDogfoodCache,
|
|
60
73
|
verificationEvidence,
|
|
61
74
|
reviewEvidence,
|
|
75
|
+
dogfoodEvidence,
|
|
62
76
|
toolOutcomeEvidence,
|
|
63
77
|
policyDecisionEvidence,
|
|
64
78
|
lifecycleStateEvidence,
|
|
@@ -75,6 +89,11 @@ import {
|
|
|
75
89
|
isBranchAlreadyMergedOrClosed,
|
|
76
90
|
checkMergeConflicts,
|
|
77
91
|
checkBranchBaseIsUpToDate,
|
|
92
|
+
findWorktreeForBranch,
|
|
93
|
+
parseGitInvocation,
|
|
94
|
+
prSourceBranch,
|
|
95
|
+
rebindSessionWorkspaceFromHost,
|
|
96
|
+
forgetSessionWorkspace,
|
|
78
97
|
checkLockfileSync,
|
|
79
98
|
branchHasDocumentationChange,
|
|
80
99
|
isDocumentationRequired,
|
|
@@ -207,6 +226,10 @@ const call = (
|
|
|
207
226
|
) => guardToolCall(toolName, input, context);
|
|
208
227
|
const shell = (cmd: string) => call("bash", { command: cmd });
|
|
209
228
|
const blocked = (r: unknown): boolean => typeof r === "string";
|
|
229
|
+
// The guard's allow path returns undefined and its block path returns a string
|
|
230
|
+
// (see guardToolCall). Asserting exactly undefined keeps a stray object, such
|
|
231
|
+
// as an unrelated internal error, from reading as an allow.
|
|
232
|
+
const allowed = (r: unknown): boolean => r === undefined;
|
|
210
233
|
|
|
211
234
|
console.log("- Policy 1: native todo gate -");
|
|
212
235
|
todo("s-empty");
|
|
@@ -2191,6 +2214,27 @@ check("GIT_DIR prefix to external repository is blocked for writes", blocked(awa
|
|
|
2191
2214
|
check("env GIT_DIR prefix to external repository is blocked for writes", blocked(await shell(`env GIT_DIR=${externalRepo}/.git git commit -m test`)));
|
|
2192
2215
|
check("GIT_DIR prefix push to external repository is blocked", blocked(await shell(`GIT_DIR=${externalRepo}/.git git push origin feat/external`)));
|
|
2193
2216
|
|
|
2217
|
+
// An indeterminate repository is outside the boundary, even when the session
|
|
2218
|
+
// itself sits in a git workspace. `findGitRoot` walks up to the nearest
|
|
2219
|
+
// existing ancestor, so `$OTHER_REPO` resolved to the workspace's own root and
|
|
2220
|
+
// passed the same-repo test that admits linked worktrees. The feature branch
|
|
2221
|
+
// keeps the protected-branch gate quiet so this pins the boundary decision.
|
|
2222
|
+
const varRepo = mkdtempSync(join(tmpdir(), "wg-var-repo-"));
|
|
2223
|
+
spawnSync("git", ["init", "-b", "feat/var"], { cwd: varRepo });
|
|
2224
|
+
setWorkspaceRoot(varRepo);
|
|
2225
|
+
const variableGit = await shell("git -C $OTHER_REPO commit -m x");
|
|
2226
|
+
check(
|
|
2227
|
+
"git -C with an unresolvable variable stays blocked inside a git workspace",
|
|
2228
|
+
blocked(variableGit) && String(variableGit).includes("unresolvable variable reference"),
|
|
2229
|
+
);
|
|
2230
|
+
const variableGitDir = await shell("GIT_DIR=$OTHER_REPO/.git git push origin feat/x");
|
|
2231
|
+
check(
|
|
2232
|
+
"GIT_DIR with an unresolvable variable stays blocked inside a git workspace",
|
|
2233
|
+
blocked(variableGitDir) && String(variableGitDir).includes("unresolvable variable reference"),
|
|
2234
|
+
);
|
|
2235
|
+
setWorkspaceRoot(root);
|
|
2236
|
+
rmSync(varRepo, { recursive: true, force: true });
|
|
2237
|
+
|
|
2194
2238
|
rmSync(externalRepo, { recursive: true, force: true });
|
|
2195
2239
|
|
|
2196
2240
|
// 5. Verification isolation and privilege checks
|
|
@@ -2390,6 +2434,21 @@ check(
|
|
|
2390
2434
|
"verification timeout names a remedy the agent can actually apply",
|
|
2391
2435
|
String(timeoutFinalRes).includes("verifyTimeoutMs") && String(timeoutFinalRes).includes("WORKFLOW_GUARD_VERIFY_TIMEOUT_MS"),
|
|
2392
2436
|
);
|
|
2437
|
+
check(
|
|
2438
|
+
"verification timeout names the user as the actor for a limit the agent cannot raise",
|
|
2439
|
+
String(timeoutFinalRes).includes("Only the user can raise the limit") && String(timeoutFinalRes).includes(".jsonc"),
|
|
2440
|
+
);
|
|
2441
|
+
// Machine consumers (audit, evidence, the V2 seat) branch on the code, not the
|
|
2442
|
+
// prose, so pin the timeout's own code rather than only its message.
|
|
2443
|
+
const timeoutDecision = await guardToolDecision(
|
|
2444
|
+
"todowrite",
|
|
2445
|
+
{ todos: [item("timeout work", "completed")] },
|
|
2446
|
+
{ sessionID: "s-verify-timeout" },
|
|
2447
|
+
);
|
|
2448
|
+
check(
|
|
2449
|
+
"a verification timeout carries its own structured code",
|
|
2450
|
+
timeoutDecision.status === "blocked" && timeoutDecision.code === "verification_timed_out",
|
|
2451
|
+
);
|
|
2393
2452
|
setWorkspaceRoot(verifyRepo);
|
|
2394
2453
|
reloadProjectConfig(verifyRepo);
|
|
2395
2454
|
if (prevTimeoutVerifyEnv !== undefined) process.env.WORKFLOW_GUARD_VERIFY = prevTimeoutVerifyEnv;
|
|
@@ -2888,6 +2947,614 @@ setWorkspaceRoot(root);
|
|
|
2888
2947
|
reloadProjectConfig(root);
|
|
2889
2948
|
resetReviewState();
|
|
2890
2949
|
|
|
2950
|
+
// Dogfood gate: off by default, blocks when opted in without fresh evidence,
|
|
2951
|
+
// refuses self-certification from an actor session, and never caches a HOLD.
|
|
2952
|
+
console.log("- Dogfood gate (pre-deployment evidence) -");
|
|
2953
|
+
const dogfoodPlugin = await (defaultExport?.server ?? WorkflowGuard)({
|
|
2954
|
+
directory: root,
|
|
2955
|
+
worktree: root,
|
|
2956
|
+
client: fakeClient as any,
|
|
2957
|
+
project: {} as any,
|
|
2958
|
+
experimental_workspace: {} as any,
|
|
2959
|
+
serverUrl: new URL("http://localhost:4096"),
|
|
2960
|
+
$: undefined as any,
|
|
2961
|
+
});
|
|
2962
|
+
const dogfoodRepo = mkdtempSync(join(tmpdir(), "wg-dogfood-"));
|
|
2963
|
+
spawnSync("git", ["init", "-b", "feature/dogfood"], { cwd: dogfoodRepo });
|
|
2964
|
+
spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: dogfoodRepo });
|
|
2965
|
+
spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: dogfoodRepo });
|
|
2966
|
+
mkdirSync(join(dogfoodRepo, ".opencode"), { recursive: true });
|
|
2967
|
+
writeFileSync(join(dogfoodRepo, "code.txt"), "shipped\n");
|
|
2968
|
+
spawnSync("git", ["add", "code.txt"], { cwd: dogfoodRepo });
|
|
2969
|
+
spawnSync("git", ["commit", "-m", "base"], { cwd: dogfoodRepo });
|
|
2970
|
+
setWorkspaceRoot(dogfoodRepo);
|
|
2971
|
+
reloadProjectConfig(dogfoodRepo);
|
|
2972
|
+
resetReviewState();
|
|
2973
|
+
resetDogfoodState();
|
|
2974
|
+
process.env.WORKFLOW_GUARD_REQUIRE_REVIEW = "0";
|
|
2975
|
+
|
|
2976
|
+
check("dogfood gate is off by default", isDogfoodRequired(dogfoodRepo) === false);
|
|
2977
|
+
check(
|
|
2978
|
+
"PR creation allowed without dogfood evidence when the gate is off",
|
|
2979
|
+
allowed(await shell("gh pr create --title t --body 'Changelog: update'")),
|
|
2980
|
+
);
|
|
2981
|
+
|
|
2982
|
+
writeFileSync(join(dogfoodRepo, ".opencode", "workflow-guard.json"), JSON.stringify({ requireReview: false, requireDogfood: true }));
|
|
2983
|
+
reloadProjectConfig(dogfoodRepo);
|
|
2984
|
+
check("requireDogfood: true enables the gate", isDogfoodRequired(dogfoodRepo) === true);
|
|
2985
|
+
check(
|
|
2986
|
+
"PR creation blocked when the dogfood gate is on and no run is recorded",
|
|
2987
|
+
blocked(await shell("gh pr create --title t --body 'Changelog: update'")),
|
|
2988
|
+
);
|
|
2989
|
+
const dogfoodBlockedStatus = JSON.parse(String(await dogfoodPlugin.tool?.guard_status?.execute({ directory: dogfoodRepo }, { sessionID: "s-dogfood", worktree: dogfoodRepo, directory: dogfoodRepo } as any)));
|
|
2990
|
+
check(
|
|
2991
|
+
"guard_status reports dogfood as outstanding work",
|
|
2992
|
+
dogfoodBlockedStatus.outstandingRequirements.includes("dogfood") && dogfoodBlockedStatus.projectConfig.requireDogfood === true,
|
|
2993
|
+
);
|
|
2994
|
+
|
|
2995
|
+
check("dogfood gate treats an unset deploy matrix as this host alone", getDogfoodDeployMatrix(dogfoodRepo).length === 0);
|
|
2996
|
+
writeFileSync(join(dogfoodRepo, ".opencode", "workflow-guard.json"), JSON.stringify({ requireReview: false, requireDogfood: true, dogfoodDeployMatrix: [" bluefin ", "bluefin", ""] }));
|
|
2997
|
+
reloadProjectConfig(dogfoodRepo);
|
|
2998
|
+
check("dogfood deploy matrix trims and de-duplicates host labels", JSON.stringify(getDogfoodDeployMatrix(dogfoodRepo)) === JSON.stringify(["bluefin"]));
|
|
2999
|
+
|
|
3000
|
+
// A HOLD is recorded in memory but never durable, and never satisfies the gate.
|
|
3001
|
+
const heldDogfood = recordDogfoodResult({
|
|
3002
|
+
verdict: "hold",
|
|
3003
|
+
scenarioId: "permission-rule-reorder",
|
|
3004
|
+
scenarioSha256: "a".repeat(64),
|
|
3005
|
+
taxonomySha256: "b".repeat(64),
|
|
3006
|
+
taxonomyClass: "permission-bypass",
|
|
3007
|
+
checks: [{ id: "exit-code", status: "pass" }],
|
|
3008
|
+
runCount: 1,
|
|
3009
|
+
host: { label: "bluefin" },
|
|
3010
|
+
workspace: dogfoodRepo,
|
|
3011
|
+
}, "s-dogfood");
|
|
3012
|
+
check("a HOLD verdict does not cache durable evidence", heldDogfood.verdict === "hold" && !existsSync(getDogfoodCacheFilePath()));
|
|
3013
|
+
check("a HOLD verdict leaves the gate unsatisfied", evaluateDogfoodGate(dogfoodRepo).missingHosts.length > 0);
|
|
3014
|
+
|
|
3015
|
+
const passedDogfood = recordDogfoodResult({
|
|
3016
|
+
verdict: "pass",
|
|
3017
|
+
scenarioId: "permission-rule-reorder",
|
|
3018
|
+
scenarioSha256: "a".repeat(64),
|
|
3019
|
+
taxonomySha256: "b".repeat(64),
|
|
3020
|
+
checks: [{ id: "exit-code", status: "pass" }],
|
|
3021
|
+
runCount: 1,
|
|
3022
|
+
host: { label: "bluefin" },
|
|
3023
|
+
workspace: dogfoodRepo,
|
|
3024
|
+
}, "s-dogfood");
|
|
3025
|
+
check("a PASS verdict caches durable evidence", passedDogfood.verdict === "pass" && existsSync(getDogfoodCacheFilePath()));
|
|
3026
|
+
check("a PASS verdict satisfies the gate for its own worktree", evaluateDogfoodGate(dogfoodRepo).missingHosts.length === 0 && evaluateDogfoodGate(dogfoodRepo).matched?.scenarioId === "permission-rule-reorder");
|
|
3027
|
+
check(
|
|
3028
|
+
"PR creation allowed once a passing run is recorded at the current tree",
|
|
3029
|
+
allowed(await shell("gh pr create --title t --body 'Changelog: update'")),
|
|
3030
|
+
);
|
|
3031
|
+
|
|
3032
|
+
// The evidence binds to tracked content: a tracked edit invalidates it.
|
|
3033
|
+
writeFileSync(join(dogfoodRepo, "code.txt"), "changed after the run\n");
|
|
3034
|
+
const staleDogfoodGate = evaluateDogfoodGate(dogfoodRepo);
|
|
3035
|
+
check(
|
|
3036
|
+
"a tracked edit after the run invalidates the evidence with a cause",
|
|
3037
|
+
staleDogfoodGate.missingHosts.length > 0 && /does not match the current worktree contents/.test(staleDogfoodGate.reason ?? ""),
|
|
3038
|
+
);
|
|
3039
|
+
check(
|
|
3040
|
+
"PR creation blocked again after the run's tree changed",
|
|
3041
|
+
blocked(await shell("gh pr create --title t --body 'Changelog: update'")),
|
|
3042
|
+
);
|
|
3043
|
+
spawnSync("git", ["checkout", "--", "code.txt"], { cwd: dogfoodRepo });
|
|
3044
|
+
spawnSync("git", ["commit", "--allow-empty", "-m", "empty commit after run"], { cwd: dogfoodRepo });
|
|
3045
|
+
const commitDriftGate = evaluateDogfoodGate(dogfoodRepo);
|
|
3046
|
+
check(
|
|
3047
|
+
"a new commit after the run is named as commit drift",
|
|
3048
|
+
commitDriftGate.missingHosts.length > 0 && /bound to commit/.test(commitDriftGate.reason ?? ""),
|
|
3049
|
+
);
|
|
3050
|
+
|
|
3051
|
+
// Deploy matrix: a pass on a host outside the matrix does not satisfy it.
|
|
3052
|
+
writeFileSync(join(dogfoodRepo, ".opencode", "workflow-guard.json"), JSON.stringify({ requireReview: false, requireDogfood: true, dogfoodDeployMatrix: ["bluefin", "windows-ci"] }));
|
|
3053
|
+
reloadProjectConfig(dogfoodRepo);
|
|
3054
|
+
const matrixGate = evaluateDogfoodGate(dogfoodRepo);
|
|
3055
|
+
check(
|
|
3056
|
+
"deploy matrix hosts beyond the recorded host stay unsatisfied",
|
|
3057
|
+
matrixGate.missingHosts.includes("bluefin") && matrixGate.missingHosts.includes("windows-ci"),
|
|
3058
|
+
);
|
|
3059
|
+
|
|
3060
|
+
// A second host's record satisfies the rest of the matrix, because records
|
|
3061
|
+
// accumulate instead of replacing each other. Both hosts re-run here at the
|
|
3062
|
+
// current commit, since the earlier bluefin record predates the empty commit.
|
|
3063
|
+
await dogfoodPlugin.tool?.record_dogfood?.execute(
|
|
3064
|
+
{ scenarioId: "permission-rule-reorder", scenarioSha256: "c".repeat(64), taxonomySha256: "d".repeat(64), verdict: "pass", checks: JSON.stringify([{ id: "exit-code", status: "pass" }]), hostLabel: "bluefin", hostOs: "linux", directory: dogfoodRepo },
|
|
3065
|
+
{ sessionID: "s-dogfood", worktree: dogfoodRepo, directory: dogfoodRepo } as any,
|
|
3066
|
+
);
|
|
3067
|
+
const windowsRecord = await dogfoodPlugin.tool?.record_dogfood?.execute(
|
|
3068
|
+
{ scenarioId: "permission-rule-reorder", scenarioSha256: "c".repeat(64), taxonomySha256: "d".repeat(64), verdict: "pass", checks: JSON.stringify([{ id: "exit-code", status: "pass" }]), hostLabel: "windows-ci", hostOs: "windows", directory: dogfoodRepo },
|
|
3069
|
+
{ sessionID: "s-dogfood", worktree: dogfoodRepo, directory: dogfoodRepo } as any,
|
|
3070
|
+
);
|
|
3071
|
+
check("a second matrix host can record its own pass", typeof windowsRecord === "string" && windowsRecord.includes("PASS") && windowsRecord.includes("windows-ci"));
|
|
3072
|
+
const satisfiedMatrix = evaluateDogfoodGate(dogfoodRepo);
|
|
3073
|
+
check(
|
|
3074
|
+
"a multi-host deploy matrix is satisfiable when every host has a fresh pass",
|
|
3075
|
+
satisfiedMatrix.missingHosts.length === 0 && (satisfiedMatrix.matchedRecords ?? []).length === 2,
|
|
3076
|
+
);
|
|
3077
|
+
const cachedPasses = loadDogfoodCache();
|
|
3078
|
+
check(
|
|
3079
|
+
"the durable cache keeps passing records for every matrix host",
|
|
3080
|
+
Array.isArray(cachedPasses) && cachedPasses.every((record) => record.verdict === "pass") && ["bluefin", "windows-ci"].every((host) => cachedPasses.some((record) => record.host.label === host)),
|
|
3081
|
+
);
|
|
3082
|
+
// The PR audit entry must name every host the matrix covered, not just the
|
|
3083
|
+
// newest record, or an auditor would read a multi-host pass as a single-host one.
|
|
3084
|
+
const matrixPr = await shell("gh pr create --title t --body 'Changelog: update'");
|
|
3085
|
+
check("a satisfied multi-host matrix allows PR creation", allowed(matrixPr));
|
|
3086
|
+
const matrixPrAudit = getRecentAuditEntries(500).filter(
|
|
3087
|
+
(entry) => entry.reason === "dogfood_evidence_matched" && (entry.evidence?.dogfoodCoveredHosts ?? []).length > 1,
|
|
3088
|
+
);
|
|
3089
|
+
check(
|
|
3090
|
+
"the PR preflight journals every host the dogfood matrix covered",
|
|
3091
|
+
matrixPrAudit.length > 0 &&
|
|
3092
|
+
JSON.stringify([...(matrixPrAudit.pop()?.evidence?.dogfoodCoveredHosts ?? [])].sort()) === JSON.stringify(["bluefin", "windows-ci"]),
|
|
3093
|
+
);
|
|
3094
|
+
|
|
3095
|
+
// A newer HOLD on a host supersedes that host's older pass: a regression the
|
|
3096
|
+
// host later found must not be hidden behind the earlier green run.
|
|
3097
|
+
recordDogfoodResult({
|
|
3098
|
+
verdict: "hold",
|
|
3099
|
+
scenarioId: "permission-rule-reorder",
|
|
3100
|
+
scenarioSha256: "c".repeat(64),
|
|
3101
|
+
taxonomySha256: "d".repeat(64),
|
|
3102
|
+
taxonomyClass: "permission-bypass",
|
|
3103
|
+
checks: [{ id: "exit-code", status: "hold" }],
|
|
3104
|
+
runCount: 1,
|
|
3105
|
+
host: { label: "windows-ci" },
|
|
3106
|
+
workspace: dogfoodRepo,
|
|
3107
|
+
}, "s-dogfood");
|
|
3108
|
+
const regressedGate = evaluateDogfoodGate(dogfoodRepo);
|
|
3109
|
+
check(
|
|
3110
|
+
"a newer HOLD on one host reopens the gate with its taxonomy class",
|
|
3111
|
+
regressedGate.missingHosts.includes("windows-ci") && /HOLD with class 'permission-bypass'/.test(regressedGate.reason ?? ""),
|
|
3112
|
+
);
|
|
3113
|
+
check(
|
|
3114
|
+
"a HOLD on one host does not discard another host's cached pass",
|
|
3115
|
+
loadDogfoodCache().some((record) => record.host.label === "bluefin") && !loadDogfoodCache().some((record) => record.verdict === "hold"),
|
|
3116
|
+
);
|
|
3117
|
+
|
|
3118
|
+
// A later PASS on a different host must not resurrect a pass that a newer HOLD
|
|
3119
|
+
// already superseded. The HOLD is never written to disk, so re-persisting every
|
|
3120
|
+
// pass would leave the old green run behind and a restart would serve it as
|
|
3121
|
+
// evidence for the held host.
|
|
3122
|
+
resetDogfoodState();
|
|
3123
|
+
recordDogfoodResult({
|
|
3124
|
+
verdict: "pass",
|
|
3125
|
+
scenarioId: "permission-rule-reorder",
|
|
3126
|
+
scenarioSha256: "c".repeat(64),
|
|
3127
|
+
taxonomySha256: "d".repeat(64),
|
|
3128
|
+
checks: [{ id: "exit-code", status: "pass" }],
|
|
3129
|
+
runCount: 1,
|
|
3130
|
+
host: { label: "bluefin" },
|
|
3131
|
+
workspace: dogfoodRepo,
|
|
3132
|
+
}, "s-dogfood");
|
|
3133
|
+
recordDogfoodResult({
|
|
3134
|
+
verdict: "hold",
|
|
3135
|
+
scenarioId: "permission-rule-reorder",
|
|
3136
|
+
scenarioSha256: "c".repeat(64),
|
|
3137
|
+
taxonomySha256: "d".repeat(64),
|
|
3138
|
+
taxonomyClass: "permission-bypass",
|
|
3139
|
+
checks: [{ id: "exit-code", status: "hold" }],
|
|
3140
|
+
runCount: 1,
|
|
3141
|
+
host: { label: "bluefin" },
|
|
3142
|
+
workspace: dogfoodRepo,
|
|
3143
|
+
}, "s-dogfood");
|
|
3144
|
+
recordDogfoodResult({
|
|
3145
|
+
verdict: "pass",
|
|
3146
|
+
scenarioId: "permission-rule-reorder",
|
|
3147
|
+
scenarioSha256: "c".repeat(64),
|
|
3148
|
+
taxonomySha256: "d".repeat(64),
|
|
3149
|
+
checks: [{ id: "exit-code", status: "pass" }],
|
|
3150
|
+
runCount: 1,
|
|
3151
|
+
host: { label: "windows-ci" },
|
|
3152
|
+
workspace: dogfoodRepo,
|
|
3153
|
+
}, "s-dogfood");
|
|
3154
|
+
check(
|
|
3155
|
+
"a later pass on another host does not resurrect a held host's cached pass",
|
|
3156
|
+
!loadDogfoodCache().some((record) => record.host.label === "bluefin") &&
|
|
3157
|
+
loadDogfoodCache().some((record) => record.host.label === "windows-ci"),
|
|
3158
|
+
);
|
|
3159
|
+
const diskAfterResurrection = loadDogfoodCache();
|
|
3160
|
+
resetDogfoodState();
|
|
3161
|
+
persistDogfoodCache(diskAfterResurrection);
|
|
3162
|
+
check(
|
|
3163
|
+
"a restart after that still reports the held host as unsatisfied",
|
|
3164
|
+
evaluateDogfoodGate(dogfoodRepo).missingHosts.includes("bluefin"),
|
|
3165
|
+
);
|
|
3166
|
+
|
|
3167
|
+
// A HOLD must evict only the holding host's passes. In a cold process the
|
|
3168
|
+
// in-memory list holds just the new record, so a filter over memory alone would
|
|
3169
|
+
// delete every other host's durable pass and re-open the gate for a host that
|
|
3170
|
+
// never changed.
|
|
3171
|
+
resetDogfoodState();
|
|
3172
|
+
recordDogfoodResult({
|
|
3173
|
+
verdict: "pass",
|
|
3174
|
+
scenarioId: "permission-rule-reorder",
|
|
3175
|
+
scenarioSha256: "e".repeat(64),
|
|
3176
|
+
taxonomySha256: "f".repeat(64),
|
|
3177
|
+
checks: [{ id: "exit-code", status: "pass" }],
|
|
3178
|
+
runCount: 1,
|
|
3179
|
+
host: { label: "bluefin" },
|
|
3180
|
+
workspace: dogfoodRepo,
|
|
3181
|
+
}, "s-dogfood");
|
|
3182
|
+
recordDogfoodResult({
|
|
3183
|
+
verdict: "pass",
|
|
3184
|
+
scenarioId: "permission-rule-reorder",
|
|
3185
|
+
scenarioSha256: "e".repeat(64),
|
|
3186
|
+
taxonomySha256: "f".repeat(64),
|
|
3187
|
+
checks: [{ id: "exit-code", status: "pass" }],
|
|
3188
|
+
runCount: 1,
|
|
3189
|
+
host: { label: "windows-ci" },
|
|
3190
|
+
workspace: dogfoodRepo,
|
|
3191
|
+
}, "s-dogfood");
|
|
3192
|
+
check("both hosts are durable before the regression", existsSync(getDogfoodCacheFilePath()));
|
|
3193
|
+
const diskBeforeHold = loadDogfoodCache();
|
|
3194
|
+
// Cold process: the cache file survives and memory is empty. `resetDogfoodState`
|
|
3195
|
+
// deletes both, so the cache is written back by hand to model a restart.
|
|
3196
|
+
resetDogfoodState();
|
|
3197
|
+
persistDogfoodCache(diskBeforeHold);
|
|
3198
|
+
recordDogfoodResult({
|
|
3199
|
+
verdict: "hold",
|
|
3200
|
+
scenarioId: "permission-rule-reorder",
|
|
3201
|
+
scenarioSha256: "e".repeat(64),
|
|
3202
|
+
taxonomySha256: "f".repeat(64),
|
|
3203
|
+
taxonomyClass: "permission-bypass",
|
|
3204
|
+
checks: [{ id: "exit-code", status: "hold" }],
|
|
3205
|
+
runCount: 1,
|
|
3206
|
+
host: { label: "windows-ci" },
|
|
3207
|
+
workspace: dogfoodRepo,
|
|
3208
|
+
}, "s-dogfood");
|
|
3209
|
+
check(
|
|
3210
|
+
"a HOLD in a cold process keeps another host's durable pass",
|
|
3211
|
+
loadDogfoodCache().some((record) => record.host.label === "bluefin"),
|
|
3212
|
+
);
|
|
3213
|
+
check(
|
|
3214
|
+
"a HOLD in a cold process removes only that host's cached pass",
|
|
3215
|
+
!loadDogfoodCache().some((record) => record.host.label === "windows-ci"),
|
|
3216
|
+
);
|
|
3217
|
+
// The durable cache is shared across repositories, so eviction is scoped to the
|
|
3218
|
+
// workspace that held. Otherwise a HOLD in one repository would erase another
|
|
3219
|
+
// repository's record for the same host label.
|
|
3220
|
+
const otherRepo = join(dogfoodRepo, "..", "dogfood-other-repo");
|
|
3221
|
+
mkdirSync(join(otherRepo, ".opencode"), { recursive: true });
|
|
3222
|
+
writeFileSync(join(otherRepo, ".opencode", "workflow-guard.json"), JSON.stringify({ requireReview: false, requireDogfood: true }));
|
|
3223
|
+
writeFileSync(join(otherRepo, "code.txt"), "other\n");
|
|
3224
|
+
spawnSync("git", ["init", "-q", "-b", "main"], { cwd: otherRepo });
|
|
3225
|
+
spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: otherRepo });
|
|
3226
|
+
spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: otherRepo });
|
|
3227
|
+
spawnSync("git", ["add", "-A"], { cwd: otherRepo });
|
|
3228
|
+
spawnSync("git", ["commit", "-qm", "init"], { cwd: otherRepo });
|
|
3229
|
+
recordDogfoodResult({
|
|
3230
|
+
verdict: "pass",
|
|
3231
|
+
scenarioId: "permission-rule-reorder",
|
|
3232
|
+
scenarioSha256: "e".repeat(64),
|
|
3233
|
+
taxonomySha256: "f".repeat(64),
|
|
3234
|
+
checks: [{ id: "exit-code", status: "pass" }],
|
|
3235
|
+
runCount: 1,
|
|
3236
|
+
host: { label: "windows-ci" },
|
|
3237
|
+
workspace: otherRepo,
|
|
3238
|
+
}, "s-dogfood");
|
|
3239
|
+
const passesBeforeScopedHold = loadDogfoodCache().filter((record) => record.workspace === otherRepo).length;
|
|
3240
|
+
recordDogfoodResult({
|
|
3241
|
+
verdict: "hold",
|
|
3242
|
+
scenarioId: "permission-rule-reorder",
|
|
3243
|
+
scenarioSha256: "e".repeat(64),
|
|
3244
|
+
taxonomySha256: "f".repeat(64),
|
|
3245
|
+
taxonomyClass: "permission-bypass",
|
|
3246
|
+
checks: [{ id: "exit-code", status: "hold" }],
|
|
3247
|
+
runCount: 1,
|
|
3248
|
+
host: { label: "windows-ci" },
|
|
3249
|
+
workspace: dogfoodRepo,
|
|
3250
|
+
}, "s-dogfood");
|
|
3251
|
+
check(
|
|
3252
|
+
"a HOLD in one workspace keeps another workspace's pass for the same host label",
|
|
3253
|
+
passesBeforeScopedHold > 0 && loadDogfoodCache().filter((record) => record.workspace === otherRepo).length === passesBeforeScopedHold,
|
|
3254
|
+
);
|
|
3255
|
+
// Simulate a further restart: drop in-memory state only, keeping the cache.
|
|
3256
|
+
const diskAfterColdHold = loadDogfoodCache();
|
|
3257
|
+
resetDogfoodState();
|
|
3258
|
+
persistDogfoodCache(diskAfterColdHold);
|
|
3259
|
+
const afterColdHold = evaluateDogfoodGate(dogfoodRepo);
|
|
3260
|
+
check(
|
|
3261
|
+
"after a cold HOLD the gate still needs the holding host, not the untouched one",
|
|
3262
|
+
afterColdHold.missingHosts.includes("windows-ci") && !afterColdHold.missingHosts.includes("bluefin"),
|
|
3263
|
+
);
|
|
3264
|
+
|
|
3265
|
+
// A HOLD must evict that host's cached passes. Otherwise a restart drops the
|
|
3266
|
+
// in-memory HOLD and serves the older green run, re-opening the gate on a host
|
|
3267
|
+
// whose most recent run held.
|
|
3268
|
+
resetDogfoodState();
|
|
3269
|
+
recordDogfoodResult({
|
|
3270
|
+
verdict: "pass",
|
|
3271
|
+
scenarioId: "permission-rule-reorder",
|
|
3272
|
+
scenarioSha256: "e".repeat(64),
|
|
3273
|
+
taxonomySha256: "f".repeat(64),
|
|
3274
|
+
checks: [{ id: "exit-code", status: "pass" }],
|
|
3275
|
+
runCount: 1,
|
|
3276
|
+
host: { label: "bluefin" },
|
|
3277
|
+
workspace: dogfoodRepo,
|
|
3278
|
+
}, "s-dogfood");
|
|
3279
|
+
check("the earlier pass is durable before the regression", existsSync(getDogfoodCacheFilePath()));
|
|
3280
|
+
recordDogfoodResult({
|
|
3281
|
+
verdict: "hold",
|
|
3282
|
+
scenarioId: "permission-rule-reorder",
|
|
3283
|
+
scenarioSha256: "e".repeat(64),
|
|
3284
|
+
taxonomySha256: "f".repeat(64),
|
|
3285
|
+
taxonomyClass: "permission-bypass",
|
|
3286
|
+
checks: [{ id: "exit-code", status: "hold" }],
|
|
3287
|
+
runCount: 1,
|
|
3288
|
+
host: { label: "bluefin" },
|
|
3289
|
+
workspace: dogfoodRepo,
|
|
3290
|
+
}, "s-dogfood");
|
|
3291
|
+
check(
|
|
3292
|
+
"a HOLD removes that host's cached pass from disk",
|
|
3293
|
+
!loadDogfoodCache().some((record) => record.host.label === "bluefin"),
|
|
3294
|
+
);
|
|
3295
|
+
// Simulate a restart: drop in-memory state only, keeping the disk cache.
|
|
3296
|
+
const diskAfterHold = loadDogfoodCache();
|
|
3297
|
+
resetDogfoodState();
|
|
3298
|
+
persistDogfoodCache(diskAfterHold);
|
|
3299
|
+
check(
|
|
3300
|
+
"a restart after a HOLD cannot re-satisfy the gate from a stale cached pass",
|
|
3301
|
+
evaluateDogfoodGate(dogfoodRepo).missingHosts.length > 0,
|
|
3302
|
+
);
|
|
3303
|
+
|
|
3304
|
+
// A PASS and a HOLD for one host can land in the same millisecond. The tie must
|
|
3305
|
+
// resolve to the HOLD in the durable view too, because the HOLD is never cached:
|
|
3306
|
+
// persisting the pass would let a restart serve it. Pinning Date.now is the only
|
|
3307
|
+
// way to exercise the tie, since two real runs cannot finish in one millisecond.
|
|
3308
|
+
// The HOLD is recorded first because memory is newest-first: a later PASS for
|
|
3309
|
+
// the same key is met first by the durable selector, which is where a strict
|
|
3310
|
+
// ">" tie-break picks the pass.
|
|
3311
|
+
resetDogfoodState();
|
|
3312
|
+
const realNow = Date.now;
|
|
3313
|
+
Date.now = () => 1_700_000_000_000;
|
|
3314
|
+
try {
|
|
3315
|
+
recordDogfoodResult({
|
|
3316
|
+
verdict: "hold",
|
|
3317
|
+
scenarioId: "permission-rule-reorder",
|
|
3318
|
+
scenarioSha256: "e".repeat(64),
|
|
3319
|
+
taxonomySha256: "f".repeat(64),
|
|
3320
|
+
taxonomyClass: "permission-bypass",
|
|
3321
|
+
checks: [{ id: "exit-code", status: "hold" }],
|
|
3322
|
+
runCount: 1,
|
|
3323
|
+
host: { label: "bluefin" },
|
|
3324
|
+
workspace: dogfoodRepo,
|
|
3325
|
+
}, "s-dogfood");
|
|
3326
|
+
recordDogfoodResult({
|
|
3327
|
+
verdict: "pass",
|
|
3328
|
+
scenarioId: "permission-rule-reorder",
|
|
3329
|
+
scenarioSha256: "e".repeat(64),
|
|
3330
|
+
taxonomySha256: "f".repeat(64),
|
|
3331
|
+
checks: [{ id: "exit-code", status: "pass" }],
|
|
3332
|
+
runCount: 1,
|
|
3333
|
+
host: { label: "bluefin" },
|
|
3334
|
+
workspace: dogfoodRepo,
|
|
3335
|
+
}, "s-dogfood");
|
|
3336
|
+
} finally {
|
|
3337
|
+
Date.now = realNow;
|
|
3338
|
+
}
|
|
3339
|
+
check(
|
|
3340
|
+
"a same-millisecond HOLD then PASS leaves no cached pass",
|
|
3341
|
+
!loadDogfoodCache().some((record) => record.host.label === "bluefin"),
|
|
3342
|
+
);
|
|
3343
|
+
const tiedDisk = loadDogfoodCache();
|
|
3344
|
+
resetDogfoodState();
|
|
3345
|
+
persistDogfoodCache(tiedDisk);
|
|
3346
|
+
check(
|
|
3347
|
+
"a restart after a same-millisecond HOLD then PASS stays unsatisfied",
|
|
3348
|
+
evaluateDogfoodGate(dogfoodRepo).missingHosts.includes("bluefin"),
|
|
3349
|
+
);
|
|
3350
|
+
|
|
3351
|
+
// Retention is per host. A global cap would let repeated passes on one host
|
|
3352
|
+
// evict another host's only pass and silently reopen the gate for a host that
|
|
3353
|
+
// never changed.
|
|
3354
|
+
resetDogfoodState();
|
|
3355
|
+
for (let run = 0; run < 6; run += 1) {
|
|
3356
|
+
recordDogfoodResult({
|
|
3357
|
+
verdict: "pass",
|
|
3358
|
+
scenarioId: "permission-rule-reorder",
|
|
3359
|
+
scenarioSha256: "c".repeat(64),
|
|
3360
|
+
taxonomySha256: "d".repeat(64),
|
|
3361
|
+
checks: [{ id: "exit-code", status: "pass" }],
|
|
3362
|
+
runCount: 1,
|
|
3363
|
+
host: { label: "bluefin" },
|
|
3364
|
+
workspace: dogfoodRepo,
|
|
3365
|
+
}, "s-dogfood");
|
|
3366
|
+
}
|
|
3367
|
+
recordDogfoodResult({
|
|
3368
|
+
verdict: "pass",
|
|
3369
|
+
scenarioId: "permission-rule-reorder",
|
|
3370
|
+
scenarioSha256: "c".repeat(64),
|
|
3371
|
+
taxonomySha256: "d".repeat(64),
|
|
3372
|
+
checks: [{ id: "exit-code", status: "pass" }],
|
|
3373
|
+
runCount: 1,
|
|
3374
|
+
host: { label: "windows-ci" },
|
|
3375
|
+
workspace: dogfoodRepo,
|
|
3376
|
+
}, "s-dogfood");
|
|
3377
|
+
check(
|
|
3378
|
+
"repeated passes on one host cannot evict another host's pass",
|
|
3379
|
+
getDogfoodRecordsForWorkspace(dogfoodRepo).some((record) => record.host.label === "windows-ci"),
|
|
3380
|
+
);
|
|
3381
|
+
check(
|
|
3382
|
+
"per-host retention keeps only the newest few runs for the busiest host",
|
|
3383
|
+
getDogfoodRecordsForWorkspace(dogfoodRepo).filter((record) => record.host.label === "bluefin").length <= 3,
|
|
3384
|
+
);
|
|
3385
|
+
resetDogfoodState();
|
|
3386
|
+
|
|
3387
|
+
// A HOLD on a host outside the deploy matrix cannot block the gate, so it must
|
|
3388
|
+
// not be reported as the reason: that would name the wrong host and remedy.
|
|
3389
|
+
writeFileSync(join(dogfoodRepo, ".opencode", "workflow-guard.json"), JSON.stringify({ requireReview: false, requireDogfood: true, dogfoodDeployMatrix: ["bluefin"] }));
|
|
3390
|
+
reloadProjectConfig(dogfoodRepo);
|
|
3391
|
+
recordDogfoodResult({
|
|
3392
|
+
verdict: "hold",
|
|
3393
|
+
scenarioId: "permission-rule-reorder",
|
|
3394
|
+
scenarioSha256: "e".repeat(64),
|
|
3395
|
+
taxonomySha256: "f".repeat(64),
|
|
3396
|
+
taxonomyClass: "permission-bypass",
|
|
3397
|
+
checks: [{ id: "exit-code", status: "hold" }],
|
|
3398
|
+
runCount: 1,
|
|
3399
|
+
host: { label: "windows-ci" },
|
|
3400
|
+
workspace: dogfoodRepo,
|
|
3401
|
+
}, "s-dogfood");
|
|
3402
|
+
const outsideMatrixHold = evaluateDogfoodGate(dogfoodRepo);
|
|
3403
|
+
check(
|
|
3404
|
+
"an out-of-matrix HOLD is not reported as the blocking cause",
|
|
3405
|
+
outsideMatrixHold.missingHosts.includes("bluefin") &&
|
|
3406
|
+
!outsideMatrixHold.missingHosts.includes("windows-ci") &&
|
|
3407
|
+
!/(HOLD|permission-bypass)/.test(outsideMatrixHold.reason ?? ""),
|
|
3408
|
+
);
|
|
3409
|
+
resetDogfoodState();
|
|
3410
|
+
writeFileSync(join(dogfoodRepo, ".opencode", "workflow-guard.json"), JSON.stringify({ requireReview: false, requireDogfood: true, dogfoodDeployMatrix: ["bluefin", "windows-ci"] }));
|
|
3411
|
+
reloadProjectConfig(dogfoodRepo);
|
|
3412
|
+
resetDogfoodState();
|
|
3413
|
+
|
|
3414
|
+
// Compaction must carry the dogfood state, so a context summary cannot lose the
|
|
3415
|
+
// only reminder that a pre-deployment run is still outstanding.
|
|
3416
|
+
const dogfoodRepoPlugin = await (defaultExport?.server ?? WorkflowGuard)({
|
|
3417
|
+
directory: dogfoodRepo,
|
|
3418
|
+
worktree: dogfoodRepo,
|
|
3419
|
+
client: fakeClient as any,
|
|
3420
|
+
project: {} as any,
|
|
3421
|
+
experimental_workspace: {} as any,
|
|
3422
|
+
serverUrl: new URL("http://localhost:4096"),
|
|
3423
|
+
$: undefined as any,
|
|
3424
|
+
});
|
|
3425
|
+
const dogfoodCompactOutput: { context: string[] } = { context: [] };
|
|
3426
|
+
const dogfoodCompactFn = dogfoodRepoPlugin["experimental.session.compacting"];
|
|
3427
|
+
if (typeof dogfoodCompactFn === "function") {
|
|
3428
|
+
await dogfoodCompactFn({ sessionID: "s-dogfood" } as any, dogfoodCompactOutput as any);
|
|
3429
|
+
}
|
|
3430
|
+
check(
|
|
3431
|
+
"compaction reports the missing dogfood hosts by name",
|
|
3432
|
+
dogfoodCompactOutput.context.some(
|
|
3433
|
+
(context) =>
|
|
3434
|
+
context.includes("Dogfood Evidence: MISSING") &&
|
|
3435
|
+
context.includes("bluefin") &&
|
|
3436
|
+
context.includes("windows-ci"),
|
|
3437
|
+
),
|
|
3438
|
+
);
|
|
3439
|
+
|
|
3440
|
+
// The system guidance advertises the gate only for a repository that enabled
|
|
3441
|
+
// it, so an unopted repository is never told to satisfy a requirement it has.
|
|
3442
|
+
check(
|
|
3443
|
+
"system guidance stays silent about dogfood when the gate is off",
|
|
3444
|
+
!buildWorkflowGuardSystemGuidance({ projectMemoryEnabled: false, learningEnabled: false }).includes("Dogfood Gate"),
|
|
3445
|
+
);
|
|
3446
|
+
check(
|
|
3447
|
+
"system guidance names the dogfood requirement when the gate is on",
|
|
3448
|
+
buildWorkflowGuardSystemGuidance({ projectMemoryEnabled: false, learningEnabled: false, dogfoodRequired: true }).includes("Dogfood Gate"),
|
|
3449
|
+
);
|
|
3450
|
+
|
|
3451
|
+
// Recursion refusal: the actor session cannot certify itself.
|
|
3452
|
+
delete process.env.WORKFLOW_GUARD_REQUIRE_REVIEW;
|
|
3453
|
+
process.env.WORKFLOW_GUARD_DOGFOOD_ACTOR = "1";
|
|
3454
|
+
process.env.WORKFLOW_GUARD_DOGFOOD_DEPTH = "1";
|
|
3455
|
+
check("actor marker sets the depth and the actor flag", isDogfoodActor() === true && getDogfoodDepth() === 1);
|
|
3456
|
+
delete process.env.WORKFLOW_GUARD_DOGFOOD_ACTOR;
|
|
3457
|
+
check("depth alone still marks the session as an actor", isDogfoodActor() === true);
|
|
3458
|
+
const actorRecordResult = await dogfoodPlugin.tool?.record_dogfood?.execute(
|
|
3459
|
+
{ scenarioId: "s", scenarioSha256: "a".repeat(64), taxonomySha256: "b".repeat(64), verdict: "pass", checks: JSON.stringify([{ id: "exit-code", status: "pass" }]), hostLabel: "bluefin", directory: dogfoodRepo },
|
|
3460
|
+
{ sessionID: "s-dogfood-actor", worktree: dogfoodRepo, directory: dogfoodRepo } as any,
|
|
3461
|
+
);
|
|
3462
|
+
check(
|
|
3463
|
+
"a dogfood actor is refused when it tries to record its own verdict",
|
|
3464
|
+
typeof actorRecordResult === "string" && actorRecordResult.includes("cannot certify itself"),
|
|
3465
|
+
);
|
|
3466
|
+
delete process.env.WORKFLOW_GUARD_DOGFOOD_DEPTH;
|
|
3467
|
+
process.env.WORKFLOW_GUARD_DOGFOOD_DEPTH = "not-a-number";
|
|
3468
|
+
check("an unparseable depth marker fails closed to a non-zero depth", getDogfoodDepth() === 1);
|
|
3469
|
+
delete process.env.WORKFLOW_GUARD_DOGFOOD_DEPTH;
|
|
3470
|
+
check("with no marker the session is not an actor", isDogfoodActor() === false && getDogfoodDepth() === 0);
|
|
3471
|
+
|
|
3472
|
+
// Verdict validation: a pass needs every check passing, and a HOLD names a class.
|
|
3473
|
+
const lyingPass = await dogfoodPlugin.tool?.record_dogfood?.execute(
|
|
3474
|
+
{ scenarioId: "s", scenarioSha256: "a".repeat(64), taxonomySha256: "b".repeat(64), verdict: "pass", checks: JSON.stringify([{ id: "exit-code", status: "pass" }, { id: "diff-allowlist", status: "skip" }]), hostLabel: "bluefin", directory: dogfoodRepo },
|
|
3475
|
+
{ sessionID: "s-dogfood", worktree: dogfoodRepo, directory: dogfoodRepo } as any,
|
|
3476
|
+
);
|
|
3477
|
+
check(
|
|
3478
|
+
"a pass with a skipped check is refused, so a gate cannot pass by omission",
|
|
3479
|
+
typeof lyingPass === "string" && lyingPass.includes("requires every check to be 'pass'"),
|
|
3480
|
+
);
|
|
3481
|
+
const classlessHold = await dogfoodPlugin.tool?.record_dogfood?.execute(
|
|
3482
|
+
{ scenarioId: "s", scenarioSha256: "a".repeat(64), taxonomySha256: "b".repeat(64), verdict: "hold", checks: JSON.stringify([{ id: "exit-code", status: "hold" }]), hostLabel: "bluefin", directory: dogfoodRepo },
|
|
3483
|
+
{ sessionID: "s-dogfood", worktree: dogfoodRepo, directory: dogfoodRepo } as any,
|
|
3484
|
+
);
|
|
3485
|
+
check(
|
|
3486
|
+
"a HOLD without a taxonomy class is refused so the failure stays diagnosable",
|
|
3487
|
+
typeof classlessHold === "string" && classlessHold.includes("must name its taxonomy class"),
|
|
3488
|
+
);
|
|
3489
|
+
const malformedChecks = await dogfoodPlugin.tool?.record_dogfood?.execute(
|
|
3490
|
+
{ scenarioId: "s", scenarioSha256: "a".repeat(64), taxonomySha256: "b".repeat(64), verdict: "pass", checks: "not json", hostLabel: "bluefin", directory: dogfoodRepo },
|
|
3491
|
+
{ sessionID: "s-dogfood", worktree: dogfoodRepo, directory: dogfoodRepo } as any,
|
|
3492
|
+
);
|
|
3493
|
+
check(
|
|
3494
|
+
"malformed check rows are refused instead of recorded as passing",
|
|
3495
|
+
typeof malformedChecks === "string" && malformedChecks.includes("checks must be a JSON array"),
|
|
3496
|
+
);
|
|
3497
|
+
const anonymousHost = await dogfoodPlugin.tool?.record_dogfood?.execute(
|
|
3498
|
+
{ scenarioId: "s", scenarioSha256: "a".repeat(64), taxonomySha256: "b".repeat(64), verdict: "pass", checks: JSON.stringify([{ id: "exit-code", status: "pass" }]), hostLabel: " ", directory: dogfoodRepo },
|
|
3499
|
+
{ sessionID: "s-dogfood", worktree: dogfoodRepo, directory: dogfoodRepo } as any,
|
|
3500
|
+
);
|
|
3501
|
+
check(
|
|
3502
|
+
"an empty host label is refused because the deploy matrix could never match it",
|
|
3503
|
+
typeof anonymousHost === "string" && anonymousHost.includes("scenarioId and hostLabel must both be non-empty"),
|
|
3504
|
+
);
|
|
3505
|
+
// An empty check list would slip past the per-row checks because `[].some(...)`
|
|
3506
|
+
// is false, so a pass would assert nothing.
|
|
3507
|
+
const noChecks = await dogfoodPlugin.tool?.record_dogfood?.execute(
|
|
3508
|
+
{ scenarioId: "s", scenarioSha256: "a".repeat(64), taxonomySha256: "b".repeat(64), verdict: "pass", checks: "[]", hostLabel: "bluefin", directory: dogfoodRepo },
|
|
3509
|
+
{ sessionID: "s-dogfood", worktree: dogfoodRepo, directory: dogfoodRepo } as any,
|
|
3510
|
+
);
|
|
3511
|
+
check(
|
|
3512
|
+
"a pass with no check rows is refused instead of asserting nothing",
|
|
3513
|
+
typeof noChecks === "string" && noChecks.includes("at least one check row is required") && evaluateDogfoodGate(dogfoodRepo).missingHosts.length > 0,
|
|
3514
|
+
);
|
|
3515
|
+
|
|
3516
|
+
// A real recorded verdict through the tool satisfies the gate and journals it.
|
|
3517
|
+
const goodDogfood = await dogfoodPlugin.tool?.record_dogfood?.execute(
|
|
3518
|
+
{
|
|
3519
|
+
scenarioId: "permission-rule-reorder",
|
|
3520
|
+
scenarioSha256: "c".repeat(64),
|
|
3521
|
+
taxonomySha256: "d".repeat(64),
|
|
3522
|
+
verdict: "pass",
|
|
3523
|
+
checks: JSON.stringify([{ id: "exit-code", status: "pass" }, { id: "diff-allowlist", status: "pass" }]),
|
|
3524
|
+
hostLabel: "bluefin",
|
|
3525
|
+
runCount: 3,
|
|
3526
|
+
hostOs: "linux",
|
|
3527
|
+
hostArch: "x64",
|
|
3528
|
+
directory: dogfoodRepo,
|
|
3529
|
+
},
|
|
3530
|
+
{ sessionID: "s-dogfood", worktree: dogfoodRepo, directory: dogfoodRepo } as any,
|
|
3531
|
+
);
|
|
3532
|
+
check("record_dogfood records a passing run through the tool", typeof goodDogfood === "string" && goodDogfood.includes("PASS") && goodDogfood.includes("bluefin"));
|
|
3533
|
+
const dogfoodAudit = getRecentAuditEntries(30).find((entry) => entry.tool === "record_dogfood.verdict" && entry.reason === "dogfood_passed");
|
|
3534
|
+
check(
|
|
3535
|
+
"the recorded dogfood verdict is journaled with its binding",
|
|
3536
|
+
dogfoodAudit?.evidence?.dogfoodVerdict === "pass" && dogfoodAudit?.evidence?.binding?.workspace === dogfoodRepo,
|
|
3537
|
+
);
|
|
3538
|
+
const dogfoodEvidenceRecord = dogfoodEvidence(getLastDogfoodResultForWorkspace(dogfoodRepo)!);
|
|
3539
|
+
check(
|
|
3540
|
+
"dogfood evidence carries the digest pair and is attestation-confidence",
|
|
3541
|
+
dogfoodEvidenceRecord.kind === "dogfood" && dogfoodEvidenceRecord.source.scenarioSha256 === "c".repeat(64) && dogfoodEvidenceRecord.confidence === "attestation",
|
|
3542
|
+
);
|
|
3543
|
+
|
|
3544
|
+
process.env.WORKFLOW_GUARD_REQUIRE_DOGFOOD = "0";
|
|
3545
|
+
writeFileSync(join(dogfoodRepo, ".opencode", "workflow-guard.json"), JSON.stringify({ requireReview: false, requireDogfood: true }));
|
|
3546
|
+
reloadProjectConfig(dogfoodRepo);
|
|
3547
|
+
check("WORKFLOW_GUARD_REQUIRE_DOGFOOD=0 overrides config true", isDogfoodRequired(dogfoodRepo) === false);
|
|
3548
|
+
delete process.env.WORKFLOW_GUARD_REQUIRE_DOGFOOD;
|
|
3549
|
+
process.env.WORKFLOW_GUARD_REQUIRE_DOGFOOD = "1";
|
|
3550
|
+
check("WORKFLOW_GUARD_REQUIRE_DOGFOOD=1 forces the gate on", isDogfoodRequired(dogfoodRepo) === true);
|
|
3551
|
+
delete process.env.WORKFLOW_GUARD_REQUIRE_DOGFOOD;
|
|
3552
|
+
resetDogfoodState();
|
|
3553
|
+
rmSync(dogfoodRepo, { recursive: true, force: true });
|
|
3554
|
+
setWorkspaceRoot(root);
|
|
3555
|
+
reloadProjectConfig(root);
|
|
3556
|
+
resetDogfoodState();
|
|
3557
|
+
|
|
2891
3558
|
// 12. Custom Plugin Tools & Event Auditing
|
|
2892
3559
|
console.log("- Custom Tools & Event Auditing -");
|
|
2893
3560
|
const customPlugin = await (defaultExport?.server ?? WorkflowGuard)({
|
|
@@ -3723,6 +4390,529 @@ check(
|
|
|
3723
4390
|
);
|
|
3724
4391
|
check("variable start point behind a redirect still fails closed", blocked(await shell("git switch -c wg-var-redir $WG_REF 2>/dev/null")));
|
|
3725
4392
|
|
|
4393
|
+
// Worktree-aware git verdicts: a git command is judged against the checkout it
|
|
4394
|
+
// runs in, not against the session-bound one. The regression these pin: a
|
|
4395
|
+
// linked worktree on a feature branch was resolved to the primary checkout on
|
|
4396
|
+
// `main`, so its commits were blocked as protected-branch writes, and PR
|
|
4397
|
+
// preflight judged the wrong tree.
|
|
4398
|
+
console.log("- Worktree-aware git verdicts -");
|
|
4399
|
+
check(
|
|
4400
|
+
"parseGitInvocation resolves a bare git command against the supplied workdir",
|
|
4401
|
+
parseGitInvocation("git status", "/tmp/some-worktree")?.repoDir === "/tmp/some-worktree",
|
|
4402
|
+
);
|
|
4403
|
+
check(
|
|
4404
|
+
"parseGitInvocation marks an assumed base as not explicit",
|
|
4405
|
+
parseGitInvocation("git status", "/tmp/some-worktree")?.explicitRepoDir === false,
|
|
4406
|
+
);
|
|
4407
|
+
check(
|
|
4408
|
+
"parseGitInvocation treats -C as an explicit repository",
|
|
4409
|
+
parseGitInvocation("git -C /tmp/named status")?.repoDir === "/tmp/named" &&
|
|
4410
|
+
parseGitInvocation("git -C /tmp/named status")?.explicitRepoDir === true,
|
|
4411
|
+
);
|
|
4412
|
+
check("prSourceBranch reads gh --head", prSourceBranch("gh pr create --head feat/x --body b") === "feat/x");
|
|
4413
|
+
check("prSourceBranch reads the gh -H short form", prSourceBranch("gh pr create -H feat/x --body b") === "feat/x");
|
|
4414
|
+
check("prSourceBranch reads --head=value", prSourceBranch("gh pr create --head=feat/x --body b") === "feat/x");
|
|
4415
|
+
check("prSourceBranch reads the attached -Hvalue form", prSourceBranch("gh pr create -Hfeat/x --body b") === "feat/x");
|
|
4416
|
+
check("prSourceBranch reads az --source-branch", prSourceBranch("az repos pr create --source-branch feat/x --description b") === "feat/x");
|
|
4417
|
+
check("prSourceBranch strips a fork owner prefix", prSourceBranch("gh pr create --head someone:feature/y --body b") === "feature/y");
|
|
4418
|
+
check("prSourceBranch returns undefined when no branch is named", prSourceBranch("gh pr create --body b") === undefined);
|
|
4419
|
+
// A `--head`-looking token inside a quoted body is command data, not a branch.
|
|
4420
|
+
// The dispatcher used to unwrap the segment first, and the joined text lost the
|
|
4421
|
+
// quotes, so body prose was re-read as flags.
|
|
4422
|
+
check(
|
|
4423
|
+
"a --head token inside a quoted PR body is not read as the source branch",
|
|
4424
|
+
prSourceBranch(`gh pr create --title t --body 'Changelog: ok. Reviewer note: --head innocent'`) === undefined,
|
|
4425
|
+
);
|
|
4426
|
+
check(
|
|
4427
|
+
"a real --head still wins over a --head token inside the body",
|
|
4428
|
+
prSourceBranch(`gh pr create --head feat/x --body 'Changelog: ok, see --head nope'`) === "feat/x",
|
|
4429
|
+
);
|
|
4430
|
+
// A repository claim must be an actual assignment. A commit message that
|
|
4431
|
+
// happens to contain `GIT_DIR=` previously read as one, which was harmless
|
|
4432
|
+
// while the value only picked a base directory but is not harmless now that the
|
|
4433
|
+
// permission seat trusts `explicitRepoDir` to decide whether it may judge.
|
|
4434
|
+
check(
|
|
4435
|
+
"an unquoted GIT_DIR= inside a commit message is not a repository claim",
|
|
4436
|
+
parseGitInvocation("git commit -m GIT_DIR=/repo")?.explicitRepoDir === false,
|
|
4437
|
+
);
|
|
4438
|
+
check(
|
|
4439
|
+
"a quoted GIT_DIR= inside a commit message is not a repository claim",
|
|
4440
|
+
parseGitInvocation("git commit -m 'GIT_DIR=/repo'")?.explicitRepoDir === false,
|
|
4441
|
+
);
|
|
4442
|
+
check(
|
|
4443
|
+
"a real GIT_DIR= prefix is a repository claim",
|
|
4444
|
+
parseGitInvocation("GIT_DIR=/repo git commit")?.repoDir === "/repo" &&
|
|
4445
|
+
parseGitInvocation("GIT_DIR=/repo git commit")?.explicitRepoDir === true,
|
|
4446
|
+
);
|
|
4447
|
+
// `git -C ~/repo` reaches that repository through the shell's own expansion. A
|
|
4448
|
+
// literal `~` resolved against the workspace base put the write inside the
|
|
4449
|
+
// workspace, so a commit to a home-directory repository read as a local one.
|
|
4450
|
+
check(
|
|
4451
|
+
"a tilde repository selector expands to the home directory",
|
|
4452
|
+
parseGitInvocation("git -C ~/wg-probe-repo commit")?.repoDir === join(process.env.HOME || homedir(), "wg-probe-repo"),
|
|
4453
|
+
);
|
|
4454
|
+
check(
|
|
4455
|
+
"a tilde git-dir selector expands to the home directory",
|
|
4456
|
+
parseGitInvocation("git --git-dir=~/.git commit")?.repoDir === join(process.env.HOME || homedir(), ".git"),
|
|
4457
|
+
);
|
|
4458
|
+
check(
|
|
4459
|
+
"an unresolvable repository selector keeps its literal text instead of collapsing to the root",
|
|
4460
|
+
parseGitInvocation("git -C $OTHER_REPO/wg commit", "/tmp/wg-base")?.repoDir === join("/tmp/wg-base", "$OTHER_REPO", "wg"),
|
|
4461
|
+
);
|
|
4462
|
+
// `~user` names that user's home. Reading only the home base dropped the path
|
|
4463
|
+
// after the name, so `git -C ~csh/repo commit` was judged against the home
|
|
4464
|
+
// directory instead of the repository inside it.
|
|
4465
|
+
check(
|
|
4466
|
+
"a named-user repository selector keeps the path after the home",
|
|
4467
|
+
parseGitInvocation("git -C ~root/repo commit")?.repoDir === join(dirname(process.env.HOME || homedir()), "root", "repo"),
|
|
4468
|
+
);
|
|
4469
|
+
// Git also accepts the attached short form. Leaving `-C~/repo` unparsed kept
|
|
4470
|
+
// the assumed base, so the commit was judged in the session's own checkout.
|
|
4471
|
+
check(
|
|
4472
|
+
"an attached short -C selector names its repository",
|
|
4473
|
+
parseGitInvocation("git -C~/wg-probe-repo commit")?.repoDir === join(process.env.HOME || homedir(), "wg-probe-repo") &&
|
|
4474
|
+
parseGitInvocation("git -C~/wg-probe-repo commit")?.explicitRepoDir === true,
|
|
4475
|
+
);
|
|
4476
|
+
// A dangling `-C` names no repository, but the flag is still in the text. Both
|
|
4477
|
+
// seats must then agree the command is unclassifiable: the seat defers, and
|
|
4478
|
+
// execute.before decides. Reporting it as an assumed base let the seat judge a
|
|
4479
|
+
// command whose repository it could not know.
|
|
4480
|
+
check(
|
|
4481
|
+
"a dangling -C is reported as an explicit claim, not silent text truncation",
|
|
4482
|
+
parseGitInvocation("git -C")?.explicitRepoDir === true && parseGitInvocation("git -C")?.rest === "",
|
|
4483
|
+
);
|
|
4484
|
+
check(
|
|
4485
|
+
"a dangling --git-dir is reported as an explicit claim",
|
|
4486
|
+
parseGitInvocation("git --git-dir")?.explicitRepoDir === true,
|
|
4487
|
+
);
|
|
4488
|
+
check(
|
|
4489
|
+
"a dangling option that names no repository is not a claim",
|
|
4490
|
+
parseGitInvocation("git -c")?.explicitRepoDir === false,
|
|
4491
|
+
);
|
|
4492
|
+
check(
|
|
4493
|
+
"a wrapped GIT_DIR= prefix is still a repository claim",
|
|
4494
|
+
parseGitInvocation("timeout 30 env GIT_DIR=/repo git commit")?.repoDir === "/repo" &&
|
|
4495
|
+
parseGitInvocation("sudo -u x env GIT_DIR=/repo git commit")?.repoDir === "/repo",
|
|
4496
|
+
);
|
|
4497
|
+
check(
|
|
4498
|
+
"a bare git command wrapped in env/nohup/timeout is still parsed",
|
|
4499
|
+
parseGitInvocation("env git push origin feature/x")?.rest === "push origin feature/x" &&
|
|
4500
|
+
parseGitInvocation("timeout 60 git push origin feature/x")?.rest === "push origin feature/x" &&
|
|
4501
|
+
parseGitInvocation("nohup git -C /repo commit")?.repoDir === "/repo",
|
|
4502
|
+
);
|
|
4503
|
+
|
|
4504
|
+
const wtRepo = mkdtempSync(join(tmpdir(), "wg-workdir-repo-"));
|
|
4505
|
+
spawnSync("git", ["init", "-b", "main"], { cwd: wtRepo });
|
|
4506
|
+
spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: wtRepo });
|
|
4507
|
+
spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: wtRepo });
|
|
4508
|
+
writeFileSync(join(wtRepo, "shared.txt"), "base\n");
|
|
4509
|
+
spawnSync("git", ["add", "-A"], { cwd: wtRepo });
|
|
4510
|
+
spawnSync("git", ["commit", "-qm", "init"], { cwd: wtRepo });
|
|
4511
|
+
spawnSync("git", ["switch", "-c", "feat/wt-branch"], { cwd: wtRepo });
|
|
4512
|
+
writeFileSync(join(wtRepo, "shared.txt"), "branch content\n");
|
|
4513
|
+
spawnSync("git", ["commit", "-qam", "branch edit"], { cwd: wtRepo });
|
|
4514
|
+
spawnSync("git", ["switch", "main"], { cwd: wtRepo });
|
|
4515
|
+
writeFileSync(join(wtRepo, "shared.txt"), "main content\n");
|
|
4516
|
+
spawnSync("git", ["commit", "-qam", "main edit"], { cwd: wtRepo });
|
|
4517
|
+
const wtLinked = mkdtempSync(join(tmpdir(), "wg-workdir-linked-"));
|
|
4518
|
+
rmSync(wtLinked, { recursive: true, force: true });
|
|
4519
|
+
spawnSync("git", ["worktree", "add", wtLinked, "feat/wt-branch"], { cwd: wtRepo });
|
|
4520
|
+
check("findWorktreeForBranch resolves the checkout holding a branch", findWorktreeForBranch(wtRepo, "feat/wt-branch") === wtLinked);
|
|
4521
|
+
check("findWorktreeForBranch accepts a full ref name", findWorktreeForBranch(wtRepo, "refs/heads/feat/wt-branch") === wtLinked);
|
|
4522
|
+
check("findWorktreeForBranch returns undefined for an absent branch", findWorktreeForBranch(wtRepo, "feat/absent") === undefined);
|
|
4523
|
+
check(
|
|
4524
|
+
"checkMergeConflicts judges an explicitly named branch that is not checked out",
|
|
4525
|
+
checkMergeConflicts(wtRepo, "feat/wt-branch").hasConflicts &&
|
|
4526
|
+
(checkMergeConflicts(wtRepo, "feat/wt-branch").reason ?? "").includes("'feat/wt-branch'"),
|
|
4527
|
+
);
|
|
4528
|
+
// The protected-branch gate follows the workdir: the primary checkout is on
|
|
4529
|
+
// `main`, yet a commit run in the linked feature worktree is legitimate.
|
|
4530
|
+
const wtCommit = await call("bash", { command: "git commit -m x --allow-empty", workdir: wtLinked }, { sessionID: "s-wt-commit", worktree: wtRepo, directory: wtRepo });
|
|
4531
|
+
check("a commit in a linked feature worktree is not blocked as a protected-branch write", !blocked(wtCommit));
|
|
4532
|
+
const primaryCommit = await call("bash", { command: "git commit -m x --allow-empty", workdir: wtRepo }, { sessionID: "s-wt-commit-primary", worktree: wtRepo, directory: wtRepo });
|
|
4533
|
+
check("a commit in the primary checkout on main stays blocked", blocked(primaryCommit));
|
|
4534
|
+
// PR preflight follows --head to the branch's own worktree, so its merge
|
|
4535
|
+
// conflict is found even though the shell sits in the primary checkout.
|
|
4536
|
+
const prevWtReview = process.env.WORKFLOW_GUARD_REQUIRE_REVIEW;
|
|
4537
|
+
process.env.WORKFLOW_GUARD_REQUIRE_REVIEW = "0";
|
|
4538
|
+
try {
|
|
4539
|
+
const headPr = await call(
|
|
4540
|
+
"bash",
|
|
4541
|
+
{ command: "gh pr create --head feat/wt-branch --title t --body 'Changelog: fixed'", workdir: wtRepo },
|
|
4542
|
+
{ sessionID: "s-wt-pr-head", worktree: wtRepo, directory: wtRepo },
|
|
4543
|
+
);
|
|
4544
|
+
check(
|
|
4545
|
+
"PR preflight resolves --head to the linked worktree and names its conflict",
|
|
4546
|
+
blocked(headPr) && String(headPr).includes("'feat/wt-branch'") && String(headPr).includes("merge conflicts"),
|
|
4547
|
+
);
|
|
4548
|
+
const barePr = await call(
|
|
4549
|
+
"bash",
|
|
4550
|
+
{ command: "gh pr create --title t --body 'Changelog: fixed'", workdir: wtRepo },
|
|
4551
|
+
{ sessionID: "s-wt-pr-bare", worktree: wtRepo, directory: wtRepo },
|
|
4552
|
+
);
|
|
4553
|
+
check("a bare PR command still judges the shell's checkout", !blocked(barePr));
|
|
4554
|
+
if (blocked(barePr)) console.log(" blocked:", barePr);
|
|
4555
|
+
} finally {
|
|
4556
|
+
if (prevWtReview === undefined) delete process.env.WORKFLOW_GUARD_REQUIRE_REVIEW;
|
|
4557
|
+
else process.env.WORKFLOW_GUARD_REQUIRE_REVIEW = prevWtReview;
|
|
4558
|
+
}
|
|
4559
|
+
// A workdir outside the workspace is only legible when it is a linked
|
|
4560
|
+
// worktree of the SAME repository. A genuinely foreign repository cannot be
|
|
4561
|
+
// judged: the command runs there, so `rm -rf dist` or `> artifact.txt` would be
|
|
4562
|
+
// validated against the workspace root while changing another tree.
|
|
4563
|
+
const foreignWtRepo = mkdtempSync(join(tmpdir(), "wg-foreign-workdir-"));
|
|
4564
|
+
spawnSync("git", ["init", "-b", "main"], { cwd: foreignWtRepo });
|
|
4565
|
+
spawnSync("git", ["-c", "user.name=T", "-c", "user.email=t@e", "commit", "--allow-empty", "-m", "init"], { cwd: foreignWtRepo });
|
|
4566
|
+
todo("s-wt-foreign", item("probe foreign workdir", "in_progress"));
|
|
4567
|
+
const foreignWrite = await call("bash", { command: "echo x > artifact.txt", workdir: foreignWtRepo }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4568
|
+
check(
|
|
4569
|
+
"a relative shell mutation in a foreign workdir is blocked instead of judged against the workspace",
|
|
4570
|
+
blocked(foreignWrite) && String(foreignWrite).includes(foreignWtRepo),
|
|
4571
|
+
);
|
|
4572
|
+
const foreignGitWrite = await call("bash", { command: "git commit -m x --allow-empty", workdir: foreignWtRepo }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4573
|
+
check("a git write in a foreign workdir is blocked", blocked(foreignGitWrite));
|
|
4574
|
+
const foreignRead = await call("bash", { command: "ls -la", workdir: foreignWtRepo }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4575
|
+
check("a read-only command in a foreign workdir stays allowed", !blocked(foreignRead));
|
|
4576
|
+
const linkedWrite = await call("bash", { command: "echo x > .tmp-linked.txt", workdir: wtLinked }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4577
|
+
check("a relative shell mutation in a linked worktree of the same repository is allowed", !blocked(linkedWrite));
|
|
4578
|
+
const linkedAbsoluteWrite = await call("bash", { command: `echo x > ${join(wtRepo, "abs.txt")}`, workdir: wtLinked }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4579
|
+
check("an absolute target in the primary checkout stays allowed from a linked worktree", !blocked(linkedAbsoluteWrite));
|
|
4580
|
+
const linkedOutsideWrite = await call("bash", { command: `echo x > ${join(foreignWtRepo, "outside.txt")}`, workdir: wtLinked }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4581
|
+
check("an absolute target outside both checkouts is still blocked from a linked worktree", blocked(linkedOutsideWrite));
|
|
4582
|
+
|
|
4583
|
+
// An in-command `cd` moves the tree later segments run in, so the walk has to
|
|
4584
|
+
// follow it. Judging every segment against the shell's initial `workdir` let
|
|
4585
|
+
// `cd <foreign> && git commit` be checked as a commit in the session's own
|
|
4586
|
+
// checkout, which both allowed a foreign write and denied nothing.
|
|
4587
|
+
const cdForeignCommit = await call("bash", { command: `cd ${foreignWtRepo} && git commit -m x --allow-empty` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4588
|
+
check(
|
|
4589
|
+
"an in-command cd into a foreign repository is blocked for a git write",
|
|
4590
|
+
blocked(cdForeignCommit) && String(cdForeignCommit).includes(foreignWtRepo),
|
|
4591
|
+
);
|
|
4592
|
+
const cdForeignWrite = await call("bash", { command: `cd ${foreignWtRepo} && echo x > out.txt` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4593
|
+
check("an in-command cd into a foreign repository is blocked for a shell mutation", blocked(cdForeignWrite));
|
|
4594
|
+
const cdForeignRead = await call("bash", { command: `cd ${foreignWtRepo} && ls -la` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4595
|
+
check("an in-command cd into a foreign repository stays allowed for a read", !blocked(cdForeignRead));
|
|
4596
|
+
// A newline separates commands exactly like `;`. The dispatcher planned its
|
|
4597
|
+
// segments from the newline-flattened command, so a newline-separated `cd`
|
|
4598
|
+
// moved nothing and every later segment was judged against the shell's start
|
|
4599
|
+
// tree. `cd <foreign>` then `rm -rf x` therefore read as a workspace removal.
|
|
4600
|
+
for (const [label, command] of [
|
|
4601
|
+
["a removal", `cd ${foreignWtRepo}\nrm -rf out.txt`],
|
|
4602
|
+
["a git write", `cd ${foreignWtRepo}\ngit commit -m x --allow-empty`],
|
|
4603
|
+
["a redirect", `cd ${foreignWtRepo}\necho x > artifact.txt`],
|
|
4604
|
+
] as const) {
|
|
4605
|
+
const newlineCd = await call("bash", { command }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4606
|
+
check(`a newline-separated cd into a foreign repository is blocked for ${label}`, blocked(newlineCd) && String(newlineCd).includes(foreignWtRepo));
|
|
4607
|
+
}
|
|
4608
|
+
const newlineCdRead = await call("bash", { command: `cd ${foreignWtRepo}\nls -la` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4609
|
+
check("a newline-separated cd into a foreign repository stays allowed for a read", !blocked(newlineCdRead));
|
|
4610
|
+
// A heredoc feeding a shell runs its body as shell text, so the body's `cd`
|
|
4611
|
+
// moves the tree its later lines run in just like a `;`-separated command. The
|
|
4612
|
+
// body was never re-planned, so the removal looked like a workspace write.
|
|
4613
|
+
const heredocCd = await call("bash", { command: `bash <<'WGEOF'\ncd ${foreignWtRepo}\nrm -rf out.txt\nWGEOF` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4614
|
+
check("a heredoc shell body that cds into a foreign repository is blocked", blocked(heredocCd) && String(heredocCd).includes(foreignWtRepo));
|
|
4615
|
+
const heredocGit = await call("bash", { command: `bash <<'WGEOF'\ncd ${foreignWtRepo}\ngit commit -m x --allow-empty\nWGEOF` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4616
|
+
check("a heredoc shell body that cds into a foreign repository is blocked for a git write", blocked(heredocGit) && String(heredocGit).includes(foreignWtRepo));
|
|
4617
|
+
// Bash still runs a heredoc body it closes at end of input, so an unterminated
|
|
4618
|
+
// body is code too. Requiring a terminator line dropped the payload entirely.
|
|
4619
|
+
const heredocUnterminated = await call("bash", { command: `bash <<'WGEOF'\ncd ${foreignWtRepo}\nrm -rf out.txt` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4620
|
+
check("an unterminated heredoc shell body is scanned as code", blocked(heredocUnterminated) && String(heredocUnterminated).includes(foreignWtRepo));
|
|
4621
|
+
// `source` executes a body it reads from stdin, so its body is shell text even
|
|
4622
|
+
// though the launcher list names only interpreters.
|
|
4623
|
+
const heredocSource = await call("bash", { command: `source /dev/stdin <<'WGEOF'\ncd ${foreignWtRepo}\nrm -rf out.txt\nWGEOF` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4624
|
+
check("a source heredoc body that cds into a foreign repository is blocked", blocked(heredocSource) && String(heredocSource).includes(foreignWtRepo));
|
|
4625
|
+
// The body feeds `cat`, but the pipe delivers it to a shell, so it runs.
|
|
4626
|
+
const heredocPiped = await call("bash", { command: `cat <<'WGEOF' | sh\ncd ${foreignWtRepo}\nrm -rf out.txt\nWGEOF` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4627
|
+
check("a heredoc body piped into a shell is scanned as code", blocked(heredocPiped) && String(heredocPiped).includes(foreignWtRepo));
|
|
4628
|
+
// The pipe decides, and an unrecognized downstream command is not a filter. An
|
|
4629
|
+
// allowlist that named only common shells let `| ash` and `| make -f -` run a
|
|
4630
|
+
// body the scan had already masked as prose.
|
|
4631
|
+
for (const downstream of ["ash", "make -f -", "busybox sh", "xargs sh -c"]) {
|
|
4632
|
+
const pipeUnknown = await call("bash", { command: `cat <<'WGEOF' | ${downstream}\ncd ${foreignWtRepo}\nrm -rf out.txt\nWGEOF` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4633
|
+
check(`a heredoc body piped into ${downstream} is scanned as code`, blocked(pipeUnknown) && String(pipeUnknown).includes(foreignWtRepo));
|
|
4634
|
+
}
|
|
4635
|
+
const pipeFilter = await call("bash", { command: `cat <<'WGEOF' | grep build\ncd ${foreignWtRepo}\nrm -rf out.txt\nWGEOF` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4636
|
+
check("a heredoc body piped into a known filter stays data", allowed(pipeFilter));
|
|
4637
|
+
// A pipeline hands the body to every stage, so a filter followed by a shell
|
|
4638
|
+
// still executes it. Reading only the first stage masked the body as prose.
|
|
4639
|
+
for (const pipeline of ["grep x | sh", "cat | bash", "tee /dev/null | sh"]) {
|
|
4640
|
+
const pipeChain = await call("bash", { command: `cat <<'WGEOF' | ${pipeline}\ncd ${foreignWtRepo}\nrm -rf out.txt\nWGEOF` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4641
|
+
check(`a heredoc body reaching a shell through ${pipeline} is scanned as code`, blocked(pipeChain) && String(pipeChain).includes(foreignWtRepo));
|
|
4642
|
+
}
|
|
4643
|
+
// `|&` also pipes, so a body it delivers to a shell runs exactly like a `|`
|
|
4644
|
+
// body. `||` runs the right side only on failure and delivers no body.
|
|
4645
|
+
for (const operator of ["|& sh", "|& ash"]) {
|
|
4646
|
+
const ampersandPipe = await call("bash", { command: `cat <<'WGEOF' ${operator}\ncd ${foreignWtRepo}\nrm -rf out.txt\nWGEOF` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4647
|
+
check(`a heredoc body delivered through ${operator} is scanned as code`, blocked(ampersandPipe) && String(ampersandPipe).includes(foreignWtRepo));
|
|
4648
|
+
}
|
|
4649
|
+
// `|&` into a filter is still a filter, so the body stays prose.
|
|
4650
|
+
const ampersandFilter = await call("bash", { command: `cat <<'WGEOF' |& grep build\ncd ${foreignWtRepo}\nrm -rf out.txt\nWGEOF` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4651
|
+
check("a heredoc body delivered through |& to a filter stays data", allowed(ampersandFilter));
|
|
4652
|
+
const logicalOrPipe = await call("bash", { command: `cat <<'WGEOF' || grep build\ncd ${foreignWtRepo}\nrm -rf out.txt\nWGEOF` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4653
|
+
check("a heredoc body after || stays data because no pipe delivers it", allowed(logicalOrPipe));
|
|
4654
|
+
// A program that can run a command named in its own arguments is not a filter.
|
|
4655
|
+
// `sed`, `awk`, and `sort` each execute the body's text with a flag or program.
|
|
4656
|
+
for (const header of ["sed 'e'", "sed -n '1e echo hi'", `awk '{system($0)}'`, "sort --compress-program='sh'"]) {
|
|
4657
|
+
const execConsumer = await call("bash", { command: `${header} <<'WGEOF'\ncd ${foreignWtRepo}\nrm -rf out.txt\nWGEOF` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4658
|
+
check(`a heredoc consumed by ${header} is scanned as code`, blocked(execConsumer) && String(execConsumer).includes(foreignWtRepo));
|
|
4659
|
+
}
|
|
4660
|
+
// A heredoc feeding `cat` is data. Its body is never executed, so documentation
|
|
4661
|
+
// that quotes a blocked command shape must stay writable. The shell-backed form
|
|
4662
|
+
// above is code; this one is prose.
|
|
4663
|
+
const heredocDoc = await call("bash", { command: `cat > notes.md <<'WGEOF'\nbash -c 'cd /other-repo && rm -rf x' is blocked.\nWGEOF` }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
|
|
4664
|
+
check("a data heredoc quoting a blocked command shape is not treated as code", allowed(heredocDoc));
|
|
4665
|
+
const heredocDocProse = await call("bash", { command: `cat > notes.md <<'WGEOF'\nrm -rf build is blocked by policy.\nWGEOF` }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
|
|
4666
|
+
check("a data heredoc body is not read as a shell mutation", allowed(heredocDocProse));
|
|
4667
|
+
// The mask has to find the heredoc in the raw text. A quoted `<<` opened a fake
|
|
4668
|
+
// body and swallowed the real payload that followed it.
|
|
4669
|
+
const heredocMasked = await call("bash", { command: `echo "<<DONE"\npython3 -c "open('${join(foreignWtRepo, "evil")}','w').write('x')"\nDONE` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4670
|
+
check("a quoted << does not mask a later interpreter payload", blocked(heredocMasked) && String(heredocMasked).includes(foreignWtRepo));
|
|
4671
|
+
// One header line can introduce two bodies. Only the first was ranged, so the
|
|
4672
|
+
// second body was read as top-level commands.
|
|
4673
|
+
const heredocPair = await call("bash", { command: `cat <<A <<B\nhello\nA\nrm -rf build is prose\nB` }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
|
|
4674
|
+
check("a second heredoc body on the same header line stays data", !blocked(heredocPair));
|
|
4675
|
+
const cdIndeterminate = await call("bash", { command: "cd $WG_UNKNOWN_TREE && git commit -m x" }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4676
|
+
check(
|
|
4677
|
+
"an indeterminate cd target followed by a write fails closed",
|
|
4678
|
+
blocked(cdIndeterminate) && String(cdIndeterminate).includes("cannot be determined"),
|
|
4679
|
+
);
|
|
4680
|
+
const cdWorkspaceSubdir = await call("bash", { command: "mkdir -p nested && cd nested && echo x > artifact.txt" }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4681
|
+
check(
|
|
4682
|
+
"an in-command cd into a workspace subdirectory is judged where it lands, not as a foreign tree",
|
|
4683
|
+
String(cdWorkspaceSubdir).includes("protected branch") && !String(cdWorkspaceSubdir).includes("outside workspace root"),
|
|
4684
|
+
);
|
|
4685
|
+
const cdLinkedSubdir = await call("bash", { command: "mkdir -p nested && cd nested && echo x > artifact.txt" }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
|
|
4686
|
+
check("an in-command cd into a linked-worktree subdirectory is allowed", !blocked(cdLinkedSubdir));
|
|
4687
|
+
const cdLinkedCommit = await call("bash", { command: `cd ${wtLinked} && git commit -m x --allow-empty` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4688
|
+
check("an in-command cd into a linked worktree is not blocked as a foreign tree", !blocked(cdLinkedCommit));
|
|
4689
|
+
const wrapperCwdCommit = await call("bash", { command: `env -C ${foreignWtRepo} git commit -m x --allow-empty` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4690
|
+
check("a wrapper -C into a foreign repository is blocked for a git write", blocked(wrapperCwdCommit));
|
|
4691
|
+
// The nested payload is one shell word to the outer shell, so its own `cd` and
|
|
4692
|
+
// its own `cwd` option were invisible to the top-level walk.
|
|
4693
|
+
const nestedCd = await call("bash", { command: `bash -c 'cd ${foreignWtRepo} && git commit -m x --allow-empty'` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4694
|
+
check("a nested shell payload that cds into a foreign repository is blocked", blocked(nestedCd) && String(nestedCd).includes(foreignWtRepo));
|
|
4695
|
+
const nestedExecCwd = await call("bash", { command: `node -e "require('child_process').execSync('git commit -m x --allow-empty', {cwd:'${foreignWtRepo}'})"` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4696
|
+
check("a nested interpreter command with a foreign cwd is blocked", blocked(nestedExecCwd) && String(nestedExecCwd).includes(foreignWtRepo));
|
|
4697
|
+
const nestedLocal = await call("bash", { command: `node -e "require('child_process').execSync('git status', {cwd:'${wtRepo}'})"` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4698
|
+
check("a nested interpreter command in the workspace stays allowed", !blocked(nestedLocal));
|
|
4699
|
+
const nestedLinked = await call("bash", { command: `bash -c 'cd ${wtLinked} && git commit -m x --allow-empty'` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4700
|
+
check("a nested payload cd into a linked worktree is not blocked as foreign", !blocked(nestedLinked));
|
|
4701
|
+
|
|
4702
|
+
// A `cd` inside a grouping subshell is scoped to it. Treating it as moving the
|
|
4703
|
+
// outer shell made a later workspace mutation read as happening in the foreign
|
|
4704
|
+
// tree the subshell visited.
|
|
4705
|
+
const subshellCdThenWorkspaceWrite = await call("bash", { command: `( cd ${foreignWtRepo} && ls ) && echo x > out.txt` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4706
|
+
check(
|
|
4707
|
+
"a cd inside a grouping subshell does not move the tree of later segments",
|
|
4708
|
+
!String(subshellCdThenWorkspaceWrite).includes(foreignWtRepo) && !String(subshellCdThenWorkspaceWrite).includes("outside workspace root"),
|
|
4709
|
+
);
|
|
4710
|
+
const subshellCdThenWorkspaceCommit = await call("bash", { command: `( cd ${foreignWtRepo} && ls ) && git commit -m x --allow-empty` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4711
|
+
check("a subshell cd does not foreign-flag a later commit in the workspace", !String(subshellCdThenWorkspaceCommit).includes(foreignWtRepo));
|
|
4712
|
+
// The payload's declared root is only a working directory when a spawn-family
|
|
4713
|
+
// call or `process.chdir` names it. An unrelated `{cwd: ...}` key is not one.
|
|
4714
|
+
const payloadObjectKey = await call("bash", { command: `node -e "const o={cwd:'${foreignWtRepo}'}; require('fs').writeFileSync('probe.txt','y')"` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4715
|
+
check("an unrelated object cwd key does not foreign-flag a payload write", !String(payloadObjectKey).includes(foreignWtRepo));
|
|
4716
|
+
const payloadUnrelatedChdir = await call("bash", { command: `node -e "foo.chdir('${foreignWtRepo}'); require('fs').writeFileSync('probe.txt','y')"` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4717
|
+
check("chdir on an unrelated object does not foreign-flag a payload write", !String(payloadUnrelatedChdir).includes(foreignWtRepo));
|
|
4718
|
+
const payloadProcessChdir = await call("bash", { command: `node -e "process.chdir('${foreignWtRepo}'); require('fs').writeFileSync('probe.txt','y')"` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4719
|
+
check("process.chdir into a foreign directory before a write is blocked", blocked(payloadProcessChdir) && String(payloadProcessChdir).includes(foreignWtRepo));
|
|
4720
|
+
const payloadUnresolvableCwd = await call("bash", { command: "node -e \"require('child_process').execSync('git commit -m x --allow-empty', {cwd: process.env.WG_OTHER});\"" }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4721
|
+
check(
|
|
4722
|
+
"a payload mutating with an unresolvable cwd fails closed",
|
|
4723
|
+
blocked(payloadUnresolvableCwd) && String(payloadUnresolvableCwd).includes("cannot be determined"),
|
|
4724
|
+
);
|
|
4725
|
+
// An indeterminate launcher tree must not be assumed to be the workspace root.
|
|
4726
|
+
// `cd $DIR && node -e "writeFileSync('x')"` used to read as a workspace write
|
|
4727
|
+
// because the payload walk substituted the workspace root for the unknown one.
|
|
4728
|
+
const payloadIndeterminateLauncher = await call("bash", { command: `cd $WG_UNKNOWN_WT && node -e "require('fs').writeFileSync('probe.txt','y')"` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4729
|
+
check(
|
|
4730
|
+
"a payload mutating under an indeterminate launcher tree fails closed",
|
|
4731
|
+
blocked(payloadIndeterminateLauncher) && String(payloadIndeterminateLauncher).includes("cannot be determined"),
|
|
4732
|
+
);
|
|
4733
|
+
const payloadIndeterminateLauncherGit = await call("bash", { command: `cd $WG_UNKNOWN_WT && node -e "require('child_process').execSync('git commit -m x --allow-empty')"` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4734
|
+
check("a payload git write under an indeterminate launcher tree fails closed", blocked(payloadIndeterminateLauncherGit));
|
|
4735
|
+
const payloadIndeterminateLauncherRead = await call("bash", { command: `cd $WG_UNKNOWN_WT && node -e "console.log(1)"` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4736
|
+
check("a read-only payload under an indeterminate launcher tree stays allowed", !blocked(payloadIndeterminateLauncherRead));
|
|
4737
|
+
// The spawn anchor must not read `regex.exec(str, {cwd})` as a spawned process.
|
|
4738
|
+
// Asserted in the linked worktree, where a write is allowed outright, so the
|
|
4739
|
+
// verdict cannot pass for the unrelated protected-branch reason.
|
|
4740
|
+
const payloadRegexExec = await call("bash", { command: `node -e "const r=/a/.exec('b',{cwd:'${foreignWtRepo}'}); require('fs').writeFileSync('probe.txt','y')"` }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
|
|
4741
|
+
check(
|
|
4742
|
+
"a RegExp exec with a cwd key is not treated as a spawn working directory",
|
|
4743
|
+
!blocked(payloadRegexExec) && !String(payloadRegexExec).includes(foreignWtRepo),
|
|
4744
|
+
);
|
|
4745
|
+
|
|
4746
|
+
// A `pushd` inside a grouping subshell leaves its entry on the subshell's own
|
|
4747
|
+
// stack. bash discards that stack with the subshell, so a later outer `popd`
|
|
4748
|
+
// stops at "directory stack empty" and leaves the outer `cd` in force. A walk
|
|
4749
|
+
// that leaked the inner entry planned the outer removal back at the workspace.
|
|
4750
|
+
// The genuine leak shape puts a `cd` between the two, so the outer directory is
|
|
4751
|
+
// no longer the workspace and the removal lands in the foreign tree.
|
|
4752
|
+
const subshellPushdThenPopd = await call("bash", { command: `( pushd ${foreignWtRepo} >/dev/null ) ; cd ${foreignWtRepo} ; popd >/dev/null 2>&1 ; rm -rf out.txt` }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
|
|
4753
|
+
check(
|
|
4754
|
+
"a pushd inside a grouping subshell does not leak into a later outer popd",
|
|
4755
|
+
blocked(subshellPushdThenPopd) && String(subshellPushdThenPopd).includes(foreignWtRepo),
|
|
4756
|
+
);
|
|
4757
|
+
const subshellPushdThenPopdCommit = await call("bash", { command: `( pushd ${foreignWtRepo} >/dev/null ) ; cd ${foreignWtRepo} ; popd >/dev/null 2>&1 ; git commit -m x --allow-empty` }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
|
|
4758
|
+
check("a leaked subshell pushd does not let an outer git write read as the workspace", blocked(subshellPushdThenPopdCommit) && String(subshellPushdThenPopdCommit).includes(foreignWtRepo));
|
|
4759
|
+
// A `popd` that only rotates the stack is not a pop, so it fails closed.
|
|
4760
|
+
const popdRotate = await call("bash", { command: `pushd ${foreignWtRepo} >/dev/null 2>&1 ; popd +1 >/dev/null 2>&1 ; rm -rf out.txt` }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
|
|
4761
|
+
check("a popd stack rotation followed by a mutation fails closed", blocked(popdRotate) && String(popdRotate).includes("cannot be determined"));
|
|
4762
|
+
|
|
4763
|
+
// `pushd +N` and `pushd -N` rotate the stack and `-n` only pushes: neither
|
|
4764
|
+
// changes directory the way a plain `pushd <dir>` does, so the walk reports
|
|
4765
|
+
// them as unchecked and a mutation after one fails closed.
|
|
4766
|
+
const pushdRotate = await call("bash", { command: "pushd +1 >/dev/null 2>&1 ; rm -rf out.txt" }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
|
|
4767
|
+
check("a pushd stack rotation followed by a mutation fails closed", blocked(pushdRotate) && String(pushdRotate).includes("cannot be determined"));
|
|
4768
|
+
const pushdNoChange = await call("bash", { command: `pushd -n ${foreignWtRepo} >/dev/null 2>&1 ; rm -rf out.txt` }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
|
|
4769
|
+
check("a pushd -n that only pushes the stack fails closed before a mutation", blocked(pushdNoChange));
|
|
4770
|
+
const pushdRotateRead = await call("bash", { command: "pushd +1 >/dev/null 2>&1 ; ls -la" }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
|
|
4771
|
+
check("a pushd stack rotation followed by a read stays allowed", !blocked(pushdRotateRead));
|
|
4772
|
+
|
|
4773
|
+
// A control-flow body runs in the CURRENT shell, so a `cd` inside it moves the
|
|
4774
|
+
// directory of later segments. Which body runs depends on a condition this walk
|
|
4775
|
+
// cannot evaluate, so a construct whose bodies disagree about the directory
|
|
4776
|
+
// fails closed rather than letting the last body parsed win. Only `( … )`
|
|
4777
|
+
// scopes a directory.
|
|
4778
|
+
for (const [label, command] of [
|
|
4779
|
+
["then", `if true; then cd ${foreignWtRepo}; fi; rm -rf out.txt`],
|
|
4780
|
+
["else", `if true; then cd ${foreignWtRepo}; else cd .; fi; rm -rf out.txt`],
|
|
4781
|
+
["do", `for i in 1; do cd ${foreignWtRepo}; done; rm -rf out.txt`],
|
|
4782
|
+
["while", `while false; do :; done; cd ${foreignWtRepo}; rm -rf out.txt`],
|
|
4783
|
+
["case", `case x in x) cd ${foreignWtRepo};; esac; rm -rf out.txt`],
|
|
4784
|
+
["brace group", `{ cd ${foreignWtRepo} ; } ; rm -rf out.txt`],
|
|
4785
|
+
] as const) {
|
|
4786
|
+
const flowMutation = await call("bash", { command }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
|
|
4787
|
+
check(`a conditional cd inside a '${label}' body fails closed before a mutation`, blocked(flowMutation));
|
|
4788
|
+
}
|
|
4789
|
+
// A construct body that mutates without changing directory must stay allowed:
|
|
4790
|
+
// the condition does not affect the tree the mutation lands in.
|
|
4791
|
+
const conditionalNoCd = await call("bash", { command: `if [ -d .git ]; then echo hi; fi ; echo x > out.txt` }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
|
|
4792
|
+
check("a conditional body with no cd keeps the tree of later segments", !blocked(conditionalNoCd));
|
|
4793
|
+
const caseArmParen = await call("bash", { command: `( case x in x) cd ${foreignWtRepo} ;; esac ; mkdir -p mk )` }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
|
|
4794
|
+
check("a case pattern paren is not read as a grouping close", blocked(caseArmParen));
|
|
4795
|
+
// A `case` that follows a body opener (`then`, `else`, `do`, `elif`) still opens
|
|
4796
|
+
// an arm. Only a leading `case` was recognised, so the arm's `cd` went unseen and
|
|
4797
|
+
// the `esac` popped the enclosing frame instead.
|
|
4798
|
+
for (const [label, command] of [
|
|
4799
|
+
["then", `if true; then case x in x) cd ${foreignWtRepo};; esac; fi; rm -rf out.txt`],
|
|
4800
|
+
["else", `if true; then :; else case x in x) cd ${foreignWtRepo};; esac; fi; rm -rf out.txt`],
|
|
4801
|
+
["elif", `if a; then :; elif b; then case x in x) cd ${foreignWtRepo};; esac; fi; rm -rf out.txt`],
|
|
4802
|
+
["do", `for i in 1; do case x in x) cd ${foreignWtRepo};; esac; done; rm -rf out.txt`],
|
|
4803
|
+
] as const) {
|
|
4804
|
+
const nestedCase = await call("bash", { command }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
|
|
4805
|
+
check(`a case arm after '${label}' cannot hide its cd`, blocked(nestedCase));
|
|
4806
|
+
}
|
|
4807
|
+
// A case arm inside a grouping subshell is scoped like any other subshell cd, so
|
|
4808
|
+
// its pattern paren must not consume the subshell's own closing paren.
|
|
4809
|
+
const caseInSubshell = await call("bash", { command: `( case x in x) cd ${foreignWtRepo} ;; esac ) ; echo x > out.txt` }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
|
|
4810
|
+
check("a case arm inside a grouping subshell stays scoped to it", !blocked(caseInSubshell));
|
|
4811
|
+
// `eval` runs its body in the current shell, so a `cd` inside it moves the
|
|
4812
|
+
// directory of later segments. Both halves are covered: a payload that changes
|
|
4813
|
+
// directory and mutates, and a payload whose `cd` moves a later outer command.
|
|
4814
|
+
const evalCompound = await call("bash", { command: `eval "cd ${foreignWtRepo} && git commit -m x --allow-empty"` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4815
|
+
check("an eval payload that changes directory and mutates is blocked", blocked(evalCompound));
|
|
4816
|
+
const evalMovesOuter = await call("bash", { command: `eval "cd ${foreignWtRepo}" ; rm -rf out.txt` }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
|
|
4817
|
+
check("an eval cd moves the tree of a later outer command", blocked(evalMovesOuter));
|
|
4818
|
+
const evalHarmless = await call("bash", { command: `eval "echo hi" ; echo x > out.txt` }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
|
|
4819
|
+
check("a harmless eval leaves the tree of later segments alone", !blocked(evalHarmless));
|
|
4820
|
+
// A leading assignment still reaches `eval`, so the `cd` inside it moves the
|
|
4821
|
+
// tree of the lines that follow.
|
|
4822
|
+
const evalAssignment = await call("bash", { command: `A=1 eval 'cd ${foreignWtRepo}'\nrm -rf out.txt` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4823
|
+
check("an assignment-prefixed eval moves the tree of later segments", blocked(evalAssignment) && String(evalAssignment).includes(foreignWtRepo));
|
|
4824
|
+
// `~user` never means a literal `~user` directory. Reading it as a relative
|
|
4825
|
+
// path put the walk inside the workspace while the shell reached that home.
|
|
4826
|
+
const tildeUser = await call("bash", { command: "cd ~root && rm -rf out.txt" }, { sessionID: "s-wt-foreign", worktree: wtLinked, directory: wtLinked });
|
|
4827
|
+
check("a cd to a named user's home is not read as a workspace-relative path", blocked(tildeUser) && String(tildeUser).includes("outside workspace root"));
|
|
4828
|
+
|
|
4829
|
+
// A payload can launch another interpreter. Only the innermost quoted body was
|
|
4830
|
+
// extracted, so the outer launcher swallowed the inner one and its `cd` and
|
|
4831
|
+
// mutation were never re-planned.
|
|
4832
|
+
const doublyNestedPayload = await call("bash", { command: `bash -c "bash -c 'cd ${foreignWtRepo} && git commit -m x --allow-empty'"` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4833
|
+
check("a doubly nested shell payload is re-planned until its mutation is judged", blocked(doublyNestedPayload) && String(doublyNestedPayload).includes(foreignWtRepo));
|
|
4834
|
+
// The `'\''` idiom is how a shell nests a single quote inside a single-quoted
|
|
4835
|
+
// span. Extraction stopped at the first closing quote, so the payload read as a
|
|
4836
|
+
// bare `bash -c ` and the nested mutation was never scanned.
|
|
4837
|
+
const quoteEscapePayload = await call("bash", { command: "bash -c 'bash -c '\\''cd " + foreignWtRepo + " && git commit -m x --allow-empty'\\'''" }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4838
|
+
check("a payload nested with the quote-escape idiom is scanned past the first quote", blocked(quoteEscapePayload) && String(quoteEscapePayload).includes(foreignWtRepo));
|
|
4839
|
+
|
|
4840
|
+
// `git -C <repo>` carries its repository in the flag, with no `cd` to follow.
|
|
4841
|
+
// The payload walk never applied the foreign-repository check to it.
|
|
4842
|
+
const payloadGitDashC = await call("bash", { command: `bash -c 'git -C ${foreignWtRepo} commit -m x --allow-empty'` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4843
|
+
check("a payload git -C into a foreign repository is blocked", blocked(payloadGitDashC) && String(payloadGitDashC).includes(foreignWtRepo));
|
|
4844
|
+
const payloadGitDashCLocal = await call("bash", { command: `bash -c 'git -C ${wtLinked} commit -m x --allow-empty'` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4845
|
+
check("a payload git -C into a linked worktree stays allowed", !blocked(payloadGitDashCLocal));
|
|
4846
|
+
// A spawn `cwd` after a call expression is still a spawn `cwd`. A flat `[^)]*`
|
|
4847
|
+
// up to the first closing paren matched nothing and left the tree unchecked.
|
|
4848
|
+
const spawnCwdAfterCall = await call("bash", { command: `node -e "require('child_process').execSync(String('git commit -m x --allow-empty'), {cwd:'${foreignWtRepo}'})"` }, { sessionID: "s-wt-foreign", worktree: wtRepo, directory: wtRepo });
|
|
4849
|
+
check("a spawn cwd after a nested call is still read as the working directory", blocked(spawnCwdAfterCall) && String(spawnCwdAfterCall).includes(foreignWtRepo));
|
|
4850
|
+
|
|
4851
|
+
rmSync(join(wtLinked, "nested"), { recursive: true, force: true });
|
|
4852
|
+
rmSync(foreignWtRepo, { recursive: true, force: true });
|
|
4853
|
+
spawnSync("git", ["worktree", "remove", "--force", wtLinked], { cwd: wtRepo });
|
|
4854
|
+
rmSync(wtRepo, { recursive: true, force: true });
|
|
4855
|
+
|
|
4856
|
+
// Host session moves rebind the workspace: `session_move` relocates a session
|
|
4857
|
+
// between checkouts, and a stale binding made every later decision judge the
|
|
4858
|
+
// tree the session had left. A session bound to a LINKED WORKTREE by its own
|
|
4859
|
+
// tool-call workdir differs from the directory `session.updated` reports, so an
|
|
4860
|
+
// update alone must never move it back.
|
|
4861
|
+
const moveRepoA = mkdtempSync(join(tmpdir(), "wg-move-a-"));
|
|
4862
|
+
spawnSync("git", ["init", "-b", "main"], { cwd: moveRepoA });
|
|
4863
|
+
spawnSync("git", ["-c", "user.name=T", "-c", "user.email=t@e", "commit", "--allow-empty", "-m", "init"], { cwd: moveRepoA });
|
|
4864
|
+
const moveRepoB = mkdtempSync(join(tmpdir(), "wg-move-b-"));
|
|
4865
|
+
spawnSync("git", ["init", "-b", "main"], { cwd: moveRepoB });
|
|
4866
|
+
spawnSync("git", ["-c", "user.name=T", "-c", "user.email=t@e", "commit", "--allow-empty", "-m", "init"], { cwd: moveRepoB });
|
|
4867
|
+
setSessionWorkspace("s-host-move", moveRepoA);
|
|
4868
|
+
rebindSessionWorkspaceFromHost("s-host-move", moveRepoB, { moved: true });
|
|
4869
|
+
check("an announced host move rebinds the session workspace", getSessionBoundWorkspace("s-host-move") === resolve(moveRepoB));
|
|
4870
|
+
check("an announced host move is journaled as a granted widening", getRecentAuditEntries(20).some((entry) => entry.tool === "workspace.binding" && entry.reason === "workspace_rebind_session_move" && entry.sessionID === "s-host-move"));
|
|
4871
|
+
const beforeNoop = getRecentAuditEntries(200).filter((entry) => entry.reason === "workspace_rebind_session_move").length;
|
|
4872
|
+
rebindSessionWorkspaceFromHost("s-host-move", moveRepoB);
|
|
4873
|
+
const afterNoop = getRecentAuditEntries(200).filter((entry) => entry.reason === "workspace_rebind_session_move").length;
|
|
4874
|
+
check("a repeated move event to the same directory is a no-op", afterNoop === beforeNoop);
|
|
4875
|
+
// The regression: a session whose own tool-call workdir bound it to a LINKED
|
|
4876
|
+
// WORKTREE must survive a routine `session.updated` that names the primary
|
|
4877
|
+
// checkout of the same repository. Both resolve to the same repository, so the
|
|
4878
|
+
// update only records the baseline.
|
|
4879
|
+
const moveLinked = join(mkdtempSync(join(tmpdir(), "wg-move-linked-")), "wt");
|
|
4880
|
+
spawnSync("git", ["worktree", "add", "-b", "feat/host-move", moveLinked], { cwd: moveRepoA });
|
|
4881
|
+
setSessionWorkspace("s-host-linked", moveLinked);
|
|
4882
|
+
rebindSessionWorkspaceFromHost("s-host-linked", moveRepoA);
|
|
4883
|
+
check(
|
|
4884
|
+
"a routine update naming the primary checkout keeps a linked-worktree binding",
|
|
4885
|
+
getSessionBoundWorkspace("s-host-linked") === resolve(moveLinked),
|
|
4886
|
+
);
|
|
4887
|
+
// A directory that resolves to a DIFFERENT repository is a move even on the
|
|
4888
|
+
// first observation, which is how OpenCode 1.x sees one (it has no move event).
|
|
4889
|
+
rebindSessionWorkspaceFromHost("s-host-linked", moveRepoB);
|
|
4890
|
+
check("an update naming another repository rebinds", getSessionBoundWorkspace("s-host-linked") === resolve(moveRepoB));
|
|
4891
|
+
forgetSessionWorkspace("s-host-linked");
|
|
4892
|
+
spawnSync("git", ["worktree", "remove", "--force", moveLinked], { cwd: moveRepoA });
|
|
4893
|
+
// A later update that names a DIFFERENT directory than the last one is a move
|
|
4894
|
+
// even without the `session.moved` announcement.
|
|
4895
|
+
rebindSessionWorkspaceFromHost("s-host-move", moveRepoA);
|
|
4896
|
+
check("an update naming a new directory rebinds without an announcement", getSessionBoundWorkspace("s-host-move") === resolve(moveRepoA));
|
|
4897
|
+
rebindSessionWorkspaceFromHost(undefined, moveRepoA);
|
|
4898
|
+
rebindSessionWorkspaceFromHost("s-host-move", undefined);
|
|
4899
|
+
check("an incomplete move event never binds", getSessionBoundWorkspace("s-host-move") === resolve(moveRepoA));
|
|
4900
|
+
// An unresolved directory is not cached, so a later event for the same path
|
|
4901
|
+
// retries instead of being suppressed forever by a stale "already seen". A path
|
|
4902
|
+
// that resolves to another repository is then a move.
|
|
4903
|
+
const notYetRepo = mkdtempSync(join(tmpdir(), "wg-host-retry-"));
|
|
4904
|
+
setSessionWorkspace("s-host-retry", moveRepoA);
|
|
4905
|
+
rebindSessionWorkspaceFromHost("s-host-retry", notYetRepo);
|
|
4906
|
+
check("an unresolved host directory leaves the binding alone", getSessionBoundWorkspace("s-host-retry") === resolve(moveRepoA));
|
|
4907
|
+
spawnSync("git", ["init", "-b", "main"], { cwd: notYetRepo });
|
|
4908
|
+
rebindSessionWorkspaceFromHost("s-host-retry", notYetRepo);
|
|
4909
|
+
check("a retry for the now-resolvable directory rebinds when it is another repository", getSessionBoundWorkspace("s-host-retry") === resolve(notYetRepo));
|
|
4910
|
+
forgetSessionWorkspace("s-host-retry");
|
|
4911
|
+
check("deleting a session drops its binding", getSessionBoundWorkspace("s-host-retry") === undefined);
|
|
4912
|
+
rmSync(notYetRepo, { recursive: true, force: true });
|
|
4913
|
+
rmSync(moveRepoA, { recursive: true, force: true });
|
|
4914
|
+
rmSync(moveRepoB, { recursive: true, force: true });
|
|
4915
|
+
|
|
3726
4916
|
// Lockfile synchronization tests
|
|
3727
4917
|
const lockRepo = mkdtempSync(join(tmpdir(), "wg-lock-repo-"));
|
|
3728
4918
|
spawnSync("git", ["init", "-b", "main"], { cwd: lockRepo });
|
|
@@ -5797,20 +6987,86 @@ try {
|
|
|
5797
6987
|
{ workspaceRoot: seatFeatureRepo, client: fakeClient as unknown },
|
|
5798
6988
|
);
|
|
5799
6989
|
check(
|
|
5800
|
-
"seat
|
|
5801
|
-
pushSeat.effect
|
|
6990
|
+
"seat defers a cwd-dependent push instead of judging the session checkout",
|
|
6991
|
+
pushSeat.effect === "abstain" && pushSeat.decision?.code === "repository_unidentified",
|
|
6992
|
+
);
|
|
6993
|
+
// An explicit `-C` names the repository in the command text, so the seat is
|
|
6994
|
+
// back on facts and runs the core. That is the path that reports the
|
|
6995
|
+
// deliberately skipped remote PR probe.
|
|
6996
|
+
const explicitPushSeat = await evaluatePermissionSeat(
|
|
6997
|
+
{ ...seatEvent([`git -C ${seatFeatureRepo} push origin feature/push-seat`]), sessionID: "s-seat-explicit" },
|
|
6998
|
+
{ workspaceRoot: seatFeatureRepo, client: fakeClient as unknown },
|
|
5802
6999
|
);
|
|
5803
7000
|
check(
|
|
5804
|
-
"seat
|
|
5805
|
-
|
|
7001
|
+
"seat still runs the core when the command names its repository",
|
|
7002
|
+
explicitPushSeat.effect === "abstain" && explicitPushSeat.decision?.code === "remote_state_unchecked",
|
|
5806
7003
|
);
|
|
5807
|
-
|
|
7004
|
+
// The bug this fixes: the session sat in the primary checkout while the
|
|
7005
|
+
// command targeted a linked worktree, and the seat denied on the primary
|
|
7006
|
+
// checkout's protected branch. A cwd-dependent push must never be judged
|
|
7007
|
+
// against a checkout the command does not name.
|
|
7008
|
+
const protectedCheckoutSeat = await evaluatePermissionSeat(
|
|
5808
7009
|
{ ...seatEvent(["git push origin feature/push-seat"]), sessionID: "s-seat-block" },
|
|
5809
7010
|
{ workspaceRoot: seatMainRepo, client: fakeClient as unknown },
|
|
5810
7011
|
);
|
|
5811
7012
|
check(
|
|
5812
|
-
"seat
|
|
5813
|
-
|
|
7013
|
+
"seat no longer denies a cwd-dependent push from a protected session checkout",
|
|
7014
|
+
protectedCheckoutSeat.effect === "abstain" && protectedCheckoutSeat.decision?.code === "repository_unidentified",
|
|
7015
|
+
);
|
|
7016
|
+
// A push whose DESTINATION is protected is a finding in every checkout, so
|
|
7017
|
+
// the workdir cannot change the verdict and the seat keeps its denial.
|
|
7018
|
+
const protectedDestSeat = await evaluatePermissionSeat(
|
|
7019
|
+
{ ...seatEvent(["git push origin main"]), sessionID: "s-seat-dest" },
|
|
7020
|
+
{ workspaceRoot: seatFeatureRepo, client: fakeClient as unknown },
|
|
7021
|
+
);
|
|
7022
|
+
check(
|
|
7023
|
+
"seat denies a push to a protected destination regardless of the working directory",
|
|
7024
|
+
protectedDestSeat.effect === "deny",
|
|
7025
|
+
);
|
|
7026
|
+
// A CONFIGURED protected branch is not that case: the seat recognizes the
|
|
7027
|
+
// built-in pair only, so the verdict still depends on the checkout and
|
|
7028
|
+
// defers to execute.before, which resolves the workdir and reads config.
|
|
7029
|
+
mkdirSync(join(seatFeatureRepo, ".opencode"), { recursive: true });
|
|
7030
|
+
writeFileSync(join(seatFeatureRepo, ".opencode", "workflow-guard.json"), JSON.stringify({ protectedBranches: ["release/prod"] }));
|
|
7031
|
+
reloadProjectConfig(seatFeatureRepo);
|
|
7032
|
+
const configuredDestSeat = await evaluatePermissionSeat(
|
|
7033
|
+
{ ...seatEvent(["git push origin release/prod"]), sessionID: "s-seat-configured-dest" },
|
|
7034
|
+
{ workspaceRoot: seatFeatureRepo, client: fakeClient as unknown },
|
|
7035
|
+
);
|
|
7036
|
+
check(
|
|
7037
|
+
"seat defers a push to a configured protected branch instead of judging the session checkout",
|
|
7038
|
+
configuredDestSeat.effect === "abstain" && configuredDestSeat.decision?.code === "repository_unidentified",
|
|
7039
|
+
);
|
|
7040
|
+
rmSync(join(seatFeatureRepo, ".opencode"), { recursive: true, force: true });
|
|
7041
|
+
reloadProjectConfig(seatFeatureRepo);
|
|
7042
|
+
const explicitProtectedSeat = await evaluatePermissionSeat(
|
|
7043
|
+
{ ...seatEvent([`git -C ${seatMainRepo} push origin feature/push-seat`]), sessionID: "s-seat-explicit-protected" },
|
|
7044
|
+
{ workspaceRoot: seatMainRepo, client: fakeClient as unknown },
|
|
7045
|
+
);
|
|
7046
|
+
check(
|
|
7047
|
+
"seat denies a push from an explicitly named protected checkout",
|
|
7048
|
+
explicitProtectedSeat.effect === "deny" && explicitProtectedSeat.decision?.status === "blocked" && /protected branch/i.test(String(explicitProtectedSeat.decision?.message)),
|
|
7049
|
+
);
|
|
7050
|
+
// Naming a repository outside the session's workspace stays a boundary
|
|
7051
|
+
// finding, and the seat keeps denying it for that reason.
|
|
7052
|
+
const foreignRepoSeat = await evaluatePermissionSeat(
|
|
7053
|
+
{ ...seatEvent([`git -C ${seatMainRepo} commit -m x`]), sessionID: "s-seat-foreign" },
|
|
7054
|
+
{ workspaceRoot: seatFeatureRepo, client: fakeClient as unknown },
|
|
7055
|
+
);
|
|
7056
|
+
check(
|
|
7057
|
+
"seat denies an explicitly named repository outside the workspace",
|
|
7058
|
+
foreignRepoSeat.effect === "deny" && foreignRepoSeat.decision?.policy === "boundary",
|
|
7059
|
+
);
|
|
7060
|
+
// Deferring a checkout-derived verdict must never hide a finding about the
|
|
7061
|
+
// command text itself. A secret read in the same compound command is a real
|
|
7062
|
+
// block, no matter which checkout the push would have used.
|
|
7063
|
+
const compoundSeat = await evaluatePermissionSeat(
|
|
7064
|
+
{ ...seatEvent(["cat .env && git push origin feature/push-seat"]), sessionID: "s-seat-compound" },
|
|
7065
|
+
{ workspaceRoot: seatFeatureRepo, client: fakeClient as unknown },
|
|
7066
|
+
);
|
|
7067
|
+
check(
|
|
7068
|
+
"a genuine non-checkout finding in a compound command is still denied",
|
|
7069
|
+
compoundSeat.effect === "deny" && compoundSeat.decision?.policy === "secrets",
|
|
5814
7070
|
);
|
|
5815
7071
|
const askedSeat = await evaluatePermissionSeat(
|
|
5816
7072
|
{ ...seatEvent(["git push origin feature/push-seat"], "ask"), sessionID: "s-seat-ask" },
|
|
@@ -5822,9 +7078,9 @@ try {
|
|
|
5822
7078
|
rmSync(seatMainRepo, { recursive: true, force: true });
|
|
5823
7079
|
}
|
|
5824
7080
|
|
|
5825
|
-
// The end-to-end seat:
|
|
5826
|
-
//
|
|
5827
|
-
//
|
|
7081
|
+
// The end-to-end seat: a deferred command through the registered hook must
|
|
7082
|
+
// leave the configured effect and message untouched, and journal the deferral
|
|
7083
|
+
// so a skipped decision stays legible.
|
|
5828
7084
|
{
|
|
5829
7085
|
resetPluginInstancesForTesting();
|
|
5830
7086
|
const seatHookRepo = makeSeatRepo("feature/push-seat-hook");
|
|
@@ -5835,22 +7091,32 @@ try {
|
|
|
5835
7091
|
const seatAskEvent = { ...permissionEvent("shell", ["git push origin feature/push-seat-hook"], "s-seat-hook-ask"), effect: "ask" as "allow" | "deny" | "ask" };
|
|
5836
7092
|
await seatCtx.state.permissionHooks[0]?.(seatAskEvent);
|
|
5837
7093
|
check(
|
|
5838
|
-
"evaluate leaves a configured ask in place when the
|
|
7094
|
+
"evaluate leaves a configured ask in place when the seat defers",
|
|
5839
7095
|
seatAskEvent.effect === "ask" && seatAskEvent.message === undefined,
|
|
5840
7096
|
);
|
|
5841
7097
|
const seatPushEvent = permissionEvent("shell", ["git push origin feature/push-seat-hook"], "s-seat-hook");
|
|
5842
7098
|
await seatCtx.state.permissionHooks[0]?.(seatPushEvent);
|
|
5843
7099
|
check(
|
|
5844
|
-
"evaluate does not deny a
|
|
7100
|
+
"evaluate does not deny a cwd-dependent push it cannot locate",
|
|
5845
7101
|
seatPushEvent.effect === "allow" && seatPushEvent.message === undefined,
|
|
5846
7102
|
);
|
|
5847
|
-
const
|
|
7103
|
+
const deferAudit = getRecentAuditEntries(20).find((entry) => entry.tool === "permission.ask" && entry.phase === "decision" && entry.reason === "deferred_to_execute_before");
|
|
7104
|
+
check(
|
|
7105
|
+
"a cwd-dependent deferral is journaled with its own code, not as a plain allow",
|
|
7106
|
+
deferAudit !== undefined &&
|
|
7107
|
+
deferAudit.policyDecision?.status === "needs_approval" &&
|
|
7108
|
+
deferAudit.policyDecision?.code === "repository_unidentified" &&
|
|
7109
|
+
(deferAudit.input as any)?.deferred === "execute.before",
|
|
7110
|
+
);
|
|
7111
|
+
// The remote-probe deferral still exists for commands that name their
|
|
7112
|
+
// repository, because the seat's simulation skips that probe on purpose.
|
|
7113
|
+
const explicitSeatEvent = permissionEvent("shell", [`git -C ${seatHookRepo} push origin feature/push-seat-hook`], "s-seat-hook-explicit");
|
|
7114
|
+
await seatCtx.state.permissionHooks[0]?.(explicitSeatEvent);
|
|
7115
|
+
const remoteProbeAudit = getRecentAuditEntries(20).find((entry) => entry.tool === "permission.ask" && entry.phase === "decision" && entry.sessionID === "s-seat-hook-explicit");
|
|
5848
7116
|
check(
|
|
5849
|
-
"
|
|
5850
|
-
|
|
5851
|
-
|
|
5852
|
-
abstainAudit.policyDecision?.code === "remote_state_unchecked" &&
|
|
5853
|
-
(abstainAudit.input as any)?.deferred === "execute.before",
|
|
7117
|
+
"a repository-naming command keeps the remote-probe deferral",
|
|
7118
|
+
explicitSeatEvent.effect === "allow" &&
|
|
7119
|
+
remoteProbeAudit?.policyDecision?.code === "remote_state_unchecked",
|
|
5854
7120
|
);
|
|
5855
7121
|
seatCleanup?.();
|
|
5856
7122
|
if (prevSeatDataHome === undefined) delete process.env.XDG_DATA_HOME;
|