karajan-code 4.37.0 → 4.39.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/package.json +1 -1
- package/packages/ai-trash/src/destructive-parser.js +34 -5
- package/packages/ai-trash/src/hook.js +28 -6
- package/scripts/esbuild-sea.config.mjs +9 -0
- package/src/checks/method.js +1 -1
- package/src/checks/release-check.js +3 -1
- package/src/cli/register-meta.js +3 -1
- package/src/cli/register-pipeline.js +2 -1
- package/src/commands/rag.js +21 -1
- package/src/commands/review-gate.js +21 -22
- package/src/harden/harness-hooks.js +7 -85
- package/src/harden/hook-templates.js +6 -5
- package/src/harden/sentinel/sentinel-bash-write.mjs +106 -0
- package/src/harden/sentinel/sentinel-discard.mjs +125 -0
- package/src/harden/sentinel/sentinel-reminders.mjs +57 -0
- package/src/harden/sentinel/sentinel-shell.mjs +110 -0
- package/src/harden/sentinel/sessionstart.mjs +36 -0
- package/src/harden/sentinel-hooks.js +172 -177
- package/src/harden/supervisor-commit.js +10 -1
- package/src/policy/supervisor-verify.js +8 -4
- package/src/rag/migrate.js +20 -3
- package/src/review/card-first.js +3 -5
- package/src/review/policy-gate.js +4 -6
- package/src/review/sonar-pregate.js +3 -3
- package/src/review/tests-with-code.js +3 -6
- package/src/sonar/api.js +21 -0
- package/src/sonar/scanner.js +22 -3
package/package.json
CHANGED
|
@@ -35,14 +35,43 @@ function classifyMv(tokens) {
|
|
|
35
35
|
return { destructive: true, kind: "mv-overwrite", paths: positional.slice(-1), reason: "mv may overwrite destination" };
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// KJC-BUG-0240 (#1886): checkout and restore drop uncommitted work file by file,
|
|
39
|
+
// and a bundle keeps commits only, so the named files are kept as files and a
|
|
40
|
+
// discard of the whole tree marks it (worktree) for the hook to keep every dirty file.
|
|
41
|
+
function classifyDiscard(tokens, sub) {
|
|
42
|
+
const args = tokens.slice(2);
|
|
43
|
+
const has = (...flags) => flags.some((f) => args.includes(f));
|
|
44
|
+
const SAFE = { destructive: false, kind: "git-safe", paths: [] };
|
|
45
|
+
if (sub === "restore" && has("--staged", "-S") && !has("--worktree", "-W")) return { ...SAFE, reason: "restore --staged only unstages" };
|
|
46
|
+
// -B resets an existing branch: its commits need the bundle.
|
|
47
|
+
if (sub === "checkout" && has("-B")) return { destructive: true, kind: "git-checkout-reset-branch", bundle: true, paths: [], reason: "git checkout -B resets an existing branch" };
|
|
48
|
+
if (sub === "checkout" && has("-b", "--orphan")) return { ...SAFE, reason: "checkout -b creates a branch" };
|
|
49
|
+
const dd = args.indexOf("--");
|
|
50
|
+
const named = [];
|
|
51
|
+
let sourceValue = false;
|
|
52
|
+
args.forEach((a, i) => {
|
|
53
|
+
if (sourceValue) sourceValue = false;
|
|
54
|
+
else if (a === "-s" || a === "--source") sourceValue = true;
|
|
55
|
+
else if ((dd >= 0 && i > dd) || !a.startsWith("-")) named.push(a);
|
|
56
|
+
});
|
|
57
|
+
if (!named.length && !has("-f", "--force")) return { ...SAFE, reason: `${sub} with nothing to discard` };
|
|
58
|
+
const whole = has("-f", "--force") || named.includes(".") || named.includes(":/");
|
|
59
|
+
return { destructive: true, kind: `git-${sub}-discard`, paths: named.filter((p) => p !== "." && p !== ":/"), ...(whole ? { worktree: "tracked" } : {}), reason: `git ${sub} discards local edits` };
|
|
60
|
+
}
|
|
61
|
+
|
|
38
62
|
function classifyGit(tokens) {
|
|
39
63
|
const sub = tokens[1];
|
|
40
64
|
const has = (...flags) => flags.some((f) => tokens.includes(f));
|
|
41
|
-
if (sub === "reset" && has("--hard")) return { destructive: true, kind: "git-reset-hard", paths: [], reason: "git reset --hard drops working tree" };
|
|
42
|
-
if (sub === "clean" &&
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
65
|
+
if (sub === "reset" && has("--hard")) return { destructive: true, kind: "git-reset-hard", bundle: true, worktree: "tracked", paths: [], reason: "git reset --hard drops working tree" };
|
|
66
|
+
if (sub === "clean" && tokens.some((t) => t === "--force" || /^-[a-zA-Z]*f/.test(t))) {
|
|
67
|
+
// -x / -X also remove ignored files.
|
|
68
|
+
const ignored = tokens.some((t) => /^-[a-zA-Z]*[xX]/.test(t));
|
|
69
|
+
return { destructive: true, kind: "git-clean", bundle: true, worktree: ignored ? "untracked+ignored" : "untracked", paths: [], reason: "git clean -f removes untracked files" };
|
|
70
|
+
}
|
|
71
|
+
if (sub === "branch" && has("-D")) return { destructive: true, kind: "git-branch-D", bundle: true, paths: [], reason: "git branch -D drops unmerged branch" };
|
|
72
|
+
if (sub === "push" && has("--force", "-f", "--force-with-lease")) return { destructive: true, kind: "git-push-force", bundle: true, paths: [], reason: "git push --force rewrites remote history" };
|
|
73
|
+
if (sub === "checkout" || sub === "restore") return classifyDiscard(tokens, sub);
|
|
74
|
+
if (sub === "switch" && has("-f", "--force", "--discard-changes")) return { destructive: true, kind: "git-switch-discard", worktree: "tracked", paths: [], reason: "git switch --discard-changes drops local edits" };
|
|
46
75
|
return { destructive: false, kind: "git-safe", paths: [], reason: "git op without destructive flags" };
|
|
47
76
|
}
|
|
48
77
|
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// throws, deny the op so Claude never destroys without a recovery copy.
|
|
5
5
|
|
|
6
6
|
import { promises as fs } from "node:fs";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
7
8
|
import { isAbsolute, resolve } from "node:path";
|
|
8
9
|
import process from "node:process";
|
|
9
10
|
import { classifyCommand } from "./destructive-parser.js";
|
|
@@ -47,6 +48,29 @@ async function snapshotExistingPaths(root, paths, cwd, command) {
|
|
|
47
48
|
return snapshots;
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
// KJC-BUG-0240 (#1886): the dirty files a whole-tree discard would drop, kept as
|
|
52
|
+
// files (a bundle keeps commits only). `which`: "tracked" changes, "untracked"
|
|
53
|
+
// files, or "untracked+ignored" (git clean -x/-X removes ignored files too).
|
|
54
|
+
async function snapshotDirtyFiles(root, cwd, command, which) {
|
|
55
|
+
if (!cwd) return [];
|
|
56
|
+
const top = spawnSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
|
|
57
|
+
if (top.status !== 0) return [];
|
|
58
|
+
const withIgnored = which === "untracked+ignored";
|
|
59
|
+
// traditional + all: every file inside an ignored directory is listed one by one.
|
|
60
|
+
const st = spawnSync("git", ["-C", cwd, "status", "--porcelain", "-z", "--untracked-files=all", ...(withIgnored ? ["--ignored=traditional"] : [])], { encoding: "utf8" });
|
|
61
|
+
if (st.status !== 0) throw new Error(`git status failed: ${st.stderr.trim()}`);
|
|
62
|
+
const wanted = (rec) => (which === "tracked" ? !rec.startsWith("??") && !rec.startsWith("!!") : rec.startsWith("??") || (withIgnored && rec.startsWith("!!")));
|
|
63
|
+
const files = [];
|
|
64
|
+
let renameSource = false; // a rename or copy record is followed by its source path
|
|
65
|
+
for (const rec of st.stdout.split("\0")) {
|
|
66
|
+
if (renameSource) { renameSource = false; continue; }
|
|
67
|
+
if (rec.length < 4) continue;
|
|
68
|
+
if (wanted(rec)) files.push(rec.slice(3));
|
|
69
|
+
renameSource = "RC".includes(rec[0]);
|
|
70
|
+
}
|
|
71
|
+
return snapshotExistingPaths(root, files, top.stdout.trim(), command);
|
|
72
|
+
}
|
|
73
|
+
|
|
50
74
|
async function snapshotGitRepo(root, cwd, command, kind) {
|
|
51
75
|
if (!cwd) return { snapshots: [], skipped: "no cwd" };
|
|
52
76
|
try {
|
|
@@ -86,11 +110,9 @@ export async function handleHookPayload(payload, { root, stdin }) {
|
|
|
86
110
|
await assertOwnedByCurrentUser(root);
|
|
87
111
|
let snapshots = [];
|
|
88
112
|
let skipped = null;
|
|
89
|
-
if (verdict.kind
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
snapshots = await snapshotExistingPaths(root, verdict.paths, data.cwd, command);
|
|
93
|
-
}
|
|
113
|
+
if (verdict.bundle) ({ snapshots, skipped } = await snapshotGitRepo(root, data.cwd, command, verdict.kind));
|
|
114
|
+
snapshots.push(...(await snapshotExistingPaths(root, verdict.paths, data.cwd, command)));
|
|
115
|
+
if (verdict.worktree) snapshots.push(...(await snapshotDirtyFiles(root, data.cwd, command, verdict.worktree)));
|
|
94
116
|
if (snapshots.length) {
|
|
95
117
|
const m = await loadManifest(root);
|
|
96
118
|
for (const s of snapshots) m.entries.push(s);
|
|
@@ -106,7 +128,7 @@ export async function handleHookPayload(payload, { root, stdin }) {
|
|
|
106
128
|
if (snapshots.length) {
|
|
107
129
|
return reply(
|
|
108
130
|
"allow",
|
|
109
|
-
`ai-trash: snapshotted ${snapshots.length} ${
|
|
131
|
+
`ai-trash: snapshotted ${snapshots.length} ${snapshots.some((s) => s.type === "git-bundle") ? "item(s), git bundle included," : "path(s)"} before ${verdict.kind}`
|
|
110
132
|
);
|
|
111
133
|
}
|
|
112
134
|
return reply(
|
|
@@ -26,6 +26,15 @@ const seaTransformPlugin = {
|
|
|
26
26
|
let contents = await fs.readFile(args.path, "utf8");
|
|
27
27
|
let modified = false;
|
|
28
28
|
|
|
29
|
+
// KJC-TSK-0915 (ADR 0014): the Sentinel's guard modules are read as TEXT at
|
|
30
|
+
// load time, to be copied into a project's harness. A single-file bundle has
|
|
31
|
+
// no sibling files, so the text is inlined here, before import.meta.url goes.
|
|
32
|
+
const SENTINEL_READ = /readFileSync\(new URL\("(\.\/sentinel\/[\w-]+\.mjs)", import\.meta\.url\), "utf8"\)/g;
|
|
33
|
+
if (SENTINEL_READ.test(contents)) {
|
|
34
|
+
contents = contents.replace(SENTINEL_READ, (_m, rel) => JSON.stringify(readFileSync(path.resolve(path.dirname(args.path), rel), "utf8")));
|
|
35
|
+
modified = true;
|
|
36
|
+
}
|
|
37
|
+
|
|
29
38
|
// Replace import.meta.dirname with __dirname
|
|
30
39
|
if (contents.includes("import.meta.dirname")) {
|
|
31
40
|
contents = contents.replaceAll("import.meta.dirname", "__dirname");
|
package/src/checks/method.js
CHANGED
|
@@ -36,7 +36,7 @@ export async function collectMethodStats({ projectDir, run = runCommand, sample
|
|
|
36
36
|
const blocks = blocksRes.exitCode === 0
|
|
37
37
|
? blocksRes.stdout.split(/^@.*$/m).map((b) => b.split("\n").map((l) => l.trim()).filter(Boolean)).filter((b) => b.length > 0)
|
|
38
38
|
: [];
|
|
39
|
-
const offenders = blocks.filter((files) => checkTestsWithCode({ config: {}, stagedFiles: files
|
|
39
|
+
const offenders = blocks.filter((files) => checkTestsWithCode({ config: {}, stagedFiles: files }).mode !== "pass").length;
|
|
40
40
|
|
|
41
41
|
const verdicts = recentVerdicts(projectDir, sample);
|
|
42
42
|
const stamped = verdicts.filter((v) => v.workspace);
|
|
@@ -34,7 +34,9 @@ async function policyRangeCheck(projectDir) {
|
|
|
34
34
|
// protects the supervisor. `kj review` and `kj policy check` already lift
|
|
35
35
|
// what the sealed provenance backs; without this the release check blocked
|
|
36
36
|
// every version cut after a human `kj harden --commit`, by its own seal.
|
|
37
|
-
|
|
37
|
+
// KJC-BUG-0259: the history is judged on what is versioned (hooks, provenance);
|
|
38
|
+
// the machine-local guards were verified when the seal was reviewed.
|
|
39
|
+
const sup = liftSealedSupervisorViolations({ projectDir, violations: raw, trackedOnly: true });
|
|
38
40
|
const violations = sup.violations;
|
|
39
41
|
const hard = violations.filter((v) => v.enforcement === "deny");
|
|
40
42
|
const skipped = prScoped.size > 0 ? `; ${prScoped.size} diff-threshold invariant(s) skipped (PR-scoped)` : "";
|
package/src/cli/register-meta.js
CHANGED
|
@@ -30,7 +30,7 @@ import { envInstallCommand, briefCommand } from "../commands/env.js";
|
|
|
30
30
|
import { runReleaseCheck } from "../checks/release-check.js";
|
|
31
31
|
import { agentRunCommand } from "../commands/agent-run.js";
|
|
32
32
|
import { reportIssueCommand } from "../commands/report-issue.js";
|
|
33
|
-
import { huCommand } from "../commands/hu.js";
|
|
33
|
+
import { huCommand, HU_STATUSES } from "../commands/hu.js";
|
|
34
34
|
import { worktreeCommand } from "../commands/worktree.js";
|
|
35
35
|
import { addAdr, listAdrs } from "../environment/adr.js";
|
|
36
36
|
import { formatAdvancedIndex } from "../commands/advanced.js";
|
|
@@ -271,6 +271,8 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
271
271
|
});
|
|
272
272
|
});
|
|
273
273
|
hu.command("move <id> <status>")
|
|
274
|
+
// KJC-BUG-0257: the valid states, said before the call fails.
|
|
275
|
+
.description(`Move a card; <status> is one of: ${HU_STATUSES.join(", ")}`)
|
|
274
276
|
.option("--json", "Machine-readable output")
|
|
275
277
|
.action(async (id, status, flags) => {
|
|
276
278
|
await withConfig(pkgVersion, "hu", flags, async ({ config }) => {
|
|
@@ -201,7 +201,8 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
201
201
|
program
|
|
202
202
|
.command("solomon")
|
|
203
203
|
.description("Ask a third AI to arbitrate a rejected review verdict (brain ≠ reviewer ≠ solomon)")
|
|
204
|
-
.
|
|
204
|
+
.option("--position <text>", "Why the brain disagrees with the reviewer")
|
|
205
|
+
.option("--position-file <path>", "Read the position from a file (no quoting to get right)")
|
|
205
206
|
.option("--range <range>", "Arbitrate a git range instead of the staged diff")
|
|
206
207
|
.action(async (flags) => {
|
|
207
208
|
await withConfig(pkgVersion, "solomon", flags, async ({ config, logger }) => {
|
package/src/commands/rag.js
CHANGED
|
@@ -14,7 +14,7 @@ import { installPostMergeHook, maybeAutoUpdate } from "../rag/auto-update.js";
|
|
|
14
14
|
import { indexLibrary, LIBRARY_PROJECT } from "../rag/library.js";
|
|
15
15
|
import { loadGoldenQueries, runEval } from "../rag/eval.js";
|
|
16
16
|
import { getKarajanHome } from "../utils/paths.js";
|
|
17
|
-
import { countProjectChunks, emptyIndexRemedy, migrateProjectIndex } from "../rag/migrate.js";
|
|
17
|
+
import { countProjectChunks, countProjectSources, emptyIndexRemedy, migrateProjectIndex } from "../rag/migrate.js";
|
|
18
18
|
import { openLibraryStore, openProjectStore, projectDbPath } from "../rag/project-store.js";
|
|
19
19
|
|
|
20
20
|
// KJC-TSK-0882 (ADR 0011): el indice es del proyecto, no de la maquina.
|
|
@@ -153,6 +153,11 @@ export async function ragQueryCommand({ text, config, logger, flags = {} }) {
|
|
|
153
153
|
if (flags.json) process.stdout.write(`${JSON.stringify({ hits: [], empty: true, topK, scope, remedy })}\n`);
|
|
154
154
|
return [];
|
|
155
155
|
}
|
|
156
|
+
// KJC-BUG-0258: chunks of plans and briefs but none of the project's own files
|
|
157
|
+
// answer nothing about the code; said now, not when kj review --staged blocks.
|
|
158
|
+
if (!library && project && countProjectSources(db, project, config?.projectDir || process.cwd()) === 0) {
|
|
159
|
+
logger.warn("[rag] this project's index holds none of its own files (only plans or briefs): run kj rag index --with-sources");
|
|
160
|
+
}
|
|
156
161
|
const mode = flags.mode || "hybrid";
|
|
157
162
|
const alpha = Math.max(0, Math.min(1, Number(flags.alpha) || 0.6));
|
|
158
163
|
// Safe by grammar: parseWhere (core where-parser.js) accepts ONLY
|
|
@@ -234,6 +239,19 @@ export async function ragCoversCommand({ file, config, flags = {} }) {
|
|
|
234
239
|
}
|
|
235
240
|
}
|
|
236
241
|
|
|
242
|
+
/**
|
|
243
|
+
* KJC-BUG-0255: what `kj rag migrate` keeps. The project's own files pass the
|
|
244
|
+
* indexer's criterion of today; sources outside the tree (plans, onboarding)
|
|
245
|
+
* are kept as they are.
|
|
246
|
+
*/
|
|
247
|
+
export function migrateKeep(projectDir, config) {
|
|
248
|
+
const exclude = ragExclude(config);
|
|
249
|
+
return (source) => {
|
|
250
|
+
const rel = relative(projectDir, source);
|
|
251
|
+
return rel.startsWith("..") || isAbsolute(rel) || indexableReason(rel, source, { exclude, projectDir }) === null;
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
237
255
|
/**
|
|
238
256
|
* KJC-TSK-0888 (RAG-P1a, ADR 0011) — `kj rag migrate`: copia los chunks de este
|
|
239
257
|
* proyecto desde la base global a su propio indice, con sus embeddings, sin
|
|
@@ -241,11 +259,13 @@ export async function ragCoversCommand({ file, config, flags = {} }) {
|
|
|
241
259
|
*/
|
|
242
260
|
export async function ragMigrateCommand({ config, flags = {} }) {
|
|
243
261
|
const projectDir = config?.projectDir || process.cwd();
|
|
262
|
+
const keep = migrateKeep(projectDir, config);
|
|
244
263
|
const res = migrateProjectIndex({
|
|
245
264
|
slug: projectSlug(projectDir),
|
|
246
265
|
legacyPath: join(getKarajanHome(), "rag.db"),
|
|
247
266
|
targetPath: projectDbPath(projectDir),
|
|
248
267
|
dim: config?.rag?.embedder?.dim || 768,
|
|
268
|
+
keep,
|
|
249
269
|
});
|
|
250
270
|
if (flags.json) process.stdout.write(`${JSON.stringify(res)}\n`);
|
|
251
271
|
else process.stdout.write(`rag migrate: ${res.state} — ${res.reason}\n`);
|
|
@@ -42,7 +42,6 @@ async function enforceCardFirst({ config, projectDir }) {
|
|
|
42
42
|
// KJC-TSK-0732: the verification LEVEL is part of the verdict — a pass on
|
|
43
43
|
// branch-ref alone must never read as a tracker-verified pass.
|
|
44
44
|
if (card.note) console.log(`⚠ card-first: ${card.note}`);
|
|
45
|
-
if (card.mode === "exempt" && card.reason.includes("KJ_ALLOW_NO_CARD")) console.log(`⚠ card-first exempt: ${card.reason}`);
|
|
46
45
|
if (!card.ok) {
|
|
47
46
|
console.log(`✗ card-first gate: ${card.reason}`);
|
|
48
47
|
process.exitCode = 1;
|
|
@@ -139,6 +138,14 @@ function printVerdict(record) {
|
|
|
139
138
|
*/
|
|
140
139
|
export async function solomonCommand({ config, logger = null, flags = {} }) {
|
|
141
140
|
const projectDir = config?.projectDir || process.cwd();
|
|
141
|
+
// KJC-BUG-0260: a position is prose with blanks and slashes; from a file it
|
|
142
|
+
// needs no quoting the lane guard has to tell apart from a path.
|
|
143
|
+
const position = flags.positionFile ? readFileSync(flags.positionFile, "utf8").trim() : flags.position;
|
|
144
|
+
if (!position) {
|
|
145
|
+
console.log("✗ kj solomon: give the brain's position with --position <text> or --position-file <path>");
|
|
146
|
+
process.exitCode = 1;
|
|
147
|
+
return { ruling: "reject", reasoning: "no position given" };
|
|
148
|
+
}
|
|
142
149
|
const diff = await rawDiff(flags.range);
|
|
143
150
|
// KJC-TSK-0734 (PL-B): un hallazgo de policy de clase seguridad no es
|
|
144
151
|
// arbitrable — solomon se niega antes de gastar un token. Y una policy
|
|
@@ -162,7 +169,7 @@ export async function solomonCommand({ config, logger = null, flags = {} }) {
|
|
|
162
169
|
process.exitCode = 1;
|
|
163
170
|
return { ruling: "reject", reasoning: "security-class policy denial — not arbitrable" };
|
|
164
171
|
}
|
|
165
|
-
const res = await runSolomonArbitration({ diff, position
|
|
172
|
+
const res = await runSolomonArbitration({ diff, position, config, logger, projectDir });
|
|
166
173
|
if (res.ruling === "approve") {
|
|
167
174
|
console.log(`⚖ Solomon (${res.solomon}) rules for the brain — verdict recorded, the gate is open.`);
|
|
168
175
|
} else {
|
|
@@ -256,10 +263,9 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
256
263
|
// partitioning is a decision, and it must never split code from its tests.
|
|
257
264
|
const split = testAdded > 0 ? ` (${added - testAdded} source + ${testAdded} accompanying tests — partition by feature, never code from its tests)` : "";
|
|
258
265
|
const sizePolicy = config?.method_gates?.pr_size || "warn";
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const reason = `${added} lines added${split} exceeds the ${sizeWarn}-line budget (${sizeSource}; method_gates.pr_size: block) — partition the work, or get your user's explicit OK and re-run with KJ_ALLOW_LARGE_PR=1`;
|
|
266
|
+
// ADR 0015: no env escape — an exception is the user's label on the PR, judged in CI.
|
|
267
|
+
if (sizePolicy === "block") {
|
|
268
|
+
const reason = `${added} lines added${split} exceeds the ${sizeWarn}-line budget (${sizeSource}; method_gates.pr_size: block) — partition the work; an exception is your user's large-pr-justified label on the PR`;
|
|
263
269
|
console.log(`✗ pr-size gate: ${reason}`);
|
|
264
270
|
process.exitCode = 1;
|
|
265
271
|
return { verdict: "rejected", reviewer: "pr-size", issues: [{ severity: "high", description: reason }] };
|
|
@@ -269,7 +275,7 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
269
275
|
}
|
|
270
276
|
}
|
|
271
277
|
// KJC-TSK-0705 (PV-B): the outbound privacy boundary at commit time.
|
|
272
|
-
// Denylist data rejects (
|
|
278
|
+
// Denylist data rejects with no escape (ADR 0015: false positives go in the allow list); generic PII
|
|
273
279
|
// warns (privacy.generic: "block" hardens). Added lines only — deletions
|
|
274
280
|
// don't publish. In the SEA binary the privacy module is stubbed and
|
|
275
281
|
// throws: the gate degrades with a note instead of crashing.
|
|
@@ -288,25 +294,19 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
288
294
|
for (const f of warns) console.log(`⚠ privacy: [${f.type}] ${f.source}:${f.line} → ${f.masked} — personal data? move it out before it ships`);
|
|
289
295
|
const hardened = config?.privacy?.generic === "block" && warns.length > 0;
|
|
290
296
|
if (blocks.length > 0 || hardened) {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
console.log(`✗ privacy gate: ${reason}`);
|
|
297
|
-
process.exitCode = 1;
|
|
298
|
-
return { verdict: "rejected", reviewer: "privacy", issues: [{ severity: "high", description: reason }] };
|
|
299
|
-
}
|
|
297
|
+
for (const f of blocks) console.log(`✗ privacy: [${f.type}] ${f.source}:${f.line} → ${f.masked}`);
|
|
298
|
+
const reason = `${blocks.length || warns.length} personal-data finding(s) in the staged diff — this must not reach the repo (a confirmed false positive goes in the allow list of ~/.karajan/privacy.yml)`;
|
|
299
|
+
console.log(`✗ privacy gate: ${reason}`);
|
|
300
|
+
process.exitCode = 1;
|
|
301
|
+
return { verdict: "rejected", reviewer: "privacy", issues: [{ severity: "high", description: reason }] };
|
|
300
302
|
}
|
|
301
303
|
} catch (err) {
|
|
302
304
|
// Known degradation: the SEA stub throws its install-from-npm message.
|
|
303
305
|
// Anything else fails CLOSED — a silent skip would defeat the guarantee.
|
|
304
306
|
if (/standalone binary/i.test(err?.message || "")) {
|
|
305
307
|
console.log("⚠ privacy gate unavailable in this build — skipping");
|
|
306
|
-
} else if (process.env.KJ_ALLOW_PII === "1") {
|
|
307
|
-
console.log(`⚠ privacy gate errored (${err.message}) — KJ_ALLOW_PII=1 (explicit escape hatch)`);
|
|
308
308
|
} else {
|
|
309
|
-
const reason = `privacy gate error: ${err.message} — refusing to pass the boundary unchecked
|
|
309
|
+
const reason = `privacy gate error: ${err.message} — refusing to pass the boundary unchecked`;
|
|
310
310
|
console.log(`✗ ${reason}`);
|
|
311
311
|
process.exitCode = 1;
|
|
312
312
|
return { verdict: "rejected", reviewer: "privacy", issues: [{ severity: "high", description: reason }] };
|
|
@@ -330,9 +330,8 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
330
330
|
|
|
331
331
|
// KJC-TSK-0734 (PL-B): la policy declarativa como gate determinista, en
|
|
332
332
|
// --staged Y en --check — el pre-commit hereda los dientes sin regenerar
|
|
333
|
-
// hooks. enforcement=warn avisa; deny cierra
|
|
334
|
-
//
|
|
335
|
-
// del diff); class=security cierra sin escape y sin arbitraje.
|
|
333
|
+
// hooks. enforcement=warn avisa; deny cierra sin excepción por diff (ADR
|
|
334
|
+
// 0015); class=security cierra además sin arbitraje.
|
|
336
335
|
// KJC-BUG-0208: el mismo modulo que decide el presupuesto decide el metrico
|
|
337
336
|
// del invariante. Sumar aqui el numstat entero daba dos cifras para la misma
|
|
338
337
|
// regla en dos lineas seguidas del mismo comando.
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* Field case 2026-08-02: text rules were not enough — the environment must
|
|
4
4
|
* impose them AT TOOL TIME. `kj harden` writes a PreToolUse script and wires
|
|
5
5
|
* it into the project's `.claude/settings.json` (merged, never clobbered):
|
|
6
|
-
* Write over an existing file blocks ("use Edit",
|
|
7
|
-
* Bash
|
|
6
|
+
* Write over an existing file blocks ("use Edit"), with no escape (ADR 0015).
|
|
7
|
+
* Writes through Bash are the Sentinel's bash-write guard (KJC-BUG-0237), which
|
|
8
|
+
* replaced the JSON-reserialization guard that lived here.
|
|
8
9
|
* Claude-only v1 — the abstraction arrives with the second host that
|
|
9
10
|
* supports tool hooks.
|
|
10
11
|
*/
|
|
@@ -19,94 +20,15 @@ const SCRIPT_BODY = `#!/usr/bin/env node
|
|
|
19
20
|
// so a gate bug never bricks the session.
|
|
20
21
|
import console from "node:console";
|
|
21
22
|
import process from "node:process";
|
|
22
|
-
import { existsSync
|
|
23
|
-
import { dirname, join, relative, resolve } from "node:path";
|
|
24
|
-
import { fileURLToPath } from "node:url";
|
|
25
|
-
// El guard vive en <repo>/.karajan/harness/, asi que el arbol es dos arriba.
|
|
26
|
-
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
23
|
+
import { existsSync } from "node:fs";
|
|
27
24
|
let raw = "";
|
|
28
25
|
process.stdin.on("data", (d) => { raw += d; });
|
|
29
26
|
process.stdin.on("end", () => {
|
|
30
27
|
try {
|
|
31
28
|
const { tool_name: tool, tool_input: input = {} } = JSON.parse(raw);
|
|
32
|
-
if (tool === "Write" &&
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
process.exit(2);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
if (tool === "Bash") {
|
|
39
|
-
const cmd = String(input.command || "");
|
|
40
|
-
// KJC-BUG-0227: el escape tambien viaja en el PROPIO comando, como en el
|
|
41
|
-
// resto de guardias. Mirando solo process.env el agente no tenia salida:
|
|
42
|
-
// un export dentro del comando jamas llega a este proceso.
|
|
43
|
-
// Solo cuentan las asignaciones DEL PRINCIPIO, que son las que el shell
|
|
44
|
-
// aplica al comando. Buscarla en cualquier parte del texto dejaba pasar
|
|
45
|
-
// un "echo KJ_ALLOW_REWRITE=1; python3 ..." (catch de la review).
|
|
46
|
-
const toks = cmd.trim().split(/\\s+/);
|
|
47
|
-
let i = 0;
|
|
48
|
-
let named = false;
|
|
49
|
-
while (i < toks.length && /^[A-Z][A-Z0-9_]*=/.test(toks[i])) {
|
|
50
|
-
if (toks[i] === "KJ_ALLOW_REWRITE=1") named = true;
|
|
51
|
-
i += 1;
|
|
52
|
-
}
|
|
53
|
-
// Una asignacion delante solo alcanza a SU comando, asi que el escape del
|
|
54
|
-
// comando vale unicamente en un comando SIMPLE: en "VAR=1 true && python"
|
|
55
|
-
// o "VAR=1 && python" el python nunca la recibe (catch de la review). Lo
|
|
56
|
-
// que va entre comillas no encadena nada, asi que no cuenta.
|
|
57
|
-
// Tampoco es simple si hay sustitucion: el proceso de dentro de $( ) no
|
|
58
|
-
// hereda la asignacion (catch de la review). Mismo criterio que el resto
|
|
59
|
-
// de guardias del Sentinel.
|
|
60
|
-
// Dentro de comillas SIMPLES todo es literal; dentro de dobles, un ; no
|
|
61
|
-
// encadena pero $( ) si ejecuta. Por eso se miran dos cosas distintas.
|
|
62
|
-
const noSingle = cmd.replaceAll(/'[^']*'/g, "");
|
|
63
|
-
const substitutes = noSingle.includes("$(") || noSingle.includes("\\u0060");
|
|
64
|
-
const bare = noSingle.replaceAll(/"[^"]*"/g, "");
|
|
65
|
-
const simple = !substitutes && !/[;&|]/.test(bare) && !bare.includes("\\n");
|
|
66
|
-
const cmdEscape = named && simple && i < toks.length;
|
|
67
|
-
const escaped = process.env.KJ_ALLOW_REWRITE === "1" || cmdEscape;
|
|
68
|
-
// Un escape ignorado EN SILENCIO era el bug: si esta puesto y no vale, se dice.
|
|
69
|
-
if (named && !cmdEscape) console.error("kj tool gate: KJ_ALLOW_REWRITE=1 presente pero IGNORADO — solo vale delante del comando y en uno simple (sin ; | & fuera de comillas).");
|
|
70
|
-
const writes = /open\\s*\\([^)]*["'][wa]["']|>\\s*\\S+\\.json\\b/.test(cmd);
|
|
71
|
-
// KJC-BUG-0227: y solo se vigila lo que esta DENTRO del arbol. Un fichero
|
|
72
|
-
// temporal del scratchpad no es asunto de este guard, y bloquearlo
|
|
73
|
-
// ensenaba a rodearlo. Sin ruta reconocible se vigila: el lado seguro.
|
|
74
|
-
const targets = cmd.match(/[\\w./-]+\\.json\\b/g) || [];
|
|
75
|
-
// Solo se exime lo que se puede establecer CON CERTEZA: una ruta absoluta
|
|
76
|
-
// fuera del arbol. Una relativa depende del cwd del shell, que este hook
|
|
77
|
-
// no conoce — "cd packages && ... open(\\"../package.json\\")" escribe
|
|
78
|
-
// dentro del repo (catch de la review), asi que se sigue vigilando.
|
|
79
|
-
// Un enlace puede apuntar dentro: /tmp/link.json escribiria en el repo
|
|
80
|
-
// (catch de la review). Se resuelve el padre real antes de clasificar, y
|
|
81
|
-
// si no se puede resolver, se vigila.
|
|
82
|
-
// Fuera del arbol es SALIR de el, no que el nombre empiece por dos puntos:
|
|
83
|
-
// <repo>/..config.json esta dentro (catch de la review).
|
|
84
|
-
const escapesRoot = (rel) => rel === ".." || rel.startsWith("../");
|
|
85
|
-
const outsideForSure = (t) => {
|
|
86
|
-
if (!t.startsWith("/")) return false;
|
|
87
|
-
const abs = resolve(t);
|
|
88
|
-
try {
|
|
89
|
-
const root = realpathSync(ROOT);
|
|
90
|
-
// El propio fichero puede SER el enlace (/tmp/link.json -> repo/cfg.json),
|
|
91
|
-
// asi que se resuelve entero cuando existe; si aun no existe, manda su
|
|
92
|
-
// directorio real.
|
|
93
|
-
if (existsSync(abs)) return escapesRoot(relative(root, realpathSync(abs)));
|
|
94
|
-
const parts = abs.split("/");
|
|
95
|
-
const name = parts.pop();
|
|
96
|
-
return escapesRoot(relative(root, realpathSync(parts.join("/") || "/") + "/" + name));
|
|
97
|
-
} catch {
|
|
98
|
-
return false;
|
|
99
|
-
}
|
|
100
|
-
};
|
|
101
|
-
// Con expansion del shell no se sabe a donde apunta nada: "$PWD/cfg.json"
|
|
102
|
-
// deja el trozo "/cfg.json", que parece absoluto y ajeno sin serlo (catch
|
|
103
|
-
// de la review). Ante la duda se vigila.
|
|
104
|
-
const expands = /[$~]/.test(cmd) || cmd.includes("\\u0060");
|
|
105
|
-
const mine = expands || targets.length === 0 || !targets.every(outsideForSure);
|
|
106
|
-
if (!escaped && mine && /json\\.dumps?\\s*\\(/.test(cmd) && writes) {
|
|
107
|
-
console.error("kj tool gate: reserializing a whole JSON file makes the diff unreviewable — make targeted edits instead (KJ_ALLOW_REWRITE=1 to override consciously).");
|
|
108
|
-
process.exit(2);
|
|
109
|
-
}
|
|
29
|
+
if (tool === "Write" && input.file_path && existsSync(input.file_path)) {
|
|
30
|
+
console.error("kj tool gate: Write over an EXISTING file destroys unseen changes — use Edit for targeted changes.");
|
|
31
|
+
process.exit(2);
|
|
110
32
|
}
|
|
111
33
|
} catch { /* fail open */ }
|
|
112
34
|
process.exit(0);
|
|
@@ -62,10 +62,11 @@ function baseBranchGuard(baseBranch) {
|
|
|
62
62
|
return [
|
|
63
63
|
"# Branch-first guard — the base branch only moves via PR. KJC-BUG-0186:",
|
|
64
64
|
"# the bootstrap commit is exempt, there is no commit to branch from yet.",
|
|
65
|
-
|
|
65
|
+
"# No escape (ADR 0015): an env prefix on git commit was one the agent could raise.",
|
|
66
|
+
"if git rev-parse --verify HEAD >/dev/null 2>&1; then",
|
|
66
67
|
' current_branch=$(git symbolic-ref --short HEAD 2>/dev/null || echo "")',
|
|
67
68
|
` if [ "$current_branch" = "${baseBranch}" ]; then`,
|
|
68
|
-
` echo 'kj harden: direct commits on ${baseBranch} are not allowed — create a branch and open a PR
|
|
69
|
+
` echo 'kj harden: direct commits on ${baseBranch} are not allowed — create a branch and open a PR'; exit 1`,
|
|
69
70
|
" fi",
|
|
70
71
|
"fi",
|
|
71
72
|
];
|
|
@@ -78,7 +79,7 @@ function baseBranchGuard(baseBranch) {
|
|
|
78
79
|
function identityGuard(hook) {
|
|
79
80
|
const head = [
|
|
80
81
|
"# Identity lock (IDN-C, ADR 0005) — this clone's declared identity governs.",
|
|
81
|
-
|
|
82
|
+
"if [ -f .karajan/identity.local.yml ]; then",
|
|
82
83
|
];
|
|
83
84
|
const tail = [
|
|
84
85
|
"elif [ ! -f .karajan/identity.local.yml ]; then",
|
|
@@ -92,7 +93,7 @@ function identityGuard(hook) {
|
|
|
92
93
|
" kj_author=$(git var GIT_AUTHOR_IDENT | sed 's/.*<\\(.*\\)>.*/\\1/')",
|
|
93
94
|
" kj_committer=$(git var GIT_COMMITTER_IDENT | sed 's/.*<\\(.*\\)>.*/\\1/')",
|
|
94
95
|
' if [ -n "$kj_declared_email" ] && { [ "$kj_author" != "$kj_declared_email" ] || [ "$kj_committer" != "$kj_declared_email" ]; }; then',
|
|
95
|
-
' echo "kj harden: identity lock — committing as $kj_author / $kj_committer but this clone is declared as $kj_declared_email (kj identity show
|
|
96
|
+
' echo "kj harden: identity lock — committing as $kj_author / $kj_committer but this clone is declared as $kj_declared_email (kj identity show)"; exit 1',
|
|
96
97
|
" fi",
|
|
97
98
|
...tail,
|
|
98
99
|
];
|
|
@@ -103,7 +104,7 @@ function identityGuard(hook) {
|
|
|
103
104
|
' kj_hosts="${GH_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/gh}/hosts.yml"',
|
|
104
105
|
" kj_active_gh=$(awk '/^github.com:/{f=1;next} f&&/^[^[:space:]]/{f=0} f&&/^[[:space:]]*user:/{sub(/^[[:space:]]*user:[[:space:]]*/,\"\");print;exit}' \"$kj_hosts\" 2>/dev/null)",
|
|
105
106
|
' if [ -n "$kj_declared_gh" ] && [ "$kj_active_gh" != "$kj_declared_gh" ]; then',
|
|
106
|
-
' echo "kj harden: identity lock — gh session is ${kj_active_gh:-none} but this clone is declared as $kj_declared_gh — run: gh auth switch --user $kj_declared_gh
|
|
107
|
+
' echo "kj harden: identity lock — gh session is ${kj_active_gh:-none} but this clone is declared as $kj_declared_gh — run: gh auth switch --user $kj_declared_gh"; exit 1',
|
|
107
108
|
" fi",
|
|
108
109
|
...tail,
|
|
109
110
|
];
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// The Sentinel's bash-write guard (KJC-BUG-0237, moved here by KJC-TSK-0919,
|
|
2
|
+
// ADR 0014). Inside the repo, files are written through Edit/Write only, the path
|
|
3
|
+
// every gate guards. Copied byte for byte into .karajan/harness; `root` injected.
|
|
4
|
+
// The stated limit: a program you run can write files; only a sandbox stops that.
|
|
5
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
8
|
+
import { headIndex, shellSegments } from "./sentinel-shell.mjs";
|
|
9
|
+
|
|
10
|
+
const WRITES = ["tee", "touch", "truncate", "cp", "mv", "install", "ln", "sed", "perl", "dd", "sh", "bash", "zsh", "dash", "eval"];
|
|
11
|
+
const RUNNERS = ["xargs", "find", "node", "python3", "python", "ruby", "php", "deno", "bun"];
|
|
12
|
+
// touch/truncate -s SIZE, -r REF, -d DATE, -t STAMP: the value is not a file it writes.
|
|
13
|
+
const VALUED = ["-s", "--size", "-r", "--reference", "-d", "--date", "-t"];
|
|
14
|
+
|
|
15
|
+
/** Redirection targets: >&N and >&- duplicate or close a descriptor; >& FILE and >&FILE write FILE. */
|
|
16
|
+
const redirections = (words) => {
|
|
17
|
+
const out = [];
|
|
18
|
+
words.forEach((w, k) => {
|
|
19
|
+
const m = /^\d*(>>|>[|]|>)(.*)$/.exec(w);
|
|
20
|
+
if (!m) return;
|
|
21
|
+
const dup = m[2].startsWith("&") ? m[2].slice(1) : null;
|
|
22
|
+
if (dup === null) out.push(m[2] || words[k + 1]);
|
|
23
|
+
else if (dup === "") out.push(words[k + 1]);
|
|
24
|
+
else if (!/^\d*-?$/.test(dup)) out.push(dup);
|
|
25
|
+
});
|
|
26
|
+
return out;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Destination of cp/mv/install/ln: -t DIR / --target-directory[=]DIR, else the last plain word. */
|
|
30
|
+
const destination = (rest, plain) => {
|
|
31
|
+
const t = rest.findIndex((w) => w === "-t" || w === "--target-directory");
|
|
32
|
+
const tEq = rest.find((w) => w.startsWith("--target-directory="));
|
|
33
|
+
if (t >= 0) return [rest[t + 1]];
|
|
34
|
+
if (tEq) return [tEq.slice(19)];
|
|
35
|
+
return plain.length >= 2 ? [plain.at(-1)] : [];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Files sed/perl -i edit: the script is the word after -e/-f, else the first plain word; a lone plain word is the file. */
|
|
39
|
+
const inPlace = (rest, plain) => {
|
|
40
|
+
if (!rest.some((w) => w.startsWith("--in-place") || /^-[a-zA-Z0-9]*i/.test(w))) return [];
|
|
41
|
+
const script = rest.findIndex((w) => ["-e", "-f", "--expression"].includes(w));
|
|
42
|
+
if (script >= 0) return plain.filter((w) => w !== rest[script + 1]);
|
|
43
|
+
return plain.length === 1 ? plain : plain.slice(1);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The files a simple command (a word list) writes from the shell. "$(...)"
|
|
48
|
+
* marks a target that only exists at run time: unknowable, so writesRepo denies it.
|
|
49
|
+
* @param {string[]} words
|
|
50
|
+
* @returns {string[]}
|
|
51
|
+
*/
|
|
52
|
+
export const shellWrites = (words) => {
|
|
53
|
+
const out = redirections(words);
|
|
54
|
+
const i = headIndex(words, [...WRITES, ...RUNNERS]);
|
|
55
|
+
const head = (words[i] || "").split("/").at(-1);
|
|
56
|
+
// xargs / find -exec run a writer on targets that arrive at run time.
|
|
57
|
+
if (["xargs", "find"].includes(head) && words.slice(i + 1).some((w) => WRITES.includes(w.split("/").at(-1)))) out.push(`$(${head})`);
|
|
58
|
+
// Arguments without redirections (< << <<< > >> and a detached operand).
|
|
59
|
+
const rest = [];
|
|
60
|
+
for (let k = i + 1; k < words.length; k++) {
|
|
61
|
+
if (!/^\d*[<>]/.test(words[k])) rest.push(words[k]);
|
|
62
|
+
else if (/^\d*(<{1,3}|>>?|>[|&])$/.test(words[k])) k++;
|
|
63
|
+
}
|
|
64
|
+
// Operands: words that are not options, and EVERY word after "--" (touch -- -file).
|
|
65
|
+
const cut = rest.includes("--") ? rest.indexOf("--") : rest.length;
|
|
66
|
+
const plain = [...rest.slice(0, cut).filter((w) => !w.startsWith("-")), ...rest.slice(cut + 1)];
|
|
67
|
+
if (head === "tee") out.push(...plain);
|
|
68
|
+
if (head === "dd") out.push(...rest.filter((w) => w.startsWith("of=")).map((w) => w.slice(3)));
|
|
69
|
+
// By position, not value (touch -d today today writes "today").
|
|
70
|
+
if (head === "touch" || head === "truncate") out.push(...rest.filter((w, k) => k > cut || (k < cut && !w.startsWith("-") && !VALUED.includes(rest[k - 1]))));
|
|
71
|
+
// sh -c / eval run a script of their own: its writes are this command's writes.
|
|
72
|
+
if (["sh", "bash", "zsh", "dash"].includes(head) && rest.includes("-c")) for (const seg of shellSegments(rest[rest.indexOf("-c") + 1] || "")) out.push(...shellWrites(seg));
|
|
73
|
+
if (head === "eval") for (const seg of shellSegments(rest.join(" "))) out.push(...shellWrites(seg));
|
|
74
|
+
// An inline script (node -e, python -c...) that names a write API.
|
|
75
|
+
const inline = rest[rest.findIndex((w) => ["-e", "-c", "--eval", "-p", "-r"].includes(w)) + 1] || "";
|
|
76
|
+
if (/^(node|deno|bun|python[\d.]*|ruby|perl|php)$/.test(head) && /write|append|open|copy|rename|truncate|unlink|mkdir|symlink/i.test(inline)) out.push(`$(${head})`);
|
|
77
|
+
if (["cp", "mv", "install", "ln"].includes(head)) out.push(...destination(rest, plain));
|
|
78
|
+
if (head === "sed" || head === "perl") out.push(...inPlace(rest, plain));
|
|
79
|
+
return out.filter(Boolean);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Inside the repo, or unknowable ($VAR, backtick, ~user): both deny. /dev/* and
|
|
84
|
+
* descriptor duplication (&1) are outside; ~/ is the home, where the repo may live.
|
|
85
|
+
* Real paths: a link outside (/tmp/link -> repo) points in.
|
|
86
|
+
* @param {string} t
|
|
87
|
+
* @param {string} root
|
|
88
|
+
*/
|
|
89
|
+
export const writesRepo = (t, root) => {
|
|
90
|
+
if (t.includes("$") || t.includes("`")) return true;
|
|
91
|
+
if (t.startsWith("&") || t.startsWith("/dev/")) return false;
|
|
92
|
+
if (t.startsWith("~") && t !== "~" && !t.startsWith("~/")) return true;
|
|
93
|
+
let a = resolve(root, t.startsWith("~") ? homedir() + t.slice(1) : t);
|
|
94
|
+
let tail = "";
|
|
95
|
+
while (!existsSync(a) && dirname(a) !== a) {
|
|
96
|
+
tail = join(a.slice(dirname(a).length + 1), tail);
|
|
97
|
+
a = dirname(a);
|
|
98
|
+
}
|
|
99
|
+
let rel;
|
|
100
|
+
try {
|
|
101
|
+
rel = relative(realpathSync(root), join(realpathSync(a), tail));
|
|
102
|
+
} catch {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
return !rel.startsWith("..") && !rel.startsWith("/");
|
|
106
|
+
};
|