jules-orchestrator-kit 0.72.2 → 0.73.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/prompts/{Overseer.md → Auditor.md} +3 -3
- package/.agent/prompts/{Alchemist.md → Database.md} +1 -1
- package/.agent/prompts/Debugger.md +25 -0
- package/.agent/prompts/{Scribe.md → Docs.md} +8 -5
- package/.agent/prompts/{Spectator.md → E2E.md} +9 -6
- package/.agent/prompts/{Janitor.md → Hygiene.md} +2 -2
- package/.agent/prompts/{Bolt.md → Performance.md} +1 -1
- package/.agent/prompts/Resilience.md +20 -0
- package/.agent/prompts/Security.md +21 -0
- package/.agent/prompts/Testing.md +30 -0
- package/.agent/prompts/Types.md +19 -0
- package/.agent/rules/jules-protocol.md +4 -3
- package/AGENTS.md +78 -96
- package/CHANGELOG.md +193 -0
- package/JULES_RULES_TEMPLATE.md +83 -96
- package/LICENSE +1 -1
- package/README.md +77 -439
- package/ROADMAP_V1.md +22 -132
- package/bin/agentctl.mjs +443 -144
- package/bin/init.js +6 -3
- package/index.mjs +9 -6
- package/package.json +1 -1
- package/scripts/asset-integrity-check.mjs +1 -1
- package/scripts/doc-sync-check.mjs +47 -0
- package/scripts/generate-command-reference.mjs +39 -0
- package/scripts/jules-dispatch.mjs +12 -113
- package/scripts/jules-merge-swarm.mjs +8 -196
- package/scripts/jules-patch.mjs +7 -8
- package/scripts/jules-queue-runner.mjs +6 -8
- package/scripts/jules-scan-todos.mjs +10 -38
- package/scripts/jules-self-audit.mjs +8 -139
- package/scripts/jules-status.mjs +32 -38
- package/scripts/jules-webhook-receiver.mjs +1 -1
- package/src/assertions.mjs +5 -50
- package/src/bidi-guard.mjs +36 -0
- package/src/budget.mjs +3 -14
- package/src/config.mjs +2 -6
- package/src/dashboard.mjs +7 -9
- package/src/dispatch.mjs +212 -0
- package/src/engine.mjs +40 -16
- package/src/evidence.mjs +10 -41
- package/src/execution-envelope.mjs +13 -1
- package/src/flaky-ledger.mjs +1 -1
- package/src/fs-atomic.mjs +72 -0
- package/src/git.mjs +298 -27
- package/src/mcp.mjs +296 -7
- package/src/memory.mjs +0 -0
- package/src/merge-swarm.mjs +202 -0
- package/src/ops/cli-intent.mjs +1 -0
- package/src/ops/command-registry.mjs +796 -70
- package/src/ops/doctor-registry.mjs +134 -47
- package/src/ops/handover.mjs +3 -27
- package/src/ops/pr-harvest.mjs +1 -1
- package/src/prompt-guard.mjs +33 -3
- package/src/provider.mjs +51 -7
- package/src/remediation.mjs +2 -2
- package/src/role-resolver.mjs +113 -3
- package/src/router.mjs +19 -11
- package/src/runtime-env.mjs +67 -0
- package/src/scaffold.mjs +3 -1
- package/src/scope-guard.mjs +249 -0
- package/src/secret-scanner.mjs +530 -0
- package/src/security.mjs +81 -2972
- package/src/self-audit.mjs +140 -0
- package/src/session-ops.mjs +29 -0
- package/src/stability.mjs +8 -1
- package/src/stack-detector.mjs +5 -2
- package/src/state.mjs +45 -0
- package/src/swarm.mjs +76 -0
- package/src/task-optimizer.mjs +34 -7
- package/src/telemetry.mjs +23 -0
- package/src/test-tamper-guard.mjs +2173 -0
- package/src/todo-scanner.mjs +129 -0
- package/src/web-templates.mjs +3 -3
- package/src/webhook.mjs +10 -3
- package/src/wizard-init.mjs +12 -20
- package/src/wizard-oracle.mjs +4 -3
- package/src/wizard-task.mjs +37 -9
- package/.agent/prompts/Sentinel.md +0 -18
- package/scripts/utils.mjs +0 -241
package/src/router.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { normalizePath } from "./config.mjs";
|
|
|
4
4
|
import { matchesGlob } from "./security.mjs";
|
|
5
5
|
import { extractPathTokens } from "./task-optimizer.mjs";
|
|
6
6
|
import { createProvider, createFailoverProvider, createSyntaxVerifiedProvider } from "./provider.mjs";
|
|
7
|
+
import { ROLE_ALIASES, CANONICAL_ROLES } from "./role-resolver.mjs";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Dynamic Complexity & Cost Router (Roadmap v0.33.0).
|
|
@@ -71,10 +72,14 @@ const COMPLEX_SIGNALS = [
|
|
|
71
72
|
/\bencrypt(ion)?\b/i,
|
|
72
73
|
];
|
|
73
74
|
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
// Security specialists handle security-sensitive work; the primary provider is always used.
|
|
76
|
+
// Canonical slugs only: classifyTaskComplexity normalises the requested role
|
|
77
|
+
// through ROLE_ALIASES before these sets are consulted, so a legacy alias
|
|
78
|
+
// already arrives here as its canonical role. Listing legacy names here
|
|
79
|
+
// would be dead weight, not a fallback.
|
|
80
|
+
const FORCE_COMPLEX_ROLES = new Set(["security"]);
|
|
81
|
+
const FAST_LEANING_ROLES = new Set(["hygiene", "performance"]);
|
|
82
|
+
const COMPLEX_LEANING_ROLES = new Set(["auditor", "security"]);
|
|
78
83
|
|
|
79
84
|
// Supplements config.scope.deny — these are never eligible for the fast tier
|
|
80
85
|
// regardless of user scope config, mirroring src/risk.mjs's RESTRICTED_PATH_PATTERNS.
|
|
@@ -129,10 +134,13 @@ export function classifyTaskComplexity(task = {}, config = {}) {
|
|
|
129
134
|
}
|
|
130
135
|
|
|
131
136
|
const paths = collectReferencedPaths(task);
|
|
132
|
-
const
|
|
137
|
+
const rawRole = String(task.role || "").trim().toLowerCase();
|
|
138
|
+
const role = (ROLE_ALIASES[rawRole] && CANONICAL_ROLES.includes(ROLE_ALIASES[rawRole]))
|
|
139
|
+
? ROLE_ALIASES[rawRole]
|
|
140
|
+
: rawRole;
|
|
133
141
|
|
|
134
|
-
if (FORCE_COMPLEX_ROLES.has(role)) {
|
|
135
|
-
return { tier: ROUTE_TIERS.COMPLEX, score: null, forced: true, reason: `Role '${role}' always routes to the primary provider` };
|
|
142
|
+
if (FORCE_COMPLEX_ROLES.has(role) || FORCE_COMPLEX_ROLES.has(rawRole)) {
|
|
143
|
+
return { tier: ROUTE_TIERS.COMPLEX, score: null, forced: true, reason: `Role '${task.role}' always routes to the primary provider` };
|
|
136
144
|
}
|
|
137
145
|
|
|
138
146
|
// 1. Declarative Asset Override: 100% declarative non-executable files bypass sensitive-path penalty
|
|
@@ -216,13 +224,13 @@ export function classifyTaskComplexity(task = {}, config = {}) {
|
|
|
216
224
|
signals.push(`-1 short prompt (${promptLen} chars)`);
|
|
217
225
|
}
|
|
218
226
|
|
|
219
|
-
if (COMPLEX_LEANING_ROLES.has(role)) {
|
|
227
|
+
if (COMPLEX_LEANING_ROLES.has(role) || COMPLEX_LEANING_ROLES.has(rawRole)) {
|
|
220
228
|
score += 1;
|
|
221
|
-
signals.push(`+1 role '${role}' leans complex`);
|
|
229
|
+
signals.push(`+1 role '${task.role}' leans complex`);
|
|
222
230
|
}
|
|
223
|
-
if (FAST_LEANING_ROLES.has(role)) {
|
|
231
|
+
if (FAST_LEANING_ROLES.has(role) || FAST_LEANING_ROLES.has(rawRole)) {
|
|
224
232
|
score -= 2;
|
|
225
|
-
signals.push(`-2 role '${role}' leans fast`);
|
|
233
|
+
signals.push(`-2 role '${task.role}' leans fast`);
|
|
226
234
|
}
|
|
227
235
|
|
|
228
236
|
const threshold = Number.isFinite(config?.router?.threshold) ? config.router.threshold : 0;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime environment helpers shared by the kit's CLI entry points.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from the deleted `scripts/utils.mjs` shim: .env loading,
|
|
5
|
+
* timestamping, the console logger the CLI entries print through, and the
|
|
6
|
+
* isolated SDK cache directory. Nothing here is business logic; these are the
|
|
7
|
+
* small environment primitives entry scripts (and tests) need so they do not
|
|
8
|
+
* each carry their own copy.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
12
|
+
import { join, resolve } from "node:path";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
|
|
15
|
+
export const log = {
|
|
16
|
+
info: (msg) => console.log(`ℹ️ ${msg}`),
|
|
17
|
+
success: (msg) => console.log(`✅ ${msg}`),
|
|
18
|
+
warn: (msg) => console.warn(`⚠️ ${msg}`),
|
|
19
|
+
error: (msg) => console.error(`❌ ${msg}`),
|
|
20
|
+
step: (stepStr, msg) => console.log(`${stepStr} ${msg}`),
|
|
21
|
+
dim: (msg) => console.log(msg),
|
|
22
|
+
header: (msg) => console.log(`\n=== ${msg} ===\n`),
|
|
23
|
+
groupEnd: () => {},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function timestamp() {
|
|
27
|
+
return new Date().toISOString();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Loads KEY=VALUE lines from `<targetDir>/.env` into `process.env`.
|
|
32
|
+
* Quoted values and leading `export ` are tolerated; a missing file is a no-op.
|
|
33
|
+
*/
|
|
34
|
+
export function loadEnv(targetDir = process.cwd()) {
|
|
35
|
+
const envPath = join(targetDir, ".env");
|
|
36
|
+
if (!existsSync(envPath)) return;
|
|
37
|
+
try {
|
|
38
|
+
const raw = readFileSync(envPath, "utf-8");
|
|
39
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
40
|
+
const trimmed = line.trim();
|
|
41
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
42
|
+
const clean = trimmed.startsWith("export ") ? trimmed.slice(7).trim() : trimmed;
|
|
43
|
+
const eqIdx = clean.indexOf("=");
|
|
44
|
+
if (eqIdx > 0) {
|
|
45
|
+
const k = clean.slice(0, eqIdx).trim();
|
|
46
|
+
let v = clean.slice(eqIdx + 1).trim();
|
|
47
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
48
|
+
v = v.slice(1, -1);
|
|
49
|
+
}
|
|
50
|
+
process.env[k] = v;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
} catch (_) {}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getIsolatedCacheDir() {
|
|
57
|
+
return process.env.JULES_CACHE_DIR ? resolve(process.env.JULES_CACHE_DIR) : join(os.homedir(), ".cache", "jules-orchestrator-kit");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function ensureSdkCacheIsolation() {
|
|
61
|
+
const dir = getIsolatedCacheDir();
|
|
62
|
+
if (!existsSync(dir)) {
|
|
63
|
+
try { mkdirSync(dir, { recursive: true }); } catch (_) {}
|
|
64
|
+
}
|
|
65
|
+
process.env.JULES_CACHE_DIR = dir;
|
|
66
|
+
return dir;
|
|
67
|
+
}
|
package/src/scaffold.mjs
CHANGED
|
@@ -21,6 +21,8 @@ export const RUNTIME_GITIGNORE_ENTRIES = [
|
|
|
21
21
|
".agent/state/",
|
|
22
22
|
".agent/evidence/",
|
|
23
23
|
".agent/handovers/",
|
|
24
|
+
".agent/knowledge/",
|
|
25
|
+
".agent/SYSTEM_LEARNINGS.md",
|
|
24
26
|
".agent/jules-queue/.state/",
|
|
25
27
|
".agent/jules-queue/failed/",
|
|
26
28
|
".agent/jules-queue/.processing/",
|
|
@@ -250,7 +252,7 @@ export function scaffoldRepoAssets(root = process.cwd(), options = {}) {
|
|
|
250
252
|
}
|
|
251
253
|
|
|
252
254
|
if (copyDir(join(KIT_ROOT, ".agent/prompts"), join(agentDir, "prompts"), force) > 0) {
|
|
253
|
-
created.push(".agent/prompts/ (
|
|
255
|
+
created.push(".agent/prompts/ (Auditor, Performance, Security, Hygiene, Resilience, Types, Debugger, Testing, E2E, Database, Docs, A11y)");
|
|
254
256
|
}
|
|
255
257
|
if (copyDir(join(KIT_ROOT, ".agent/rules"), join(agentDir, "rules"), force) > 0) {
|
|
256
258
|
created.push(".agent/rules/");
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scope policy: which paths an agent may touch.
|
|
3
|
+
*
|
|
4
|
+
* Split out of src/security.mjs (P05). The glob matcher, the deny/protect
|
|
5
|
+
* matching and checkScope() form one job — deciding whether a path is inside the
|
|
6
|
+
* allowed scope — and none of it reads a diff or looks for a secret. The glob
|
|
7
|
+
* matcher is deliberately regex-free and is imported by assertions.mjs, risk.mjs,
|
|
8
|
+
* router.mjs and self-audit.mjs as well as by the scope rules below.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { basename } from "node:path";
|
|
12
|
+
import { canonicalizePath, isWindowsAbsolutePath } from "./config.mjs";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Glob matcher.
|
|
16
|
+
*
|
|
17
|
+
* `caseInsensitive` exists because the same repository is checked out on
|
|
18
|
+
* Linux, macOS and Windows. On APFS and NTFS, `.GitHub/` and `.github/` are
|
|
19
|
+
* the *same directory*, but git records whichever case was committed — so a
|
|
20
|
+
* case-sensitive deny rule can be walked straight past on two of the three
|
|
21
|
+
* target platforms. Deny and protect matching therefore folds case; allow
|
|
22
|
+
* matching deliberately does not, so that a case mismatch fails closed
|
|
23
|
+
* (unmatched by allow = violation) rather than opening a hole.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} filePath
|
|
26
|
+
* @param {string} globPattern
|
|
27
|
+
* @param {{ caseInsensitive?: boolean }} [opts]
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* Matches one `/`-free glob segment against one `/`-free path segment.
|
|
31
|
+
*
|
|
32
|
+
* Only `*` (zero or more characters) and `?` (exactly one) are special; every
|
|
33
|
+
* other character — including regex metacharacters like `(`, `+`, `.`, `[` —
|
|
34
|
+
* is matched literally, preserving the escaping behaviour the old regex
|
|
35
|
+
* translation had. A segment that is exactly `*` keeps its historical one-or-
|
|
36
|
+
* more semantics, so `*` cannot match an empty segment.
|
|
37
|
+
*
|
|
38
|
+
* Implemented with the classic greedy-star wildcard algorithm rather than a
|
|
39
|
+
* compiled regex: a segment like `*a*a*a*a*a*a*b` translated to
|
|
40
|
+
* `^[^/]*a[^/]*a…$` and backtracked exponentially on a long run of `a`s, so
|
|
41
|
+
* this path must never build a regex. The algorithm scans each character a
|
|
42
|
+
* bounded number of times and cannot blow up the way the regex could.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} str
|
|
45
|
+
* @param {string} pattern
|
|
46
|
+
* @param {boolean} [caseInsensitive]
|
|
47
|
+
* @returns {boolean}
|
|
48
|
+
*/
|
|
49
|
+
function matchGlobSegment(str, pattern, caseInsensitive = false) {
|
|
50
|
+
if (pattern === "*") return str.length > 0;
|
|
51
|
+
|
|
52
|
+
let s = caseInsensitive ? str.toLowerCase() : str;
|
|
53
|
+
let p = caseInsensitive ? pattern.toLowerCase() : pattern;
|
|
54
|
+
|
|
55
|
+
let si = 0;
|
|
56
|
+
let pi = 0;
|
|
57
|
+
let star = -1;
|
|
58
|
+
let matchIdx = 0;
|
|
59
|
+
|
|
60
|
+
while (si < s.length) {
|
|
61
|
+
if (pi < p.length && (p[pi] === "?" || p[pi] === s[si])) {
|
|
62
|
+
si++;
|
|
63
|
+
pi++;
|
|
64
|
+
} else if (pi < p.length && p[pi] === "*") {
|
|
65
|
+
star = pi;
|
|
66
|
+
matchIdx = si;
|
|
67
|
+
pi++;
|
|
68
|
+
} else if (star !== -1) {
|
|
69
|
+
pi = star + 1;
|
|
70
|
+
matchIdx++;
|
|
71
|
+
si = matchIdx;
|
|
72
|
+
} else {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
while (pi < p.length && p[pi] === "*") pi++;
|
|
78
|
+
return pi === p.length;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Linear-time glob matcher over `/`-split segments.
|
|
83
|
+
*
|
|
84
|
+
* `**` matches zero or more whole segments; every other pattern segment
|
|
85
|
+
* matches exactly one path segment via `matchGlobSegment`. This is a
|
|
86
|
+
* bottom-up dynamic program with a rolling array: O(n·m) time and O(m) memory
|
|
87
|
+
* for n path and m pattern segments, and it builds no regex at all.
|
|
88
|
+
*
|
|
89
|
+
* The previous implementation translated `**` into overlapping dot-star and
|
|
90
|
+
* start-anchored `(?: … |^)` alternations (`SEC-01`). Anchored against `$`, a
|
|
91
|
+
* pattern like `*a*a*a*a*a*a*a*a*b` or a chain of globstars caused catastrophic
|
|
92
|
+
* backtracking — the match time grew exponentially with input length and a
|
|
93
|
+
* hostile deny rule or file list could stall the dispatch gate. The DP
|
|
94
|
+
* replaces every one of those constructs with a bounded scan.
|
|
95
|
+
*
|
|
96
|
+
* @param {string[]} pathSegs
|
|
97
|
+
* @param {string[]} patSegs
|
|
98
|
+
* @param {boolean} [caseInsensitive]
|
|
99
|
+
* @returns {boolean}
|
|
100
|
+
*/
|
|
101
|
+
function matchGlobSegments(pathSegs, patSegs, caseInsensitive = false) {
|
|
102
|
+
const n = pathSegs.length;
|
|
103
|
+
const m = patSegs.length;
|
|
104
|
+
|
|
105
|
+
// next[j] answers "does pathSegs[i+1..] match patSegs[j..]?". Seeded for the
|
|
106
|
+
// empty-path row (i = n): only true when every remaining pattern segment is
|
|
107
|
+
// `**`, since those are the only segments that can match zero path segments.
|
|
108
|
+
let next = new Array(m + 1).fill(false);
|
|
109
|
+
next[m] = true;
|
|
110
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
111
|
+
next[j] = patSegs[j] === "**" && next[j + 1];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
115
|
+
const cur = new Array(m + 1).fill(false);
|
|
116
|
+
// cur[m] stays false: a path segment remains but the pattern is exhausted.
|
|
117
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
118
|
+
if (patSegs[j] === "**") {
|
|
119
|
+
// Consume this segment and keep `**` (next[j]), or match zero segments
|
|
120
|
+
// and move on (cur[j + 1]).
|
|
121
|
+
cur[j] = next[j] || cur[j + 1];
|
|
122
|
+
} else if (matchGlobSegment(pathSegs[i], patSegs[j], caseInsensitive)) {
|
|
123
|
+
cur[j] = next[j + 1];
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
next = cur;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return next[0];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function matchesGlob(filePath, globPattern, opts = {}) {
|
|
133
|
+
if (!filePath || !globPattern) return false;
|
|
134
|
+
const file = canonicalizePath(filePath);
|
|
135
|
+
const pattern = canonicalizePath(globPattern);
|
|
136
|
+
const caseInsensitive = Boolean(opts.caseInsensitive);
|
|
137
|
+
|
|
138
|
+
if (caseInsensitive ? file.toLowerCase() === pattern.toLowerCase() : file === pattern) return true;
|
|
139
|
+
|
|
140
|
+
return matchGlobSegments(file.split("/"), pattern.split("/"), caseInsensitive);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function isForbiddenPath(filePath, config = {}) {
|
|
144
|
+
const normFile = canonicalizePath(filePath);
|
|
145
|
+
const forbidden = config.scope?.deny || config.forbidden_paths || [];
|
|
146
|
+
return forbidden.some((pattern) => matchesGlob(normFile, pattern, { caseInsensitive: true }));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The builtin deny patterns that exist to keep credentials out of a diff, and
|
|
151
|
+
* the documented template filenames those patterns must not catch.
|
|
152
|
+
*
|
|
153
|
+
* The recursive dot-env glob is correct for `.env.local` and `.env.production`
|
|
154
|
+
* and wrong for `.env.example` — a file nearly every repository commits
|
|
155
|
+
* precisely so the environment can be documented without the values. Denying it
|
|
156
|
+
* meant no agent could ever be asked to document a new variable, in any project.
|
|
157
|
+
*
|
|
158
|
+
* The exemption is deliberately narrow. It applies only when one of the two
|
|
159
|
+
* *builtin* patterns matched: a repository that writes its own broader dot-env
|
|
160
|
+
* deny rule blocks templates too, because the pattern string is not one of
|
|
161
|
+
* these. And the diff secret scanner runs over every changed file regardless of
|
|
162
|
+
* scope, so a real credential pasted into a template still fails on exit 6.
|
|
163
|
+
*/
|
|
164
|
+
const BUILTIN_ENV_DENY_PATTERNS = new Set(["**/.env", "**/.env.*"]);
|
|
165
|
+
export const ENV_TEMPLATE_BASENAMES = new Set([
|
|
166
|
+
".env.example",
|
|
167
|
+
".env.sample",
|
|
168
|
+
".env.template",
|
|
169
|
+
".env.dist",
|
|
170
|
+
".env.defaults",
|
|
171
|
+
]);
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* True when a deny hit is the builtin credential rule catching a committed
|
|
175
|
+
* environment *template* rather than an environment file.
|
|
176
|
+
*
|
|
177
|
+
* @param {string} file - canonicalised repo-relative path
|
|
178
|
+
* @param {string} pattern - the deny pattern that matched
|
|
179
|
+
* @returns {boolean}
|
|
180
|
+
*/
|
|
181
|
+
export function isEnvTemplateException(file, pattern) {
|
|
182
|
+
if (!BUILTIN_ENV_DENY_PATTERNS.has(pattern)) return false;
|
|
183
|
+
const name = basename(file).toLowerCase();
|
|
184
|
+
if (ENV_TEMPLATE_BASENAMES.has(name)) return true;
|
|
185
|
+
// `.env.production.example`, `.env.test.sample`, ... — the documented-template
|
|
186
|
+
// suffix is what matters, not how many environment segments precede it.
|
|
187
|
+
return /^\.env\..+\.(example|sample|template|dist|defaults)$/.test(name);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function checkScope(files = [], scope = {}, opts = {}) {
|
|
191
|
+
const violations = [];
|
|
192
|
+
const deny = scope.deny || [];
|
|
193
|
+
const allow = scope.allow || [];
|
|
194
|
+
const protect = scope.protect || [];
|
|
195
|
+
|
|
196
|
+
for (const rawFile of files) {
|
|
197
|
+
// Canonicalised so that "./x", "a/../x" and "a//x" cannot present the same
|
|
198
|
+
// file under a spelling the deny patterns do not literally match.
|
|
199
|
+
const file = canonicalizePath(rawFile);
|
|
200
|
+
|
|
201
|
+
// A path that climbs out of the repository root can never be legitimate and
|
|
202
|
+
// must not be silently pattern-matched against repo-relative rules. This
|
|
203
|
+
// covers every spelling: POSIX absolute (`/etc/passwd`) and traversal
|
|
204
|
+
// (`../`, `..`), Windows drive-qualified and drive-relative (`C:\...`,
|
|
205
|
+
// `C:/...`, `C:foo`), and UNC (`\\server\share`, `//server/share`). The raw
|
|
206
|
+
// spelling is checked as well as the canonical one, because canonicalisation
|
|
207
|
+
// folds a leading `//` UNC into `/` and the check must fail on both.
|
|
208
|
+
if (
|
|
209
|
+
file === ".." ||
|
|
210
|
+
file.startsWith("../") ||
|
|
211
|
+
file.startsWith("/") ||
|
|
212
|
+
isWindowsAbsolutePath(rawFile) ||
|
|
213
|
+
isWindowsAbsolutePath(file)
|
|
214
|
+
) {
|
|
215
|
+
violations.push({ file, reason: "Path escapes the repository root", rule: "deny", pattern: "<traversal>" });
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Deny folds case: on macOS/Windows ".GitHub/" resolves to the same
|
|
220
|
+
// directory as ".github/", so a case-sensitive deny is bypassable there.
|
|
221
|
+
const matchedDeny = deny.find((pat) => matchesGlob(file, pat, { caseInsensitive: true }));
|
|
222
|
+
if (matchedDeny && !isEnvTemplateException(file, matchedDeny)) {
|
|
223
|
+
violations.push({ file, reason: `Forbidden path restriction matched pattern "${matchedDeny}"`, rule: "deny", pattern: matchedDeny });
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Allow stays case-sensitive on purpose: a case mismatch here yields "not
|
|
228
|
+
// allowed" (a violation), which is the fail-closed direction.
|
|
229
|
+
if (allow.length > 0) {
|
|
230
|
+
const isExplicitlyAllowed = allow.some((pat) => matchesGlob(file, pat));
|
|
231
|
+
if (!isExplicitlyAllowed) {
|
|
232
|
+
violations.push({ file, reason: "Path not included in allowed paths list", rule: "allow" });
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (!opts.allowProtected) {
|
|
238
|
+
const matchedProtect = protect.find((pat) => matchesGlob(file, pat, { caseInsensitive: true }));
|
|
239
|
+
if (matchedProtect) {
|
|
240
|
+
violations.push({ file, reason: `Protected file modification restriction matched pattern "${matchedProtect}"`, rule: "protect", pattern: matchedProtect });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
ok: violations.length === 0,
|
|
247
|
+
violations,
|
|
248
|
+
};
|
|
249
|
+
}
|