jules-orchestrator-kit 0.75.0 → 0.75.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -1
- package/JULES_RULES_TEMPLATE.md +3 -0
- package/README.md +4 -2
- package/bin/agentctl.mjs +52 -24
- package/package.json +2 -2
- package/scripts/ci-scope-guard.mjs +197 -17
- package/scripts/run-tests.mjs +5 -1
- package/src/ci-templates.mjs +185 -21
- package/src/engine.mjs +167 -4
- package/src/envelope.mjs +17 -1
- package/src/evidence.mjs +22 -5
- package/src/ops/command-registry.mjs +4 -1
- package/src/ops/tui-menu.mjs +4 -2
- package/src/prompt-guard.mjs +9 -0
- package/src/provider.mjs +14 -0
- package/src/secret-scanner.mjs +13 -1
- package/src/security.mjs +4 -0
- package/src/state.mjs +23 -9
- package/src/wizard-task.mjs +9 -20
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.75.2] - 2026-10-09
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Committed Mode Snapshot Target (`src/engine.mjs`)**: Pointed `materializeSnapshot` to target commit `opts.head` (defaults to `HEAD`) instead of base commit when evaluating in committed mode.
|
|
14
|
+
- **Auto-Merge CI PR Head Checkout (`src/ci-templates.mjs`)**: Detached checkout to fetched PR head (`git checkout --detach refs/pr-head`) before gate evaluation so PR verification tests candidate changes rather than base.
|
|
15
|
+
- **TUI Interactive Fallback on Headless CI (`src/ops/tui-menu.mjs`)**: Allowed interactive terminal UI menu execution when custom mock streams are explicitly provided even if `process.env.CI` is defined.
|
|
16
|
+
- **Pure JSON Output in Task Queue (`bin/agentctl.mjs`)**: Suppressed decorative informational messages during `agentctl queue --json` execution for uncorrupted machine consumption.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- **CLI Navigation Aliases & Gate Flags (`bin/agentctl.mjs`, `src/ops/command-registry.mjs`)**: Added `hub` alias for `menu`, `task new` alias for `task create`, and forwarded `--head` to `agentctl check` and verification gate.
|
|
20
|
+
|
|
21
|
+
### Security
|
|
22
|
+
- **Task Envelope & Pre-Dispatch Secret Prevention (`src/envelope.mjs`, `src/engine.mjs`)**: Rejected prompts and task intents containing unredacted high-confidence credentials before queuing or agent dispatch.
|
|
23
|
+
|
|
24
|
+
## [0.75.1] - 2026-10-09
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
- **Auto-Merge CI Workflow Template (`src/ci-templates.mjs`, `bin/agentctl.mjs`)**: Added `buildAutoMergeWorkflow` and `--with-automerge` flag to `agentctl ci init`. Generates `.github/workflows/agent-automerge.yml` operating on `workflow_run` strictly from `ref: main`, enforcing secure bot actor validation (`google-labs-jules[bot]`, `github-actions[bot]`), untrusted commit data fetching, and base-branch gate verification before automated squash-merging.
|
|
28
|
+
- **Quiet-Run Invariant (`AGENTS.md`, `JULES_RULES_TEMPLATE.md`)**: Enforced explicit invariant prohibiting cosmetic refactors and fabricated changes when scheduled or automated runs discover all constraints already satisfied.
|
|
29
|
+
- **Automated SYNC-CORE Byte Parity Test (`test/cli-surface.test.mjs`)**: Added test verifying byte-level identity between `AGENTS.md` and `JULES_RULES_TEMPLATE.md` core rule blocks.
|
|
30
|
+
|
|
31
|
+
### Security
|
|
32
|
+
- **Symlink & Mode Inspection Hardening (`scripts/ci-scope-guard.mjs`)**: Replaced `--name-only` diff inspection with null-delimited raw diff parsing (`git diff --raw -z --no-renames`). Inspects blob modes, rejects symlinks (`mode 120000`) resolving to protected paths or escaping repository root via `git cat-file blob <dstSha>` without local checkout, rejects git submodules (`mode 160000`), and fails closed on unreadable symlink targets.
|
|
33
|
+
|
|
10
34
|
## [0.75.0] - 2026-09-28
|
|
11
35
|
|
|
12
36
|
### Added
|
|
@@ -20,13 +44,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
20
44
|
- **Goal Evidence Enforcement (`src/engine.mjs`)**: Disallowed silent early termination on goal tasks lacking explicit verification evidence.
|
|
21
45
|
- **Test Environment Variable Bleed (`src/engine.mjs`)**: Sanitized test execution environment to prevent PR label bypass tokens from leaking into child test runs during gate evaluation.
|
|
22
46
|
|
|
47
|
+
### Changed
|
|
48
|
+
- **Stack-Neutral Invariants & Isolation Cleanup (`src/wizard-task.mjs`, `docs/`)**: Removed hardcoded vendor framework assumptions from task authoring in favor of project configuration and options; purged Swedish phrasing from internal test suites and scripts.
|
|
49
|
+
|
|
23
50
|
## [0.74.0] - 2026-09-19
|
|
24
51
|
|
|
25
52
|
### Added
|
|
26
53
|
- **Native `agentctl.task/v1` Frontmatter Specification & Zero-Dependency Parser (`src/envelope.mjs`, `scripts/validate-envelope.mjs`)**: Added canonical YAML frontmatter envelope support for task files (`.md`) without third-party dependencies. Reconciles `scope.allow`, `scope.deny`, `verification.commands`, `invariants`, `mcp_directives`, and `flags` with 100% backward compatibility for legacy HTML comments (`<!-- JULES_TASK_ENVELOPE: ... -->`).
|
|
27
54
|
- **Taxonomy Delegation Lanes (Green / Amber / Orange / Red) (`src/envelope.mjs`, `src/wizard-task.mjs`)**: Formulated formal risk lane delegation. Red tasks are rejected at validation; Orange tasks warn for external execution only; Amber tasks allow protected path changes (e.g. dependency upgrades in `package.json`, migrations, auth) under explicit plan approval (`requirePlanApproval: true`). Green tasks prevent unapproved protected scope mutations.
|
|
28
55
|
- **Circuit Breaker & Verification Contract in Envelope (`src/envelope.mjs`)**: Added frontmatter declarations for `circuitBreaker` (`max_attempts`, `max_diff_lines`, `stop_if_same_failure_repeats`) and `verification` metadata (`require_nonzero_test_count`, `trusted_base`), alongside `constraints` alias support.
|
|
29
|
-
- **
|
|
56
|
+
- **Custom Invariant & MCP Directive Ingestion (`src/wizard-task.mjs`)**: Ingests project invariants and MCP directives from configuration and options, attaching pre-execution documentation lookup directives into task instructions.
|
|
30
57
|
- **Dependency Upgrade Scope Harmonization (`src/wizard-task.mjs`)**: Updated `buildGuardrailFooter` to exclude explicitly authorized paths (`opts.allowedPaths`) from forbidden path warnings, eliminating conflicting instructions for dependency upgrade workloads.
|
|
31
58
|
- **Diff Payload Governor Branch Base Comparison (`src/wizard-task.mjs`)**: Calculated diff payload sizes against `git diff origin/${baseBranch}...HEAD | wc -c` instead of local uncommitted diffs.
|
|
32
59
|
|
package/JULES_RULES_TEMPLATE.md
CHANGED
|
@@ -31,6 +31,9 @@ below and keep existing project instructions when adopting this template.
|
|
|
31
31
|
affected checks if the base changes.
|
|
32
32
|
- Report what changed, commands run, results and remaining limitations. Do not
|
|
33
33
|
claim a check passed if it was skipped or could not run.
|
|
34
|
+
- Do not invent changes or perform cosmetic refactors when no substantive work
|
|
35
|
+
is needed. If the assigned task or verification demonstrates that state is already
|
|
36
|
+
correct, report the finding and leave files unmodified.
|
|
34
37
|
|
|
35
38
|
<!-- SYNC-CORE:END -->
|
|
36
39
|
|
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ You can also run local checks without connecting an agent provider.
|
|
|
17
17
|
`agentctl repair` explicitly for repair workflows. Provider output and passing
|
|
18
18
|
checks still need review before merging.
|
|
19
19
|
|
|
20
|
-
The current release is **v0.75.
|
|
20
|
+
The current release is **v0.75.2**, a pre-1.0 release. A long-term stability policy is a
|
|
21
21
|
[v1.0 goal](https://github.com/FullThrottle83/jules-orchestrator-kit/blob/main/ROADMAP_V1.md).
|
|
22
22
|
|
|
23
23
|
## Quickstart
|
|
@@ -111,6 +111,7 @@ For direct `agentctl` commands, install globally with
|
|
|
111
111
|
|
|
112
112
|
| Goal | Command |
|
|
113
113
|
| --- | --- |
|
|
114
|
+
| Interactive terminal navigation | `agentctl menu` (or `agentctl -i`) |
|
|
114
115
|
| Inspect provider setup | `agentctl providers` |
|
|
115
116
|
| Create a scoped task | `agentctl task create` |
|
|
116
117
|
| Preview queued work | `agentctl queue --dry-run` |
|
|
@@ -153,6 +154,7 @@ and [SDK/MCP integration](https://github.com/FullThrottle83/jules-orchestrator-k
|
|
|
153
154
|
|
|
154
155
|
| Command | Description |
|
|
155
156
|
| --- | --- |
|
|
157
|
+
| `menu` | Interactive terminal navigation hub; browse commands and launch workflows without typing arguments. |
|
|
156
158
|
| `dashboard` | Local telemetry viewer; default port 4100. Set another port with `--port <n>`. |
|
|
157
159
|
|
|
158
160
|
|
|
@@ -174,7 +176,7 @@ npm run package-integrity
|
|
|
174
176
|
npm run guard-reach
|
|
175
177
|
```
|
|
176
178
|
|
|
177
|
-
The recorded baseline is **
|
|
179
|
+
The recorded baseline is **1670 unit tests across 210 suites**. Doc-sync compares
|
|
178
180
|
that count with an actual run. Counts do not establish correctness for every
|
|
179
181
|
provider or project. See
|
|
180
182
|
[contributing](https://github.com/FullThrottle83/jules-orchestrator-kit/blob/main/CONTRIBUTING.md)
|
package/bin/agentctl.mjs
CHANGED
|
@@ -272,8 +272,8 @@ async function main() {
|
|
|
272
272
|
if (!command) {
|
|
273
273
|
if (args.includes("-i") || args.includes("--interactive")) {
|
|
274
274
|
const { runTuiMenu } = await import("../src/ops/tui-menu.mjs");
|
|
275
|
-
await runTuiMenu(resolveRoot());
|
|
276
|
-
process.exit(0);
|
|
275
|
+
const res = await runTuiMenu(resolveRoot());
|
|
276
|
+
process.exit(res?.code || (res?.headless ? 1 : 0));
|
|
277
277
|
}
|
|
278
278
|
const { resolveNextStep, renderNextStep } = await import("../src/ops/next-step.mjs");
|
|
279
279
|
const cwd = process.cwd();
|
|
@@ -343,6 +343,7 @@ async function main() {
|
|
|
343
343
|
"require-plan-approval": { type: "boolean" },
|
|
344
344
|
"auto-approve-plans": { type: "boolean" },
|
|
345
345
|
"auto-approve": { type: "boolean" },
|
|
346
|
+
"auto-reply": { type: "boolean" },
|
|
346
347
|
"check-premise": { type: "boolean" },
|
|
347
348
|
"goal-check": { type: "string" },
|
|
348
349
|
idempotent: { type: "boolean" },
|
|
@@ -396,6 +397,7 @@ async function main() {
|
|
|
396
397
|
}
|
|
397
398
|
|
|
398
399
|
const autoApprove = Boolean(values["auto-approve-plans"] || values["auto-approve"]);
|
|
400
|
+
const autoReply = Boolean(values["auto-reply"] || values.autoReply);
|
|
399
401
|
const task = {
|
|
400
402
|
title: values.title || "CLI Dispatch Task",
|
|
401
403
|
prompt: promptContent,
|
|
@@ -406,6 +408,8 @@ async function main() {
|
|
|
406
408
|
repoless: values.repoless,
|
|
407
409
|
autoPr: values["auto-pr"],
|
|
408
410
|
requirePlanApproval: autoApprove ? false : values["require-plan-approval"],
|
|
411
|
+
autoApprovePlan: autoApprove,
|
|
412
|
+
autoReply,
|
|
409
413
|
checkPremise: values["check-premise"] || values.idempotent,
|
|
410
414
|
goalCheck: values["goal-check"],
|
|
411
415
|
author: values.author,
|
|
@@ -421,6 +425,8 @@ async function main() {
|
|
|
421
425
|
source: values.source,
|
|
422
426
|
branch: values.branch,
|
|
423
427
|
checkPremise: values["check-premise"] || values.idempotent,
|
|
428
|
+
autoApprovePlan: autoApprove,
|
|
429
|
+
autoReply,
|
|
424
430
|
});
|
|
425
431
|
if (values.json) {
|
|
426
432
|
console.log(JSON.stringify({ ok: true, session }, null, 2));
|
|
@@ -491,6 +497,7 @@ async function main() {
|
|
|
491
497
|
args: args.slice(1),
|
|
492
498
|
options: {
|
|
493
499
|
base: { type: "string", short: "b" },
|
|
500
|
+
head: { type: "string" },
|
|
494
501
|
mode: { type: "string", short: "m", default: "working-tree" },
|
|
495
502
|
"working-tree": { type: "boolean" },
|
|
496
503
|
staged: { type: "boolean" },
|
|
@@ -526,6 +533,7 @@ async function main() {
|
|
|
526
533
|
root,
|
|
527
534
|
config,
|
|
528
535
|
base: values.base,
|
|
536
|
+
head: values.head,
|
|
529
537
|
mode: selectedMode,
|
|
530
538
|
fix: values.fix,
|
|
531
539
|
allowProtected: values["allow-protected"],
|
|
@@ -1018,6 +1026,8 @@ async function main() {
|
|
|
1018
1026
|
cmd: { type: "string", short: "c" },
|
|
1019
1027
|
task: { type: "boolean", short: "t" },
|
|
1020
1028
|
author: { type: "string" },
|
|
1029
|
+
"auto-approve": { type: "boolean" },
|
|
1030
|
+
"auto-reply": { type: "boolean" },
|
|
1021
1031
|
json: { type: "boolean", short: "j" },
|
|
1022
1032
|
"dry-run": { type: "boolean" },
|
|
1023
1033
|
},
|
|
@@ -1069,7 +1079,13 @@ async function main() {
|
|
|
1069
1079
|
}
|
|
1070
1080
|
const repairRes = await repair(
|
|
1071
1081
|
{ stderr: cleanTrace, command: values.cmd || "verify" },
|
|
1072
|
-
{
|
|
1082
|
+
{
|
|
1083
|
+
root,
|
|
1084
|
+
dryRun: values["dry-run"],
|
|
1085
|
+
author: values.author,
|
|
1086
|
+
autoApprove: values["auto-approve"],
|
|
1087
|
+
autoReply: values["auto-reply"],
|
|
1088
|
+
}
|
|
1073
1089
|
);
|
|
1074
1090
|
|
|
1075
1091
|
if (values.json) {
|
|
@@ -1388,24 +1404,29 @@ async function main() {
|
|
|
1388
1404
|
} else {
|
|
1389
1405
|
files = queueEntries.filter((f) => isTaskFile(f, queueDir));
|
|
1390
1406
|
}
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
root,
|
|
1396
|
-
config,
|
|
1397
|
-
dag: values.dag,
|
|
1398
|
-
concurrency,
|
|
1399
|
-
dryRun: values["dry-run"],
|
|
1400
|
-
});
|
|
1407
|
+
if (!values.json) {
|
|
1408
|
+
console.log(`Found ${files.length} queued task(s) in .agent/jules-queue/`);
|
|
1409
|
+
}
|
|
1410
|
+
if (files.length === 0) {
|
|
1401
1411
|
if (values.json) {
|
|
1402
|
-
console.log(JSON.stringify(results, null, 2));
|
|
1403
|
-
const anyFailed = (results.results || []).some((r) => r && r.ok === false);
|
|
1404
|
-
process.exit(anyFailed ? 1 : 0);
|
|
1412
|
+
console.log(JSON.stringify({ ok: true, processed: 0, results: [], dryRun: Boolean(values["dry-run"]) }, null, 2));
|
|
1405
1413
|
}
|
|
1406
|
-
process.exit(
|
|
1414
|
+
process.exit(0);
|
|
1407
1415
|
}
|
|
1408
|
-
|
|
1416
|
+
const concurrency = values.concurrency ? Number(values.concurrency) : undefined;
|
|
1417
|
+
const results = await run(null, {
|
|
1418
|
+
root,
|
|
1419
|
+
config,
|
|
1420
|
+
dag: values.dag,
|
|
1421
|
+
concurrency,
|
|
1422
|
+
dryRun: values["dry-run"],
|
|
1423
|
+
});
|
|
1424
|
+
if (values.json) {
|
|
1425
|
+
console.log(JSON.stringify(results, null, 2));
|
|
1426
|
+
const anyFailed = (results.results || []).some((r) => r && r.ok === false);
|
|
1427
|
+
process.exit(anyFailed ? 1 : 0);
|
|
1428
|
+
}
|
|
1429
|
+
process.exit(reportRunOutcome(results));
|
|
1409
1430
|
break;
|
|
1410
1431
|
}
|
|
1411
1432
|
|
|
@@ -1881,7 +1902,7 @@ async function main() {
|
|
|
1881
1902
|
case "ci": {
|
|
1882
1903
|
const sub = args[1];
|
|
1883
1904
|
if (sub !== "init") {
|
|
1884
|
-
console.error(`❌ Unknown 'ci' action '${sub || ""}'. Usage: agentctl ci init [--target github|gitlab] [--force]`);
|
|
1905
|
+
console.error(`❌ Unknown 'ci' action '${sub || ""}'. Usage: agentctl ci init [--target github|gitlab] [--force] [--with-automerge]`);
|
|
1885
1906
|
process.exit(2);
|
|
1886
1907
|
}
|
|
1887
1908
|
const { values } = parseArgs({
|
|
@@ -1891,6 +1912,7 @@ async function main() {
|
|
|
1891
1912
|
force: { type: "boolean", short: "f" },
|
|
1892
1913
|
"dry-run": { type: "boolean", short: "d" },
|
|
1893
1914
|
json: { type: "boolean", short: "j" },
|
|
1915
|
+
"with-automerge": { type: "boolean" },
|
|
1894
1916
|
},
|
|
1895
1917
|
allowPositionals: true,
|
|
1896
1918
|
});
|
|
@@ -1902,6 +1924,7 @@ async function main() {
|
|
|
1902
1924
|
config,
|
|
1903
1925
|
version: VERSION,
|
|
1904
1926
|
stack: detectStack(root),
|
|
1927
|
+
withAutomerge: values["with-automerge"],
|
|
1905
1928
|
});
|
|
1906
1929
|
|
|
1907
1930
|
if (values.json) {
|
|
@@ -1912,9 +1935,13 @@ async function main() {
|
|
|
1912
1935
|
console.error(`❌ ${res.error}`);
|
|
1913
1936
|
process.exit(1);
|
|
1914
1937
|
}
|
|
1915
|
-
|
|
1938
|
+
const filesDesc = Array.isArray(res.files) ? res.files.join(", ") : res.file;
|
|
1939
|
+
console.log(`✅ ${res.written ? "Wrote" : "Would write"} ${filesDesc}`);
|
|
1916
1940
|
console.log(` Stack: ${res.stack} · runtime setup: ${res.setupSummary}`);
|
|
1917
1941
|
console.log(` The workflow runs: agentctl check --mode committed`);
|
|
1942
|
+
if (values["with-automerge"]) {
|
|
1943
|
+
console.log(` Auto-merge: enabled for authorized agent pull requests via workflow_run`);
|
|
1944
|
+
}
|
|
1918
1945
|
if (!res.written && !values["dry-run"]) {
|
|
1919
1946
|
console.log(` (unchanged — pass --force to overwrite)`);
|
|
1920
1947
|
}
|
|
@@ -2011,12 +2038,13 @@ async function main() {
|
|
|
2011
2038
|
break;
|
|
2012
2039
|
}
|
|
2013
2040
|
|
|
2041
|
+
case "hub":
|
|
2014
2042
|
case "menu":
|
|
2015
2043
|
case "tui":
|
|
2016
2044
|
case "ui": {
|
|
2017
2045
|
const { runTuiMenu } = await import("../src/ops/tui-menu.mjs");
|
|
2018
|
-
await runTuiMenu(root);
|
|
2019
|
-
process.exit(0);
|
|
2046
|
+
const res = await runTuiMenu(root);
|
|
2047
|
+
process.exit(res?.code || (res?.headless ? 1 : 0));
|
|
2020
2048
|
break;
|
|
2021
2049
|
}
|
|
2022
2050
|
|
|
@@ -2175,7 +2203,7 @@ async function main() {
|
|
|
2175
2203
|
|
|
2176
2204
|
case "task": {
|
|
2177
2205
|
const subCommand = args[1] || "create";
|
|
2178
|
-
if (subCommand === "create") {
|
|
2206
|
+
if (subCommand === "create" || subCommand === "new") {
|
|
2179
2207
|
const { values, positionals } = parseArgs({
|
|
2180
2208
|
args: args.slice(2),
|
|
2181
2209
|
options: {
|
|
@@ -2436,7 +2464,7 @@ async function main() {
|
|
|
2436
2464
|
|
|
2437
2465
|
process.exit(result.ok ? 0 : 1);
|
|
2438
2466
|
} else {
|
|
2439
|
-
console.error(`Unknown task subcommand '${subCommand}'. Supported: agentctl task create, agentctl task optimize, agentctl task template, agentctl task validate`);
|
|
2467
|
+
console.error(`Unknown task subcommand '${subCommand}'. Supported: agentctl task create (or task new), agentctl task optimize, agentctl task template, agentctl task validate`);
|
|
2440
2468
|
process.exit(1);
|
|
2441
2469
|
}
|
|
2442
2470
|
break;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jules-orchestrator-kit",
|
|
3
|
-
"version": "0.75.
|
|
4
|
-
"description": "Zero-dependency safety gatekeeper, test oracle generator, and multi-agent coordination protocol for autonomous coding agents
|
|
3
|
+
"version": "0.75.2",
|
|
4
|
+
"description": "Zero-dependency safety gatekeeper, test oracle generator, and multi-agent coordination protocol for autonomous coding agents — Google Jules, Claude Code, Codex and Gemini CLI.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/FullThrottle83/jules-orchestrator-kit.git"
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { execFileSync } from "node:child_process";
|
|
18
18
|
import { checkScope } from "../src/security.mjs";
|
|
19
|
-
import { normalizePath } from "../src/config.mjs";
|
|
19
|
+
import { normalizePath, canonicalizePath } from "../src/config.mjs";
|
|
20
20
|
|
|
21
21
|
/** Exit code 3 in the kit's registry: scope violation. */
|
|
22
22
|
const EXIT_SCOPE_VIOLATION = 3;
|
|
@@ -74,36 +74,132 @@ export function loadProtectedPatterns(opts = {}) {
|
|
|
74
74
|
);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/** Where a symlink at `link` pointing at `target` lands, as a repo-relative or absolute path. */
|
|
78
|
+
export function resolveLinkTarget(link, target) {
|
|
79
|
+
const normLink = normalizePath(link);
|
|
80
|
+
const normTarget = normalizePath(target);
|
|
81
|
+
if (normTarget.startsWith("/") || /^[a-zA-Z]:[/\\]/.test(target)) {
|
|
82
|
+
return normTarget;
|
|
83
|
+
}
|
|
84
|
+
const linkDir = normLink.split("/").slice(0, -1).join("/");
|
|
85
|
+
return canonicalizePath(linkDir ? `${linkDir}/${normTarget}` : normTarget);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Parses `git diff --raw -z` output into structured file change entries.
|
|
90
|
+
*
|
|
91
|
+
* Each record emitted by `--raw -z` is:
|
|
92
|
+
* ":<srcmode> <dstmode> <srcsha> <dstsha> <status>\0<path>\0"
|
|
93
|
+
* (or two paths if renames were enabled). With `--no-renames`, it is always 1 path.
|
|
94
|
+
*
|
|
95
|
+
* For symlinks (mode 120000), retrieves the link target blob via `git cat-file blob <dstSha>`
|
|
96
|
+
* without checking out the branch to disk.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} raw - raw stdout from git diff --raw -z
|
|
99
|
+
* @param {string} [root] - repo root cwd
|
|
100
|
+
* @returns {Array<{ file: string, srcMode: string, dstMode: string, srcSha: string, dstSha: string, status: string, symlinkTarget?: string, symlinkUnreadable?: boolean }>}
|
|
101
|
+
*/
|
|
102
|
+
export function parseRawDiffEntries(raw, root = process.cwd()) {
|
|
103
|
+
if (!raw || typeof raw !== "string") return [];
|
|
104
|
+
const fields = raw.split("\0").filter((f) => f !== "");
|
|
105
|
+
const entries = [];
|
|
106
|
+
|
|
107
|
+
for (let i = 0; i < fields.length; i++) {
|
|
108
|
+
const meta = fields[i];
|
|
109
|
+
if (!meta.startsWith(":")) continue;
|
|
110
|
+
const parts = meta.slice(1).split(/\s+/);
|
|
111
|
+
const file = fields[i + 1];
|
|
112
|
+
i += 1;
|
|
113
|
+
if (!file) continue;
|
|
114
|
+
|
|
115
|
+
const srcMode = parts[0] || "";
|
|
116
|
+
const dstMode = parts[1] || "";
|
|
117
|
+
const srcSha = parts[2] || "";
|
|
118
|
+
const dstSha = parts[3] || "";
|
|
119
|
+
const status = (parts[4] || "").charAt(0);
|
|
120
|
+
|
|
121
|
+
let symlinkTarget = null;
|
|
122
|
+
let symlinkUnreadable = false;
|
|
123
|
+
|
|
124
|
+
if (dstMode === "120000") {
|
|
125
|
+
if (dstSha && !/^0+$/.test(dstSha)) {
|
|
126
|
+
try {
|
|
127
|
+
const rawTarget = execFileSync("git", ["cat-file", "blob", dstSha], {
|
|
128
|
+
cwd: root,
|
|
129
|
+
encoding: "utf-8",
|
|
130
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
131
|
+
}).trim();
|
|
132
|
+
if (rawTarget) {
|
|
133
|
+
symlinkTarget = resolveLinkTarget(file, rawTarget);
|
|
134
|
+
} else {
|
|
135
|
+
symlinkUnreadable = true;
|
|
136
|
+
}
|
|
137
|
+
} catch (_) {
|
|
138
|
+
symlinkUnreadable = true;
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
symlinkUnreadable = true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
entries.push({
|
|
146
|
+
file: normalizePath(file),
|
|
147
|
+
srcMode,
|
|
148
|
+
dstMode,
|
|
149
|
+
srcSha,
|
|
150
|
+
dstSha,
|
|
151
|
+
status,
|
|
152
|
+
symlinkTarget,
|
|
153
|
+
symlinkUnreadable,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return entries;
|
|
158
|
+
}
|
|
159
|
+
|
|
77
160
|
/**
|
|
78
161
|
* Lists the paths a pull request changes.
|
|
79
162
|
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* `säkerhet/nyckel.pem` arrive as tokens that match no pattern — a protected
|
|
83
|
-
* file walking past the guard because of how it is spelled.
|
|
163
|
+
* Inspects `git diff --raw -z --no-renames` to capture all paths while
|
|
164
|
+
* preserving spaces and non-ASCII characters without quoting corruption.
|
|
84
165
|
*
|
|
85
166
|
* @param {{ baseSha: string, headSha: string, root?: string }} opts
|
|
86
167
|
* @returns {string[]}
|
|
87
168
|
*/
|
|
88
169
|
export function listChangedFiles(opts = {}) {
|
|
170
|
+
return listChangedEntries(opts).map((e) => e.file);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Returns raw diff entries including blob mode and symlink resolution.
|
|
175
|
+
*
|
|
176
|
+
* @param {{ baseSha: string, headSha: string, root?: string }} opts
|
|
177
|
+
* @returns {Array<{ file: string, srcMode: string, dstMode: string, srcSha: string, dstSha: string, status: string, symlinkTarget?: string, symlinkUnreadable?: boolean }>}
|
|
178
|
+
*/
|
|
179
|
+
export function listChangedEntries(opts = {}) {
|
|
89
180
|
const root = opts.root || process.cwd();
|
|
90
181
|
const raw = execFileSync(
|
|
91
182
|
"git",
|
|
92
|
-
["-c", "core.quotePath=false", "diff", "-z", "--
|
|
183
|
+
["-c", "core.quotePath=false", "diff", "-z", "--raw", "--no-renames", opts.baseSha, opts.headSha],
|
|
93
184
|
{ cwd: root, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 10 * 1024 * 1024 }
|
|
94
185
|
);
|
|
95
|
-
return raw
|
|
186
|
+
return parseRawDiffEntries(raw, root);
|
|
96
187
|
}
|
|
97
188
|
|
|
98
189
|
/**
|
|
99
190
|
* Pure evaluation core, so the decision is testable without a git repository.
|
|
100
191
|
*
|
|
101
|
-
*
|
|
192
|
+
* Evaluates changed files and entries against protected patterns:
|
|
193
|
+
* - Rejects Git submodules (mode 160000)
|
|
194
|
+
* - Evaluates symlinks (mode 120000): rejects traversal / root escape, fails closed on unreadable targets,
|
|
195
|
+
* and fails if the resolved symlink target points to a protected file.
|
|
196
|
+
*
|
|
197
|
+
* @param {Array<string|object>} filesOrEntries
|
|
102
198
|
* @param {string[]} patterns
|
|
103
|
-
* @param {{ labels?: string[] }} [opts]
|
|
199
|
+
* @param {{ labels?: string[], headSha?: string, symlinks?: Array<{ link: string, target: string }> }} [opts]
|
|
104
200
|
* @returns {{ ok: boolean, bypassed: boolean, violations: Array<object> }}
|
|
105
201
|
*/
|
|
106
|
-
export function evaluateScopeGuard(
|
|
202
|
+
export function evaluateScopeGuard(filesOrEntries = [], patterns = [], opts = {}) {
|
|
107
203
|
const labels = (opts.labels || []).map((l) => String(l).toLowerCase().trim());
|
|
108
204
|
const headSha = String(opts.headSha || "").trim().toLowerCase();
|
|
109
205
|
|
|
@@ -118,12 +214,88 @@ export function evaluateScopeGuard(files = [], patterns = [], opts = {}) {
|
|
|
118
214
|
bypassed = labels.includes(BYPASS_LABEL) || labels.includes(LEGACY_BYPASS_LABEL);
|
|
119
215
|
}
|
|
120
216
|
|
|
217
|
+
const normalizedEntries = [];
|
|
218
|
+
for (const item of filesOrEntries) {
|
|
219
|
+
if (typeof item === "string") {
|
|
220
|
+
normalizedEntries.push({ file: item });
|
|
221
|
+
} else if (item && typeof item === "object") {
|
|
222
|
+
normalizedEntries.push(item);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (Array.isArray(opts.symlinks)) {
|
|
227
|
+
for (const s of opts.symlinks) {
|
|
228
|
+
if (s && s.link) {
|
|
229
|
+
normalizedEntries.push({
|
|
230
|
+
file: s.link,
|
|
231
|
+
dstMode: "120000",
|
|
232
|
+
symlinkTarget: s.target || null,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const explicitViolations = [];
|
|
239
|
+
const scopeCandidates = [];
|
|
240
|
+
const symlinkTargetOf = new Map();
|
|
241
|
+
|
|
242
|
+
for (const entry of normalizedEntries) {
|
|
243
|
+
const file = entry.file || "";
|
|
244
|
+
if (file) {
|
|
245
|
+
scopeCandidates.push(file);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 1. Refuse Git submodules (mode 160000)
|
|
249
|
+
if (entry.dstMode === "160000") {
|
|
250
|
+
explicitViolations.push({
|
|
251
|
+
file,
|
|
252
|
+
reason: "Git submodule (mode 160000) is forbidden in agent pull requests",
|
|
253
|
+
rule: "deny",
|
|
254
|
+
pattern: "<submodule>",
|
|
255
|
+
});
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 2. Inspect Symlinks (mode 120000)
|
|
260
|
+
if (entry.dstMode === "120000") {
|
|
261
|
+
if (entry.symlinkUnreadable || !entry.symlinkTarget) {
|
|
262
|
+
explicitViolations.push({
|
|
263
|
+
file,
|
|
264
|
+
reason: "Unreadable or empty symlink target cannot be verified",
|
|
265
|
+
rule: "deny",
|
|
266
|
+
pattern: "<unreadable-symlink>",
|
|
267
|
+
});
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const target = entry.symlinkTarget;
|
|
272
|
+
scopeCandidates.push(target);
|
|
273
|
+
symlinkTargetOf.set(target, file);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
121
277
|
// Matching always runs at full strength; the label only decides whether a
|
|
122
278
|
// match blocks. Passing `allowProtected` into checkScope instead would make
|
|
123
279
|
// a bypassed run report zero violations, and the job log is the record of
|
|
124
280
|
// what a human waved through.
|
|
125
|
-
const res = checkScope(
|
|
126
|
-
|
|
281
|
+
const res = checkScope(scopeCandidates, { protect: patterns }, { allowProtected: false });
|
|
282
|
+
|
|
283
|
+
const mappedScopeViolations = (res.violations || []).map((v) => {
|
|
284
|
+
const link = symlinkTargetOf.get(v.file);
|
|
285
|
+
if (link) {
|
|
286
|
+
return {
|
|
287
|
+
...v,
|
|
288
|
+
link,
|
|
289
|
+
file: link,
|
|
290
|
+
target: v.file,
|
|
291
|
+
reason: `Symlink "${link}" -> "${v.file}": ${v.reason}`,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
return v;
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const allViolations = [...explicitViolations, ...mappedScopeViolations];
|
|
298
|
+
return { ok: bypassed || allViolations.length === 0, bypassed, violations: allViolations };
|
|
127
299
|
}
|
|
128
300
|
|
|
129
301
|
/**
|
|
@@ -157,21 +329,29 @@ function main() {
|
|
|
157
329
|
}
|
|
158
330
|
|
|
159
331
|
let patterns;
|
|
160
|
-
let
|
|
332
|
+
let entries = [];
|
|
161
333
|
try {
|
|
162
334
|
patterns = loadProtectedPatterns({ baseSha, baseRef, root });
|
|
163
|
-
|
|
335
|
+
entries = listChangedEntries({ baseSha, headSha, root });
|
|
164
336
|
} catch (err) {
|
|
165
337
|
console.error(`::error::Agent Scope Guard could not evaluate this pull request: ${err.message}`);
|
|
166
338
|
process.exit(EXIT_ERROR);
|
|
167
339
|
}
|
|
168
340
|
|
|
169
341
|
const labels = parseLabels(process.env.PR_LABELS);
|
|
170
|
-
const result = evaluateScopeGuard(
|
|
342
|
+
const result = evaluateScopeGuard(entries, patterns, { labels, headSha });
|
|
171
343
|
|
|
172
344
|
console.log(`Protected patterns (${patterns.length}): ${patterns.join(", ")}`);
|
|
173
|
-
console.log(`Changed files (${
|
|
174
|
-
for (const
|
|
345
|
+
console.log(`Changed files (${entries.length}):`);
|
|
346
|
+
for (const e of entries) {
|
|
347
|
+
if (e.dstMode === "120000") {
|
|
348
|
+
console.log(` ${e.file} -> ${e.symlinkTarget || "(unresolved)"} [symlink]`);
|
|
349
|
+
} else if (e.dstMode === "160000") {
|
|
350
|
+
console.log(` ${e.file} [submodule]`);
|
|
351
|
+
} else {
|
|
352
|
+
console.log(` ${e.file}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
175
355
|
|
|
176
356
|
const labelName = headSha ? `${BYPASS_LABEL}:${headSha}` : BYPASS_LABEL;
|
|
177
357
|
const legacyLabelName = headSha ? `${LEGACY_BYPASS_LABEL}:${headSha}` : LEGACY_BYPASS_LABEL;
|
package/scripts/run-tests.mjs
CHANGED
|
@@ -101,18 +101,22 @@ function reap(signal = "SIGKILL") {
|
|
|
101
101
|
|
|
102
102
|
// Ctrl-C, a CI cancellation and a harness teardown all arrive as signals; each
|
|
103
103
|
// must take the tree with it rather than detaching it from its parent.
|
|
104
|
+
let shuttingDown = false;
|
|
104
105
|
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
105
106
|
process.on(sig, () => {
|
|
107
|
+
if (shuttingDown) return;
|
|
108
|
+
shuttingDown = true;
|
|
106
109
|
reap("SIGTERM");
|
|
107
110
|
// Give the group a moment to unwind, then make sure.
|
|
108
111
|
setTimeout(() => {
|
|
109
112
|
reap("SIGKILL");
|
|
110
|
-
process.exit(130);
|
|
113
|
+
process.exit(sig === "SIGINT" ? 130 : 143);
|
|
111
114
|
}, 2000).unref();
|
|
112
115
|
});
|
|
113
116
|
}
|
|
114
117
|
|
|
115
118
|
child.on("exit", (code, signal) => {
|
|
119
|
+
if (shuttingDown) return;
|
|
116
120
|
// Stragglers outlive a clean exit too: a test that spawned a server and
|
|
117
121
|
// failed before its teardown leaves it running.
|
|
118
122
|
reap("SIGKILL");
|