jules-orchestrator-kit 0.71.0 → 0.72.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agent/rules/jules-protocol.md +0 -1
- package/CHANGELOG.md +30 -0
- package/JULES_RULES_TEMPLATE.md +0 -1
- package/README.md +56 -1
- package/ROADMAP_V1.md +22 -6
- package/bin/agentctl.mjs +57 -18
- package/package.json +1 -1
- package/scripts/guard-reach-check.mjs +28 -5
- package/src/config.mjs +346 -2
- package/src/coverage.mjs +85 -13
- package/src/engine.mjs +97 -142
- package/src/git.mjs +104 -1
- package/src/guard-policy.mjs +413 -6
- package/src/ops/command-registry.mjs +109 -0
- package/src/ops/test-collection.mjs +29 -3
- package/src/security.mjs +458 -19
- package/src/stack-detector.mjs +21 -0
- package/src/test-paths.mjs +18 -1
- package/src/wizard-init.mjs +16 -12
- package/src/wizard-oracle.mjs +1 -1
package/src/engine.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { loadConfig,
|
|
1
|
+
import { loadConfig, resolveTrustedPolicy } from "./config.mjs";
|
|
2
2
|
import { isTestPath } from "./test-paths.mjs";
|
|
3
|
-
import { isPlaceholderTestScript } from "./stack-detector.mjs";
|
|
3
|
+
import { isPlaceholderTestScript, isSrcLayout } from "./stack-detector.mjs";
|
|
4
4
|
import { checkCollectionFloor } from "./ops/test-collection.mjs";
|
|
5
5
|
import { checkScope, scanDiff, scanBinaryPayloads, redactSecrets } from "./security.mjs";
|
|
6
|
-
import { changedFiles, diffBytes, diffText, binaryDiffEntries, symlinkChanges, showFromOrigin, runCmd } from "./git.mjs";
|
|
6
|
+
import { changedFiles, diffBytes, diffText, binaryDiffEntries, symlinkChanges, showFromOrigin, runCmd, materializeSnapshot } from "./git.mjs";
|
|
7
7
|
import { createProvider, ProviderRateLimitError, ProviderUnavailableError } from "./provider.mjs";
|
|
8
8
|
import { resolveRoutedProvider } from "./router.mjs";
|
|
9
9
|
import { withBudget, appendLedger, getQueueDir, ensureDir, rollbackBudgetReservation, isConcurrencyGroupLocked, checkDailyBudget } from "./state.mjs";
|
|
@@ -152,13 +152,30 @@ export function fingerprintFailureState(failure = {}, root = process.cwd()) {
|
|
|
152
152
|
*/
|
|
153
153
|
export async function gate(opts = {}) {
|
|
154
154
|
const root = opts.root || process.cwd();
|
|
155
|
-
const config = opts.config || loadConfig(root);
|
|
156
|
-
const base = opts.base || config.baseBranch || "main";
|
|
157
155
|
const mode = opts.mode || (opts.workingTree ? "working-tree" : (process.env.JULES_GATE_MODE || "working-tree"));
|
|
156
|
+
const config = opts.config || (mode === "working-tree" ? loadConfig(root) : null);
|
|
158
157
|
const progressBus = opts.progressBus;
|
|
159
158
|
const progressToken = opts.progressToken;
|
|
160
159
|
const phases = [];
|
|
161
160
|
|
|
161
|
+
const policy = resolveTrustedPolicy(root, opts.base, mode, { ...opts, config });
|
|
162
|
+
if (!policy.ok) {
|
|
163
|
+
phases.push({
|
|
164
|
+
phase: "scope",
|
|
165
|
+
ok: false,
|
|
166
|
+
error: policy.error,
|
|
167
|
+
violations: [{ file: ".agent/config.yml", rule: "BUILTIN_PROTECT", reason: policy.error }],
|
|
168
|
+
});
|
|
169
|
+
appendTelemetry(root, "gate_finished", { ok: false, code: policy.code || 3, error: policy.error });
|
|
170
|
+
return { ok: false, code: policy.code || 3, phases, error: policy.error, base: policy.base };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const base = policy.base;
|
|
174
|
+
const trustedScope = policy.scope;
|
|
175
|
+
let trustedVerify = policy.verify;
|
|
176
|
+
const trustedDiffKb = policy.limits?.diffKb || 75;
|
|
177
|
+
const trustedEvidence = policy.evidence || { strictTestLock: true };
|
|
178
|
+
|
|
162
179
|
appendTelemetry(root, "gate_started", { base, mode });
|
|
163
180
|
|
|
164
181
|
let files = [];
|
|
@@ -172,73 +189,15 @@ export async function gate(opts = {}) {
|
|
|
172
189
|
} catch (err) {
|
|
173
190
|
phases.push({ phase: "git_resolution", ok: false, error: err.message });
|
|
174
191
|
appendTelemetry(root, "gate_finished", { ok: false, code: 1, error: err.message });
|
|
175
|
-
return { ok: false, code: 1, phases, error: err.message };
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Phase 1: Scope Guard (Fetch trusted config strictly from origin/base using single normalizeScope)
|
|
179
|
-
let trustedScope = config.scope;
|
|
180
|
-
let trustedVerify = config.verify;
|
|
181
|
-
let trustedDiffKb = 75;
|
|
182
|
-
|
|
183
|
-
const trustedConfigRaw = showFromOrigin(root, base, ".agent/config.yml") || showFromOrigin(root, base, ".agent/jules.yml");
|
|
184
|
-
if (trustedConfigRaw) {
|
|
185
|
-
try {
|
|
186
|
-
const parsed = parseYaml(trustedConfigRaw);
|
|
187
|
-
// CRITICAL B1 FIX: normalizeScope ensures BUILTIN_DENY is ALWAYS merged with user deny rules
|
|
188
|
-
trustedScope = normalizeScope(parsed);
|
|
189
|
-
if (parsed.limits?.diff_kb || parsed.limits?.diffKb) {
|
|
190
|
-
trustedDiffKb = Number(parsed.limits.diff_kb || parsed.limits.diffKb) || 75;
|
|
191
|
-
}
|
|
192
|
-
if (parsed.verify || parsed.test_cmd || parsed.build_cmd) {
|
|
193
|
-
trustedVerify = {
|
|
194
|
-
setup: parsed.verify?.setup || config.verify.setup,
|
|
195
|
-
lint: parsed.verify?.lint || parsed.lint_cmd || config.verify.lint,
|
|
196
|
-
test: parsed.verify?.test || parsed.test_cmd || config.verify.test,
|
|
197
|
-
unit: parsed.verify?.unit || config.verify.unit || parsed.verify?.test || parsed.test_cmd || config.verify.test,
|
|
198
|
-
fuzz: parsed.verify?.fuzz || parsed.fuzz_cmd || config.verify.fuzz,
|
|
199
|
-
invariant: parsed.verify?.invariant || parsed.invariant_cmd || config.verify.invariant,
|
|
200
|
-
e2e: parsed.verify?.e2e || parsed.e2e_cmd || config.verify.e2e,
|
|
201
|
-
teardown: parsed.verify?.teardown || config.verify.teardown,
|
|
202
|
-
build: parsed.verify?.build || parsed.build_cmd || config.verify.build,
|
|
203
|
-
stages: parsed.verify?.stages || config.verify.stages,
|
|
204
|
-
policy: parsed.verify?.policy || config.verify.policy,
|
|
205
|
-
// Read from the base commit like every other trusted field: an
|
|
206
|
-
// uncommitted `required: false` must not be able to switch the gate off.
|
|
207
|
-
required: parsed.verify?.required !== undefined ? parsed.verify.required !== false : config.verify.required !== false,
|
|
208
|
-
// The floor the collection check applies. Omitting it here meant
|
|
209
|
-
// `verify.minTests` was silently dropped and always defaulted to 1
|
|
210
|
-
// — while the failure message told the operator to set exactly
|
|
211
|
-
// that. A remediation hint that does nothing is worse than none.
|
|
212
|
-
minTests:
|
|
213
|
-
parsed.verify?.minTests !== undefined
|
|
214
|
-
? parsed.verify.minTests
|
|
215
|
-
: parsed.verify?.min_tests !== undefined
|
|
216
|
-
? parsed.verify.min_tests
|
|
217
|
-
: config.verify.minTests,
|
|
218
|
-
tamperGuard: parsed.verify?.tamperGuard || parsed.verify?.tamper_guard || config.verify.tamperGuard,
|
|
219
|
-
scope: parsed.verify?.scope || config.verify.scope || "global",
|
|
220
|
-
timeoutMs: parsed.verify?.timeoutMs || parsed.verify?.timeout_ms || config.verify.timeoutMs,
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
} catch (_) {}
|
|
224
|
-
} else {
|
|
225
|
-
// CRITICAL H-c FIX: Fall back strictly to normalizeScope({}) (built-ins only), never HEAD config
|
|
226
|
-
trustedScope = normalizeScope({});
|
|
227
|
-
if (opts.config?.limits?.diffKb || opts.config?.limits?.diff_kb) {
|
|
228
|
-
trustedDiffKb = Number(opts.config.limits.diffKb || opts.config.limits.diff_kb) || 75;
|
|
229
|
-
}
|
|
192
|
+
return { ok: false, code: 1, phases, error: err.message, base };
|
|
230
193
|
}
|
|
231
194
|
|
|
232
|
-
//
|
|
233
|
-
// walked straight past a deny list that names the target. Judge both: the
|
|
234
|
-
// link because it is what the diff adds, and the path it resolves to because
|
|
235
|
-
// that is what it grants reach to.
|
|
195
|
+
// Phase 1: Scope Guard
|
|
236
196
|
let symlinks = [];
|
|
237
197
|
try {
|
|
238
198
|
symlinks = symlinkChanges(root, base, mode);
|
|
239
199
|
} catch (_) {
|
|
240
|
-
// Scope must still be enforced on the ordinary file list if git cannot
|
|
241
|
-
// describe the links.
|
|
200
|
+
// Scope must still be enforced on the ordinary file list if git cannot describe the links.
|
|
242
201
|
}
|
|
243
202
|
const scopeCandidates = [...files];
|
|
244
203
|
const symlinkTargetOf = new Map();
|
|
@@ -249,7 +208,12 @@ export async function gate(opts = {}) {
|
|
|
249
208
|
}
|
|
250
209
|
|
|
251
210
|
const scopeResult = checkScope(scopeCandidates, trustedScope, {
|
|
252
|
-
allowProtected:
|
|
211
|
+
allowProtected:
|
|
212
|
+
opts.allowProtected ||
|
|
213
|
+
process.env.JULES_ALLOW_COMMAND_FILE_CHANGES === "true" ||
|
|
214
|
+
process.env.JULES_ALLOW_COMMAND_FILE_CHANGES === "1" ||
|
|
215
|
+
process.env.AGENT_ALLOW_COMMAND_FILE_CHANGES === "true" ||
|
|
216
|
+
process.env.AGENT_ALLOW_COMMAND_FILE_CHANGES === "1",
|
|
253
217
|
});
|
|
254
218
|
// Report the violation against the link the change actually introduced, not
|
|
255
219
|
// against a path the diff never names — the operator has to be able to find it.
|
|
@@ -263,23 +227,8 @@ export async function gate(opts = {}) {
|
|
|
263
227
|
|
|
264
228
|
// Bootstrap: the files that bring a repository under the gate are not agent
|
|
265
229
|
// edits to the gate.
|
|
266
|
-
//
|
|
267
|
-
// `init` writes `.agent/**` and then tells the user to commit it. Doing
|
|
268
|
-
// exactly that produced Exit 3 on the very first run, because the base
|
|
269
|
-
// branch does not have the commit yet and every scaffolded path matches
|
|
270
|
-
// BUILTIN_PROTECT or BUILTIN_DENY. The advice printed alongside it was
|
|
271
|
-
// `--allow-protected` — so a newcomer's first lesson was how to switch the
|
|
272
|
-
// scope guard off. A gate that refuses its own installation is not strict,
|
|
273
|
-
// it is broken.
|
|
274
|
-
//
|
|
275
|
-
// Narrow on purpose, and only where it cannot weaken anything: the base
|
|
276
|
-
// commit must have no gate config at all — in which case `trustedScope` is
|
|
277
|
-
// already built-ins only and nothing in the added files is trusted — and
|
|
278
|
-
// every violating path must be scaffold that the base does not have. A
|
|
279
|
-
// repository already under the gate keeps the full rule, so an agent still
|
|
280
|
-
// cannot touch the policy it is governed by.
|
|
281
230
|
let acceptedScaffold = [];
|
|
282
|
-
if (!scopeResult.ok &&
|
|
231
|
+
if (!scopeResult.ok && policy.isBootstrap) {
|
|
283
232
|
const violations = scopeResult.violations || [];
|
|
284
233
|
const isScaffold = (f) => typeof f === "string" && f.replace(/\\/g, "/").startsWith(".agent/");
|
|
285
234
|
if (
|
|
@@ -307,7 +256,7 @@ export async function gate(opts = {}) {
|
|
|
307
256
|
|
|
308
257
|
if (!scopeResult.ok) {
|
|
309
258
|
appendTelemetry(root, "gate_finished", { ok: false, code: 3 });
|
|
310
|
-
return { ok: false, code: 3, phases };
|
|
259
|
+
return { ok: false, code: 3, phases, base };
|
|
311
260
|
}
|
|
312
261
|
|
|
313
262
|
// Phase 2: Diff Payload Governor
|
|
@@ -359,12 +308,18 @@ export async function gate(opts = {}) {
|
|
|
359
308
|
return { ok: false, code: 6, phases };
|
|
360
309
|
}
|
|
361
310
|
|
|
362
|
-
// Pre-verification: compute test directory integrity hash
|
|
363
|
-
const preTestHashResult = computeDirectoryHash(root, { testOnly: true });
|
|
364
|
-
const preTestHash = preTestHashResult.treeHash;
|
|
365
|
-
const executionRecords = [];
|
|
366
|
-
|
|
367
311
|
// Phase 4: Automated Test & Build Verification (Uses trusted verify commands only)
|
|
312
|
+
let snapshot = { cwd: root, cleanup: () => {}, mode };
|
|
313
|
+
try {
|
|
314
|
+
if (mode === "staged" || mode === "committed") {
|
|
315
|
+
snapshot = materializeSnapshot(root, mode, base);
|
|
316
|
+
}
|
|
317
|
+
} catch (snapErr) {
|
|
318
|
+
phases.push({ phase: "verify", ok: false, error: snapErr.message });
|
|
319
|
+
appendTelemetry(root, "gate_finished", { ok: false, code: snapErr.code || 1, error: snapErr.message });
|
|
320
|
+
return { ok: false, code: snapErr.code || 1, phases, error: snapErr.message, base };
|
|
321
|
+
}
|
|
322
|
+
|
|
368
323
|
let testResult = { ok: true, status: 0 };
|
|
369
324
|
let buildResult = { ok: true, status: 0 };
|
|
370
325
|
let serverResult = { ok: true, status: 0 };
|
|
@@ -400,21 +355,10 @@ export async function gate(opts = {}) {
|
|
|
400
355
|
const verifyTimeout = trustedVerify.timeoutMs || 300_000;
|
|
401
356
|
|
|
402
357
|
// Monorepo scoping: run the suites the change can actually break.
|
|
403
|
-
//
|
|
404
|
-
// `resolveWorkspaceBoundary()` shipped, was drawn in the architecture
|
|
405
|
-
// diagrams and documented as a headline feature — and was called by nothing.
|
|
406
|
-
// Every change in a monorepo ran the root suite, so a one-package edit was
|
|
407
|
-
// gated on every other package's tests. It resolves the changed files to
|
|
408
|
-
// their sub-projects and composes the per-project commands.
|
|
409
|
-
//
|
|
410
|
-
// It stays opt-in (`verify.scope: affected`) because narrowing what runs is
|
|
411
|
-
// only safe when someone asked for it, and it yields to the global command
|
|
412
|
-
// whenever a shared file is touched or no sub-project command is found — a
|
|
413
|
-
// narrower run that misses the breakage is worse than a slow one.
|
|
414
358
|
let boundary = null;
|
|
415
359
|
if (trustedVerify.scope === "affected" && !(Array.isArray(trustedVerify.stages) && trustedVerify.stages.length > 0)) {
|
|
416
360
|
try {
|
|
417
|
-
boundary = resolveWorkspaceBoundary(files,
|
|
361
|
+
boundary = resolveWorkspaceBoundary(files, snapshot.cwd);
|
|
418
362
|
} catch (_) {
|
|
419
363
|
boundary = null;
|
|
420
364
|
}
|
|
@@ -440,6 +384,13 @@ export async function gate(opts = {}) {
|
|
|
440
384
|
stagesToRun.push(...buildDefaultStages(trustedVerify));
|
|
441
385
|
}
|
|
442
386
|
|
|
387
|
+
// Pre-verification: compute test directory integrity hash on snapshot under test
|
|
388
|
+
const preTestHashResult = computeDirectoryHash(snapshot.cwd, { testOnly: true });
|
|
389
|
+
let preTestHash = preTestHashResult.treeHash;
|
|
390
|
+
let postTestHash = preTestHash;
|
|
391
|
+
const executionRecords = [];
|
|
392
|
+
let testTampered = false;
|
|
393
|
+
|
|
443
394
|
try {
|
|
444
395
|
for (const stage of stagesToRun) {
|
|
445
396
|
const isAssertion = Boolean(
|
|
@@ -459,18 +410,6 @@ export async function gate(opts = {}) {
|
|
|
459
410
|
let assertMetrics = {};
|
|
460
411
|
|
|
461
412
|
if (isAssertion) {
|
|
462
|
-
// The loosening flags have to reach here too.
|
|
463
|
-
//
|
|
464
|
-
// `scanDiff` above is given `allowTestChanges` and honours it, while
|
|
465
|
-
// the `assert:test-integrity` stage received only its own stage object
|
|
466
|
-
// and re-ran the same guard with none of them. So an override was
|
|
467
|
-
// accepted by one phase and ignored by the next: `--allow-test-change
|
|
468
|
-
// deregistration` turned the SECRETS phase green and then failed the
|
|
469
|
-
// run at the anti-tamper stage, with a flag hint the operator had
|
|
470
|
-
// already followed. One rule, two places, and the second kept the old
|
|
471
|
-
// answer — for the eighth time in this project's history, which is why
|
|
472
|
-
// the regression test asserts the verdict a caller receives rather
|
|
473
|
-
// than the behaviour of either site.
|
|
474
413
|
const assertRes = runAssertion(
|
|
475
414
|
{
|
|
476
415
|
...stage,
|
|
@@ -479,7 +418,7 @@ export async function gate(opts = {}) {
|
|
|
479
418
|
tamperGuard: trustedVerify.tamperGuard,
|
|
480
419
|
allowUnreadableTests: opts.allowUnreadableTests === true,
|
|
481
420
|
},
|
|
482
|
-
|
|
421
|
+
snapshot.cwd
|
|
483
422
|
);
|
|
484
423
|
durationMs = assertRes.metrics?.durationMs ?? (Date.now() - startTime);
|
|
485
424
|
stdoutRedacted = assertRes.stdout || "";
|
|
@@ -491,9 +430,16 @@ export async function gate(opts = {}) {
|
|
|
491
430
|
} else {
|
|
492
431
|
const stageEnv = stage.networkAccess === "forbidden" || trustedVerify.policy?.offline
|
|
493
432
|
? { ...testEnv, JULES_SANDBOX_OFFLINE: "1" }
|
|
494
|
-
: testEnv;
|
|
433
|
+
: { ...testEnv };
|
|
434
|
+
|
|
435
|
+
// F11: Prepend src/ to PYTHONPATH for Python src-layout repositories
|
|
436
|
+
if (isSrcLayout(snapshot.cwd)) {
|
|
437
|
+
const currentPypath = stageEnv.PYTHONPATH || "";
|
|
438
|
+
const srcDir = join(snapshot.cwd, "src");
|
|
439
|
+
stageEnv.PYTHONPATH = currentPypath ? `${srcDir}:${currentPypath}` : srcDir;
|
|
440
|
+
}
|
|
495
441
|
|
|
496
|
-
res = runCmd(stage.cmd, { cwd:
|
|
442
|
+
res = runCmd(stage.cmd, { cwd: snapshot.cwd, ignoreError: true, env: stageEnv, timeout: stage.timeoutMs || verifyTimeout });
|
|
497
443
|
durationMs = Date.now() - startTime;
|
|
498
444
|
stdoutRedacted = redactSecrets(res.stdout || "");
|
|
499
445
|
stderrRedacted = redactSecrets(res.stderr || "");
|
|
@@ -514,7 +460,7 @@ export async function gate(opts = {}) {
|
|
|
514
460
|
|
|
515
461
|
if (stage.kind === "test" || stage.kind === "unit") {
|
|
516
462
|
testResult = { ok: stageOk, status: res.status, stdout: stdoutRedacted, stderr: stderrRedacted, command: stage.cmd };
|
|
517
|
-
const fingerprint = !stageOk ? fingerprintFailureState(testResult,
|
|
463
|
+
const fingerprint = !stageOk ? fingerprintFailureState(testResult, snapshot.cwd) : null;
|
|
518
464
|
if (stage.cmd) recordVerifyRun(root, stage.cmd, stageOk, fingerprint, durationMs);
|
|
519
465
|
if (!stageOk && stage.cmd) {
|
|
520
466
|
const runs = readVerifyRuns(root, stage.cmd);
|
|
@@ -538,7 +484,7 @@ export async function gate(opts = {}) {
|
|
|
538
484
|
});
|
|
539
485
|
appendTelemetry(root, "gate_phase", { phase: "verify", ok: false, quarantined: true });
|
|
540
486
|
appendTelemetry(root, "gate_finished", { ok: false, code: 8 });
|
|
541
|
-
return { ok: false, code: 8, phases, flakyVerdict: flakyVerdictResult };
|
|
487
|
+
return { ok: false, code: 8, phases, flakyVerdict: flakyVerdictResult, base };
|
|
542
488
|
}
|
|
543
489
|
}
|
|
544
490
|
} else if (stage.kind === "build") {
|
|
@@ -560,30 +506,30 @@ export async function gate(opts = {}) {
|
|
|
560
506
|
break;
|
|
561
507
|
}
|
|
562
508
|
}
|
|
509
|
+
|
|
510
|
+
if (!failingCmd && trustedVerify.server && trustedVerify.server.command) {
|
|
511
|
+
serverResult = await probeDevServer(trustedVerify.server, snapshot.cwd);
|
|
512
|
+
if (!serverResult.ok) {
|
|
513
|
+
failingCmd = serverResult;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Post-verification test integrity check (No Test Weakening Invariant)
|
|
518
|
+
const postTestHashResult = computeDirectoryHash(snapshot.cwd, { testOnly: true });
|
|
519
|
+
postTestHash = postTestHashResult.treeHash;
|
|
520
|
+
if (trustedEvidence.strictTestLock && !opts.allowTestModifications && preTestHashResult.fileCount > 0) {
|
|
521
|
+
const changedTestFile = files.find((f) => isTestPath(f));
|
|
522
|
+
if (changedTestFile && preTestHash !== postTestHash) {
|
|
523
|
+
testTampered = true;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
563
526
|
} finally {
|
|
564
527
|
if (trustedVerify.teardown) {
|
|
565
528
|
try {
|
|
566
|
-
runCmd(trustedVerify.teardown, { cwd:
|
|
529
|
+
runCmd(trustedVerify.teardown, { cwd: snapshot.cwd, ignoreError: true, env: testEnv });
|
|
567
530
|
} catch (_) {}
|
|
568
531
|
}
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
if (!failingCmd && trustedVerify.server && trustedVerify.server.command) {
|
|
572
|
-
serverResult = await probeDevServer(trustedVerify.server, root);
|
|
573
|
-
if (!serverResult.ok) {
|
|
574
|
-
failingCmd = serverResult;
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
// Post-verification test integrity check (No Test Weakening Invariant)
|
|
579
|
-
const postTestHashResult = computeDirectoryHash(root, { testOnly: true });
|
|
580
|
-
const postTestHash = postTestHashResult.treeHash;
|
|
581
|
-
let testTampered = false;
|
|
582
|
-
if (config.evidence?.strictTestLock && !opts.allowTestModifications && preTestHashResult.fileCount > 0) {
|
|
583
|
-
const changedTestFile = files.find((f) => isTestPath(f));
|
|
584
|
-
if (changedTestFile && preTestHash !== postTestHash) {
|
|
585
|
-
testTampered = true;
|
|
586
|
-
}
|
|
532
|
+
snapshot.cleanup();
|
|
587
533
|
}
|
|
588
534
|
|
|
589
535
|
// A gate that ran no verification at all must not report APPROVED.
|
|
@@ -717,9 +663,9 @@ export async function gate(opts = {}) {
|
|
|
717
663
|
executionRecords,
|
|
718
664
|
secretScanOk: secretResult.ok,
|
|
719
665
|
diffKb: Math.round(bytes / 1024),
|
|
720
|
-
maxDiffKb:
|
|
666
|
+
maxDiffKb: trustedDiffKb,
|
|
721
667
|
protectedScopeOk: scopeResult.ok,
|
|
722
|
-
repository:
|
|
668
|
+
repository: policy.provider || config?.provider || "jules",
|
|
723
669
|
failedStage: failingCmd?.stageId || failingCmd?.phase || null,
|
|
724
670
|
diagnostics: failingCmd?.diagnostics?.length ? failingCmd.diagnostics : (failingCmd?.stderr ? [failingCmd.stderr] : []),
|
|
725
671
|
metrics: failingCmd?.metrics || {},
|
|
@@ -730,9 +676,18 @@ export async function gate(opts = {}) {
|
|
|
730
676
|
evidenceManifest.testIntegrity.tamperDetected = true;
|
|
731
677
|
}
|
|
732
678
|
let evidencePath = null;
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
679
|
+
const shouldPersistEvidence =
|
|
680
|
+
!opts.dryRun &&
|
|
681
|
+
!opts["dry-run"] &&
|
|
682
|
+
process.env.JULES_DRY_RUN !== "1" &&
|
|
683
|
+
process.env.AGENT_DRY_RUN !== "1" &&
|
|
684
|
+
trustedEvidence.enabled !== false &&
|
|
685
|
+
config?.evidence?.enabled !== false;
|
|
686
|
+
if (shouldPersistEvidence) {
|
|
687
|
+
try {
|
|
688
|
+
evidencePath = writeEvidenceManifest(root, evidenceManifest);
|
|
689
|
+
} catch (_) {}
|
|
690
|
+
}
|
|
736
691
|
|
|
737
692
|
// Export structured JSON report if requested
|
|
738
693
|
const jsonReportPath = opts.jsonReport || opts.jsonReportPath || process.env.JULES_JSON_REPORT || null;
|
package/src/git.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFileSync, execSync } from "node:child_process";
|
|
2
|
-
import { readFileSync, existsSync, statSync, lstatSync, readlinkSync } from "node:fs";
|
|
2
|
+
import { readFileSync, existsSync, statSync, lstatSync, readlinkSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
|
|
3
3
|
import { join, delimiter } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
4
5
|
import { normalizePath, canonicalizePath } from "./config.mjs";
|
|
5
6
|
|
|
6
7
|
|
|
@@ -676,6 +677,108 @@ export function worktreePrune(root = process.cwd()) {
|
|
|
676
677
|
return git(["worktree", "prune"], { cwd: root });
|
|
677
678
|
}
|
|
678
679
|
|
|
680
|
+
/**
|
|
681
|
+
* Materialize the revision under review into an isolated execution directory (F10).
|
|
682
|
+
*
|
|
683
|
+
* In working-tree mode, verification executes against the live tree (`root`).
|
|
684
|
+
* In staged or committed mode, the verification command must execute against the
|
|
685
|
+
* snapshot being judged, rather than the working copy — attesting a revision
|
|
686
|
+
* you did not execute is the failure this tool exists to refuse.
|
|
687
|
+
*
|
|
688
|
+
* @param {string} root - repository root
|
|
689
|
+
* @param {string} mode - "working-tree" | "staged" | "committed"
|
|
690
|
+
* @param {string} [targetRef="HEAD"] - commit reference for committed mode
|
|
691
|
+
* @returns {{ cwd: string, cleanup: () => void, mode: string }}
|
|
692
|
+
*/
|
|
693
|
+
export function materializeSnapshot(root = process.cwd(), mode = "working-tree", targetRef = "HEAD") {
|
|
694
|
+
if (mode === "working-tree" || mode === "working") {
|
|
695
|
+
return {
|
|
696
|
+
cwd: root,
|
|
697
|
+
cleanup: () => {},
|
|
698
|
+
mode,
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const prefix = `jules-${mode}-snapshot-`;
|
|
703
|
+
const tmpDir = mkdtempSync(join(tmpdir(), prefix));
|
|
704
|
+
let worktreeAdded = false;
|
|
705
|
+
|
|
706
|
+
try {
|
|
707
|
+
if (mode === "staged" || mode === "index") {
|
|
708
|
+
execFileSync("git", ["-c", "core.quotePath=false", "checkout-index", "--all", `--prefix=${tmpDir}/`], {
|
|
709
|
+
cwd: root,
|
|
710
|
+
encoding: "utf-8",
|
|
711
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
712
|
+
});
|
|
713
|
+
} else {
|
|
714
|
+
// committed mode
|
|
715
|
+
try {
|
|
716
|
+
execFileSync("git", ["worktree", "add", "--detach", tmpDir, targetRef || "HEAD"], {
|
|
717
|
+
cwd: root,
|
|
718
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
719
|
+
});
|
|
720
|
+
worktreeAdded = true;
|
|
721
|
+
} catch (_) {
|
|
722
|
+
// Fallback: git archive piped to tar when worktree cannot be created (e.g. read-only .git)
|
|
723
|
+
try {
|
|
724
|
+
const tar = execFileSync("git", ["archive", targetRef || "HEAD"], {
|
|
725
|
+
cwd: root,
|
|
726
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
727
|
+
});
|
|
728
|
+
execFileSync("tar", ["-x", "-C", tmpDir], { input: tar, stdio: ["pipe", "ignore", "pipe"] });
|
|
729
|
+
} catch (tarErr) {
|
|
730
|
+
throw new Error(`Failed to extract snapshot for ${targetRef || "HEAD"}: ${tarErr.message}`);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Preserve local runtime dependencies in snapshot so offline/sandboxed verification can run:
|
|
736
|
+
const ignoredDeps = ["node_modules", ".venv", "venv", "target", ".pytest_cache"];
|
|
737
|
+
for (const dep of ignoredDeps) {
|
|
738
|
+
const srcDep = join(root, dep);
|
|
739
|
+
const dstDep = join(tmpDir, dep);
|
|
740
|
+
if (existsSync(srcDep) && !existsSync(dstDep)) {
|
|
741
|
+
try {
|
|
742
|
+
symlinkSync(srcDep, dstDep, "junction");
|
|
743
|
+
} catch (_) {}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const cleanup = () => {
|
|
748
|
+
if (worktreeAdded) {
|
|
749
|
+
try {
|
|
750
|
+
execFileSync("git", ["worktree", "remove", "--force", tmpDir], {
|
|
751
|
+
cwd: root,
|
|
752
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
753
|
+
});
|
|
754
|
+
} catch (_) {}
|
|
755
|
+
}
|
|
756
|
+
try {
|
|
757
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
758
|
+
} catch (_) {}
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
return {
|
|
762
|
+
cwd: tmpDir,
|
|
763
|
+
cleanup,
|
|
764
|
+
mode,
|
|
765
|
+
};
|
|
766
|
+
} catch (err) {
|
|
767
|
+
if (worktreeAdded) {
|
|
768
|
+
try {
|
|
769
|
+
execFileSync("git", ["worktree", "remove", "--force", tmpDir], {
|
|
770
|
+
cwd: root,
|
|
771
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
772
|
+
});
|
|
773
|
+
} catch (_) {}
|
|
774
|
+
}
|
|
775
|
+
try {
|
|
776
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
777
|
+
} catch (_) {}
|
|
778
|
+
throw new GateError(`Failed to materialize ${mode} snapshot for verification: ${err.message}`, { code: 1 });
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
679
782
|
/**
|
|
680
783
|
* Extract 'owner/repo' from a git remote URL if it points to GitHub.
|
|
681
784
|
* Handles SSH (git@github.com:owner/repo.git), HTTPS (https://github.com/owner/repo.git),
|