@hasna/hooks 0.6.0 → 0.6.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/bin/index.js +94 -36
- package/bin/serve.js +34 -5
- package/dist/index.js +51 -13
- package/dist/lib/resolve.d.ts +8 -0
- package/dist/lib/secure-compare.d.ts +8 -0
- package/hooks/hook-workspace-repos-guard/README.md +63 -0
- package/hooks/hook-workspace-repos-guard/package.json +12 -0
- package/hooks/hook-workspace-repos-guard/src/hook.test.ts +312 -0
- package/hooks/hook-workspace-repos-guard/src/hook.ts +352 -0
- package/hooks/hook-workspace-repos-guard/tsconfig.json +21 -0
- package/package.json +1 -1
package/bin/index.js
CHANGED
|
@@ -1955,6 +1955,16 @@ var init_registry = __esm(() => {
|
|
|
1955
1955
|
matcher: "^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch|mcp__.*)$",
|
|
1956
1956
|
tags: ["git", "worktree", "repos", "multi-agent", "safety", "dangerous-ops"]
|
|
1957
1957
|
},
|
|
1958
|
+
{
|
|
1959
|
+
name: "workspace-repos-guard",
|
|
1960
|
+
displayName: "Workspace Repos Guard",
|
|
1961
|
+
description: "Blocks writes and deletes that break the ~/workspace/repos structure: writes to the repos root, top-level entry creation, non-org folders, and any delete under repos",
|
|
1962
|
+
version: "0.1.0",
|
|
1963
|
+
category: "Git Safety",
|
|
1964
|
+
event: "PreToolUse",
|
|
1965
|
+
matcher: "^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch)$",
|
|
1966
|
+
tags: ["workspace", "repos", "structure", "guard", "safety", "orgs", "multi-agent"]
|
|
1967
|
+
},
|
|
1958
1968
|
{
|
|
1959
1969
|
name: "checktests",
|
|
1960
1970
|
displayName: "Check Tests",
|
|
@@ -4278,10 +4288,18 @@ __export(exports_resolve, {
|
|
|
4278
4288
|
resolveScriptPath: () => resolveScriptPath,
|
|
4279
4289
|
resolveHookMeta: () => resolveHookMeta,
|
|
4280
4290
|
resolveHookDir: () => resolveHookDir,
|
|
4281
|
-
resolveHook: () => resolveHook
|
|
4291
|
+
resolveHook: () => resolveHook,
|
|
4292
|
+
resolveBundledHooksDir: () => resolveBundledHooksDir
|
|
4282
4293
|
});
|
|
4283
|
-
import { join as join4 } from "path";
|
|
4294
|
+
import { dirname as dirname2, join as join4 } from "path";
|
|
4284
4295
|
import { existsSync as existsSync4 } from "fs";
|
|
4296
|
+
import { fileURLToPath } from "url";
|
|
4297
|
+
function resolveBundledHooksDir(moduleDir = dirname2(fileURLToPath(import.meta.url))) {
|
|
4298
|
+
const sourceLayout = join4(moduleDir, "..", "..", "hooks");
|
|
4299
|
+
if (existsSync4(join4(sourceLayout, "hook-gitguard")))
|
|
4300
|
+
return sourceLayout;
|
|
4301
|
+
return join4(moduleDir, "..", "hooks");
|
|
4302
|
+
}
|
|
4285
4303
|
function resolveHookDir(name) {
|
|
4286
4304
|
const custom = readCustomManifest(name);
|
|
4287
4305
|
if (custom)
|
|
@@ -4290,7 +4308,7 @@ function resolveHookDir(name) {
|
|
|
4290
4308
|
if (!meta)
|
|
4291
4309
|
return;
|
|
4292
4310
|
const short = shortManifestName(name);
|
|
4293
|
-
const bundledBase =
|
|
4311
|
+
const bundledBase = resolveBundledHooksDir();
|
|
4294
4312
|
const direct = join4(bundledBase, short);
|
|
4295
4313
|
if (existsSync4(direct))
|
|
4296
4314
|
return direct;
|
|
@@ -4353,7 +4371,6 @@ function resolveHook(name) {
|
|
|
4353
4371
|
meta
|
|
4354
4372
|
};
|
|
4355
4373
|
}
|
|
4356
|
-
var __dirname = "/home/hasna/.hasna/repos/worktrees/apps/1ec6ba31-cdbc-42ea-8b1e-705e4198abbf/apps/hooks/src/lib";
|
|
4357
4374
|
var init_resolve = __esm(() => {
|
|
4358
4375
|
init_registry();
|
|
4359
4376
|
init_manifest();
|
|
@@ -4361,9 +4378,9 @@ var init_resolve = __esm(() => {
|
|
|
4361
4378
|
|
|
4362
4379
|
// src/lib/installer.ts
|
|
4363
4380
|
import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
4364
|
-
import { join as join5, dirname as
|
|
4381
|
+
import { join as join5, dirname as dirname3 } from "path";
|
|
4365
4382
|
import { homedir as homedir3 } from "os";
|
|
4366
|
-
import { fileURLToPath } from "url";
|
|
4383
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
4367
4384
|
function normalizeHookName(name) {
|
|
4368
4385
|
return name.startsWith("hook-") ? name : `hook-${name}`;
|
|
4369
4386
|
}
|
|
@@ -4428,7 +4445,7 @@ function readSettings(scope = "global", target = "claude") {
|
|
|
4428
4445
|
}
|
|
4429
4446
|
function writeSettings(settings, scope = "global", target = "claude") {
|
|
4430
4447
|
const path = getSettingsPath(scope, target);
|
|
4431
|
-
const dir =
|
|
4448
|
+
const dir = dirname3(path);
|
|
4432
4449
|
if (!existsSync5(dir)) {
|
|
4433
4450
|
mkdirSync3(dir, { recursive: true });
|
|
4434
4451
|
}
|
|
@@ -4525,7 +4542,7 @@ function readCodewithConfig(scope, configPath) {
|
|
|
4525
4542
|
}
|
|
4526
4543
|
function appendCodewithFragment(fragment, scope, configPath) {
|
|
4527
4544
|
const path = getSettingsPath(scope, "codewith", configPath);
|
|
4528
|
-
const dir =
|
|
4545
|
+
const dir = dirname3(path);
|
|
4529
4546
|
if (!existsSync5(dir))
|
|
4530
4547
|
mkdirSync3(dir, { recursive: true });
|
|
4531
4548
|
const existing = existsSync5(path) ? readFileSync3(path, "utf-8") : "";
|
|
@@ -4776,7 +4793,7 @@ var __dirname2, HOOKS_DIR, EVENT_MAP, getInstalledHooks;
|
|
|
4776
4793
|
var init_installer = __esm(() => {
|
|
4777
4794
|
init_registry();
|
|
4778
4795
|
init_resolve();
|
|
4779
|
-
__dirname2 =
|
|
4796
|
+
__dirname2 = dirname3(fileURLToPath2(import.meta.url));
|
|
4780
4797
|
HOOKS_DIR = existsSync5(join5(__dirname2, "..", "..", "hooks", "hook-gitguard")) ? join5(__dirname2, "..", "..", "hooks") : join5(__dirname2, "..", "hooks");
|
|
4781
4798
|
EVENT_MAP = {
|
|
4782
4799
|
claude: {
|
|
@@ -4813,6 +4830,35 @@ var init_installer = __esm(() => {
|
|
|
4813
4830
|
getInstalledHooks = getRegisteredHooks;
|
|
4814
4831
|
});
|
|
4815
4832
|
|
|
4833
|
+
// src/lib/registration.ts
|
|
4834
|
+
function safeMatcherTest(pattern, value) {
|
|
4835
|
+
try {
|
|
4836
|
+
return new RegExp(pattern).test(value);
|
|
4837
|
+
} catch {
|
|
4838
|
+
return false;
|
|
4839
|
+
}
|
|
4840
|
+
}
|
|
4841
|
+
function hookRegisteredInSettings(settings, name, eventSpec, hookMatcher) {
|
|
4842
|
+
const separator = eventSpec.indexOf(":");
|
|
4843
|
+
const eventName = separator === -1 ? eventSpec : eventSpec.slice(0, separator);
|
|
4844
|
+
const specMatcher = separator === -1 ? "" : eventSpec.slice(separator + 1);
|
|
4845
|
+
const matcher = specMatcher || hookMatcher || "";
|
|
4846
|
+
const hooks = settings.hooks?.[eventName];
|
|
4847
|
+
if (!Array.isArray(hooks))
|
|
4848
|
+
return false;
|
|
4849
|
+
return hooks.some((entry) => entry?.hooks?.some((h) => {
|
|
4850
|
+
const match = h?.command?.match(/^hooks run ([\w-]+)/);
|
|
4851
|
+
if (!match || match[1] !== name)
|
|
4852
|
+
return false;
|
|
4853
|
+
if (!matcher)
|
|
4854
|
+
return true;
|
|
4855
|
+
const entryMatcher = typeof entry.matcher === "string" ? entry.matcher : "";
|
|
4856
|
+
if (!entryMatcher)
|
|
4857
|
+
return true;
|
|
4858
|
+
return entryMatcher === matcher || safeMatcherTest(entryMatcher, matcher) || safeMatcherTest(matcher, entryMatcher);
|
|
4859
|
+
}));
|
|
4860
|
+
}
|
|
4861
|
+
|
|
4816
4862
|
// src/lib/profiles.ts
|
|
4817
4863
|
import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4, readdirSync as readdirSync2, rmSync, cpSync } from "fs";
|
|
4818
4864
|
import { join as join6 } from "path";
|
|
@@ -5450,14 +5496,14 @@ __export(exports_run, {
|
|
|
5450
5496
|
});
|
|
5451
5497
|
import { randomBytes } from "crypto";
|
|
5452
5498
|
import { rmSync as rmSync2, writeFileSync as writeFileSync6 } from "fs";
|
|
5453
|
-
import { basename as basename2, dirname as
|
|
5499
|
+
import { basename as basename2, dirname as dirname4, isAbsolute as isAbsolute2, join as join9 } from "path";
|
|
5454
5500
|
function extensionOf(scriptPath) {
|
|
5455
5501
|
const base = basename2(scriptPath);
|
|
5456
5502
|
const dot = base.lastIndexOf(".");
|
|
5457
5503
|
return dot > 0 ? base.slice(dot + 1) : "ts";
|
|
5458
5504
|
}
|
|
5459
5505
|
async function executeVerifiedScript(options) {
|
|
5460
|
-
const scriptDir =
|
|
5506
|
+
const scriptDir = dirname4(options.scriptPath);
|
|
5461
5507
|
const tempPath = join9(scriptDir, `.hooks-verified-${randomBytes(12).toString("hex")}.${extensionOf(options.scriptPath)}`);
|
|
5462
5508
|
try {
|
|
5463
5509
|
writeFileSync6(tempPath, options.content, { flag: "wx", mode: 384 });
|
|
@@ -6026,6 +6072,19 @@ var init_storage = __esm(() => {
|
|
|
6026
6072
|
init_pg_migrations();
|
|
6027
6073
|
});
|
|
6028
6074
|
|
|
6075
|
+
// src/lib/secure-compare.ts
|
|
6076
|
+
function secureEqual(a, b) {
|
|
6077
|
+
const enc = new TextEncoder;
|
|
6078
|
+
const ab = enc.encode(a);
|
|
6079
|
+
const bb = enc.encode(b);
|
|
6080
|
+
let diff = ab.length ^ bb.length;
|
|
6081
|
+
const n = Math.max(ab.length, bb.length);
|
|
6082
|
+
for (let i = 0;i < n; i++) {
|
|
6083
|
+
diff |= (ab[i] ?? 0) ^ (bb[i] ?? 0);
|
|
6084
|
+
}
|
|
6085
|
+
return diff === 0;
|
|
6086
|
+
}
|
|
6087
|
+
|
|
6029
6088
|
// src/serve.ts
|
|
6030
6089
|
var exports_serve = {};
|
|
6031
6090
|
__export(exports_serve, {
|
|
@@ -6087,8 +6146,8 @@ function authorized(req, apiKey) {
|
|
|
6087
6146
|
return false;
|
|
6088
6147
|
const header = req.headers.get("authorization") ?? "";
|
|
6089
6148
|
if (header.startsWith("Bearer "))
|
|
6090
|
-
return header.slice("Bearer ".length)
|
|
6091
|
-
return req.headers.get("x-api-key")
|
|
6149
|
+
return secureEqual(header.slice("Bearer ".length), apiKey);
|
|
6150
|
+
return secureEqual(req.headers.get("x-api-key") ?? "", apiKey);
|
|
6092
6151
|
}
|
|
6093
6152
|
function json(body, status = 200, headers = {}) {
|
|
6094
6153
|
return new Response(JSON.stringify(body), {
|
|
@@ -6190,10 +6249,17 @@ function collectBundledCatalog() {
|
|
|
6190
6249
|
return out;
|
|
6191
6250
|
}
|
|
6192
6251
|
async function fetchJson(base, path) {
|
|
6252
|
+
const headers = { accept: "application/json" };
|
|
6253
|
+
const apiKey = resolveApiKey();
|
|
6254
|
+
if (apiKey)
|
|
6255
|
+
headers["x-api-key"] = apiKey;
|
|
6193
6256
|
const res = await fetch(`${base}${path}`, {
|
|
6194
|
-
headers
|
|
6257
|
+
headers,
|
|
6195
6258
|
signal: AbortSignal.timeout(15000)
|
|
6196
6259
|
});
|
|
6260
|
+
if (res.status === 401) {
|
|
6261
|
+
throw new Error("registry requires API key \u2014 set HASNA_HOOKS_API_KEY or HOOKS_API_KEY (vault-delivered, never stored)");
|
|
6262
|
+
}
|
|
6197
6263
|
if (!res.ok) {
|
|
6198
6264
|
throw new Error(`GET ${path} failed with status ${res.status}`);
|
|
6199
6265
|
}
|
|
@@ -6408,8 +6474,8 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
6408
6474
|
import { z as z2 } from "zod";
|
|
6409
6475
|
import { createServer } from "http";
|
|
6410
6476
|
import { existsSync as existsSync11, readFileSync as readFileSync9 } from "fs";
|
|
6411
|
-
import { join as join11, dirname as
|
|
6412
|
-
import { fileURLToPath as
|
|
6477
|
+
import { join as join11, dirname as dirname5 } from "path";
|
|
6478
|
+
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
6413
6479
|
async function runVerifiedHook(name, scriptPath, stdin, timeoutMs) {
|
|
6414
6480
|
const content = readFileSync9(scriptPath);
|
|
6415
6481
|
const check = checkScriptHash(name, sha256Of(content));
|
|
@@ -6584,13 +6650,9 @@ function createHooksServer() {
|
|
|
6584
6650
|
if (meta && settingsExist) {
|
|
6585
6651
|
try {
|
|
6586
6652
|
const settings = JSON.parse(readFileSync9(settingsPath, "utf-8"));
|
|
6587
|
-
|
|
6588
|
-
|
|
6589
|
-
|
|
6590
|
-
return match && match[1] === name;
|
|
6591
|
-
}));
|
|
6592
|
-
if (!found) {
|
|
6593
|
-
issues.push({ hook: name, issue: `Not registered under correct event (${meta.event})`, severity: "error" });
|
|
6653
|
+
if (!hookRegisteredInSettings(settings, name, meta.event, meta.matcher)) {
|
|
6654
|
+
const eventName = meta.event.split(":")[0];
|
|
6655
|
+
issues.push({ hook: name, issue: `Not registered under correct event (${eventName})`, severity: "error" });
|
|
6594
6656
|
hookHealthy = false;
|
|
6595
6657
|
}
|
|
6596
6658
|
} catch {}
|
|
@@ -6947,8 +7009,8 @@ function createHooksServer() {
|
|
|
6947
7009
|
disabled.push(name);
|
|
6948
7010
|
settings.hooks.__disabled = disabled;
|
|
6949
7011
|
const { writeFileSync: writeFileSync7, mkdirSync: mkdirSync8 } = await import("fs");
|
|
6950
|
-
const { dirname:
|
|
6951
|
-
mkdirSync8(
|
|
7012
|
+
const { dirname: dirname6 } = await import("path");
|
|
7013
|
+
mkdirSync8(dirname6(settingsPath), { recursive: true });
|
|
6952
7014
|
writeFileSync7(settingsPath, JSON.stringify(settings, null, 2) + `
|
|
6953
7015
|
`);
|
|
6954
7016
|
return { content: [{ type: "text", text: JSON.stringify({ hook: name, disabled: true, scope }) }] };
|
|
@@ -7280,7 +7342,7 @@ var init_server = __esm(() => {
|
|
|
7280
7342
|
init_resolve();
|
|
7281
7343
|
init_store();
|
|
7282
7344
|
init_storage();
|
|
7283
|
-
__dirname3 =
|
|
7345
|
+
__dirname3 = dirname5(fileURLToPath3(import.meta.url));
|
|
7284
7346
|
pkg = { name: "@hasna/hooks", version: "0.0.0" };
|
|
7285
7347
|
try {
|
|
7286
7348
|
for (const rel of ["../../package.json", "../package.json", "../../../package.json"]) {
|
|
@@ -8557,8 +8619,8 @@ var {
|
|
|
8557
8619
|
// src/cli/index.tsx
|
|
8558
8620
|
import chalk2 from "chalk";
|
|
8559
8621
|
import { existsSync as existsSync12, readFileSync as readFileSync10 } from "fs";
|
|
8560
|
-
import { join as join12, dirname as
|
|
8561
|
-
import { fileURLToPath as
|
|
8622
|
+
import { join as join12, dirname as dirname6 } from "path";
|
|
8623
|
+
import { fileURLToPath as fileURLToPath4 } from "url";
|
|
8562
8624
|
|
|
8563
8625
|
// src/cli/components/App.tsx
|
|
8564
8626
|
import { useState as useState7 } from "react";
|
|
@@ -9736,7 +9798,7 @@ init_profiles();
|
|
|
9736
9798
|
init_manifest();
|
|
9737
9799
|
init_resolve();
|
|
9738
9800
|
import { jsxDEV as jsxDEV8 } from "react/jsx-dev-runtime";
|
|
9739
|
-
var __dirname4 =
|
|
9801
|
+
var __dirname4 = dirname6(fileURLToPath4(import.meta.url));
|
|
9740
9802
|
var pkgPath = existsSync12(join12(__dirname4, "..", "package.json")) ? join12(__dirname4, "..", "package.json") : join12(__dirname4, "..", "..", "package.json");
|
|
9741
9803
|
var pkg2 = JSON.parse(readFileSync10(pkgPath, "utf-8"));
|
|
9742
9804
|
var program2 = new Command;
|
|
@@ -10302,13 +10364,9 @@ program2.command("doctor").option("-g, --global", "Check global settings", false
|
|
|
10302
10364
|
if (meta && settingsExist) {
|
|
10303
10365
|
try {
|
|
10304
10366
|
const settings = JSON.parse(readFileSync10(settingsPath, "utf-8"));
|
|
10305
|
-
|
|
10306
|
-
|
|
10307
|
-
|
|
10308
|
-
return match && match[1] === name;
|
|
10309
|
-
}));
|
|
10310
|
-
if (!found) {
|
|
10311
|
-
issues.push({ hook: name, issue: `Not registered under correct event (${meta.event})`, severity: "error" });
|
|
10367
|
+
if (!hookRegisteredInSettings(settings, name, meta.event, meta.matcher)) {
|
|
10368
|
+
const eventName = meta.event.split(":")[0];
|
|
10369
|
+
issues.push({ hook: name, issue: `Not registered under correct event (${eventName})`, severity: "error" });
|
|
10312
10370
|
hookHealthy = false;
|
|
10313
10371
|
}
|
|
10314
10372
|
} catch {}
|
package/bin/serve.js
CHANGED
|
@@ -57,6 +57,16 @@ var HOOKS = [
|
|
|
57
57
|
matcher: "^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch|mcp__.*)$",
|
|
58
58
|
tags: ["git", "worktree", "repos", "multi-agent", "safety", "dangerous-ops"]
|
|
59
59
|
},
|
|
60
|
+
{
|
|
61
|
+
name: "workspace-repos-guard",
|
|
62
|
+
displayName: "Workspace Repos Guard",
|
|
63
|
+
description: "Blocks writes and deletes that break the ~/workspace/repos structure: writes to the repos root, top-level entry creation, non-org folders, and any delete under repos",
|
|
64
|
+
version: "0.1.0",
|
|
65
|
+
category: "Git Safety",
|
|
66
|
+
event: "PreToolUse",
|
|
67
|
+
matcher: "^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch)$",
|
|
68
|
+
tags: ["workspace", "repos", "structure", "guard", "safety", "orgs", "multi-agent"]
|
|
69
|
+
},
|
|
60
70
|
{
|
|
61
71
|
name: "checktests",
|
|
62
72
|
displayName: "Check Tests",
|
|
@@ -5041,9 +5051,15 @@ function retrustHook(name, scriptPath, version, source) {
|
|
|
5041
5051
|
}
|
|
5042
5052
|
|
|
5043
5053
|
// src/lib/resolve.ts
|
|
5044
|
-
import { join as join5 } from "path";
|
|
5054
|
+
import { dirname as dirname2, join as join5 } from "path";
|
|
5045
5055
|
import { existsSync as existsSync5 } from "fs";
|
|
5046
|
-
|
|
5056
|
+
import { fileURLToPath } from "url";
|
|
5057
|
+
function resolveBundledHooksDir(moduleDir = dirname2(fileURLToPath(import.meta.url))) {
|
|
5058
|
+
const sourceLayout = join5(moduleDir, "..", "..", "hooks");
|
|
5059
|
+
if (existsSync5(join5(sourceLayout, "hook-gitguard")))
|
|
5060
|
+
return sourceLayout;
|
|
5061
|
+
return join5(moduleDir, "..", "hooks");
|
|
5062
|
+
}
|
|
5047
5063
|
function resolveHookDir(name) {
|
|
5048
5064
|
const custom2 = readCustomManifest(name);
|
|
5049
5065
|
if (custom2)
|
|
@@ -5052,7 +5068,7 @@ function resolveHookDir(name) {
|
|
|
5052
5068
|
if (!meta)
|
|
5053
5069
|
return;
|
|
5054
5070
|
const short = shortManifestName(name);
|
|
5055
|
-
const bundledBase =
|
|
5071
|
+
const bundledBase = resolveBundledHooksDir();
|
|
5056
5072
|
const direct = join5(bundledBase, short);
|
|
5057
5073
|
if (existsSync5(direct))
|
|
5058
5074
|
return direct;
|
|
@@ -5116,6 +5132,19 @@ function resolveHook(name) {
|
|
|
5116
5132
|
};
|
|
5117
5133
|
}
|
|
5118
5134
|
|
|
5135
|
+
// src/lib/secure-compare.ts
|
|
5136
|
+
function secureEqual(a, b) {
|
|
5137
|
+
const enc = new TextEncoder;
|
|
5138
|
+
const ab = enc.encode(a);
|
|
5139
|
+
const bb = enc.encode(b);
|
|
5140
|
+
let diff = ab.length ^ bb.length;
|
|
5141
|
+
const n = Math.max(ab.length, bb.length);
|
|
5142
|
+
for (let i = 0;i < n; i++) {
|
|
5143
|
+
diff |= (ab[i] ?? 0) ^ (bb[i] ?? 0);
|
|
5144
|
+
}
|
|
5145
|
+
return diff === 0;
|
|
5146
|
+
}
|
|
5147
|
+
|
|
5119
5148
|
// src/serve.ts
|
|
5120
5149
|
var DEFAULT_SERVE_PORT = 39428;
|
|
5121
5150
|
var SERVE_HOST = "127.0.0.1";
|
|
@@ -5172,8 +5201,8 @@ function authorized(req, apiKey) {
|
|
|
5172
5201
|
return false;
|
|
5173
5202
|
const header = req.headers.get("authorization") ?? "";
|
|
5174
5203
|
if (header.startsWith("Bearer "))
|
|
5175
|
-
return header.slice("Bearer ".length)
|
|
5176
|
-
return req.headers.get("x-api-key")
|
|
5204
|
+
return secureEqual(header.slice("Bearer ".length), apiKey);
|
|
5205
|
+
return secureEqual(req.headers.get("x-api-key") ?? "", apiKey);
|
|
5177
5206
|
}
|
|
5178
5207
|
function json(body, status = 200, headers = {}) {
|
|
5179
5208
|
return new Response(JSON.stringify(body), {
|
package/dist/index.js
CHANGED
|
@@ -4849,14 +4849,14 @@ __export(exports_run, {
|
|
|
4849
4849
|
});
|
|
4850
4850
|
import { randomBytes } from "crypto";
|
|
4851
4851
|
import { rmSync as rmSync3, writeFileSync as writeFileSync6 } from "fs";
|
|
4852
|
-
import { basename as basename2, dirname as
|
|
4852
|
+
import { basename as basename2, dirname as dirname4, isAbsolute as isAbsolute2, join as join9 } from "path";
|
|
4853
4853
|
function extensionOf(scriptPath) {
|
|
4854
4854
|
const base = basename2(scriptPath);
|
|
4855
4855
|
const dot = base.lastIndexOf(".");
|
|
4856
4856
|
return dot > 0 ? base.slice(dot + 1) : "ts";
|
|
4857
4857
|
}
|
|
4858
4858
|
async function executeVerifiedScript(options) {
|
|
4859
|
-
const scriptDir =
|
|
4859
|
+
const scriptDir = dirname4(options.scriptPath);
|
|
4860
4860
|
const tempPath = join9(scriptDir, `.hooks-verified-${randomBytes(12).toString("hex")}.${extensionOf(options.scriptPath)}`);
|
|
4861
4861
|
try {
|
|
4862
4862
|
writeFileSync6(tempPath, options.content, { flag: "wx", mode: 384 });
|
|
@@ -5336,6 +5336,16 @@ var HOOKS = [
|
|
|
5336
5336
|
matcher: "^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch|mcp__.*)$",
|
|
5337
5337
|
tags: ["git", "worktree", "repos", "multi-agent", "safety", "dangerous-ops"]
|
|
5338
5338
|
},
|
|
5339
|
+
{
|
|
5340
|
+
name: "workspace-repos-guard",
|
|
5341
|
+
displayName: "Workspace Repos Guard",
|
|
5342
|
+
description: "Blocks writes and deletes that break the ~/workspace/repos structure: writes to the repos root, top-level entry creation, non-org folders, and any delete under repos",
|
|
5343
|
+
version: "0.1.0",
|
|
5344
|
+
category: "Git Safety",
|
|
5345
|
+
event: "PreToolUse",
|
|
5346
|
+
matcher: "^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch)$",
|
|
5347
|
+
tags: ["workspace", "repos", "structure", "guard", "safety", "orgs", "multi-agent"]
|
|
5348
|
+
},
|
|
5339
5349
|
{
|
|
5340
5350
|
name: "checktests",
|
|
5341
5351
|
displayName: "Check Tests",
|
|
@@ -5803,15 +5813,21 @@ function getHook(name) {
|
|
|
5803
5813
|
}
|
|
5804
5814
|
// src/lib/installer.ts
|
|
5805
5815
|
import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
|
|
5806
|
-
import { join as join6, dirname as
|
|
5816
|
+
import { join as join6, dirname as dirname3 } from "path";
|
|
5807
5817
|
import { homedir as homedir4 } from "os";
|
|
5808
|
-
import { fileURLToPath } from "url";
|
|
5818
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
5809
5819
|
|
|
5810
5820
|
// src/lib/resolve.ts
|
|
5811
|
-
import { join as join5 } from "path";
|
|
5821
|
+
import { dirname as dirname2, join as join5 } from "path";
|
|
5812
5822
|
import { existsSync as existsSync5 } from "fs";
|
|
5823
|
+
import { fileURLToPath } from "url";
|
|
5813
5824
|
init_manifest();
|
|
5814
|
-
|
|
5825
|
+
function resolveBundledHooksDir(moduleDir = dirname2(fileURLToPath(import.meta.url))) {
|
|
5826
|
+
const sourceLayout = join5(moduleDir, "..", "..", "hooks");
|
|
5827
|
+
if (existsSync5(join5(sourceLayout, "hook-gitguard")))
|
|
5828
|
+
return sourceLayout;
|
|
5829
|
+
return join5(moduleDir, "..", "hooks");
|
|
5830
|
+
}
|
|
5815
5831
|
function resolveHookDir(name) {
|
|
5816
5832
|
const custom2 = readCustomManifest(name);
|
|
5817
5833
|
if (custom2)
|
|
@@ -5820,7 +5836,7 @@ function resolveHookDir(name) {
|
|
|
5820
5836
|
if (!meta)
|
|
5821
5837
|
return;
|
|
5822
5838
|
const short = shortManifestName(name);
|
|
5823
|
-
const bundledBase =
|
|
5839
|
+
const bundledBase = resolveBundledHooksDir();
|
|
5824
5840
|
const direct = join5(bundledBase, short);
|
|
5825
5841
|
if (existsSync5(direct))
|
|
5826
5842
|
return direct;
|
|
@@ -5885,7 +5901,7 @@ function resolveHook(name) {
|
|
|
5885
5901
|
}
|
|
5886
5902
|
|
|
5887
5903
|
// src/lib/installer.ts
|
|
5888
|
-
var __dirname2 =
|
|
5904
|
+
var __dirname2 = dirname3(fileURLToPath2(import.meta.url));
|
|
5889
5905
|
var HOOKS_DIR = existsSync6(join6(__dirname2, "..", "..", "hooks", "hook-gitguard")) ? join6(__dirname2, "..", "..", "hooks") : join6(__dirname2, "..", "hooks");
|
|
5890
5906
|
function normalizeHookName(name) {
|
|
5891
5907
|
return name.startsWith("hook-") ? name : `hook-${name}`;
|
|
@@ -5983,7 +5999,7 @@ function readSettings(scope = "global", target = "claude") {
|
|
|
5983
5999
|
}
|
|
5984
6000
|
function writeSettings(settings, scope = "global", target = "claude") {
|
|
5985
6001
|
const path = getSettingsPath(scope, target);
|
|
5986
|
-
const dir =
|
|
6002
|
+
const dir = dirname3(path);
|
|
5987
6003
|
if (!existsSync6(dir)) {
|
|
5988
6004
|
mkdirSync4(dir, { recursive: true });
|
|
5989
6005
|
}
|
|
@@ -6080,7 +6096,7 @@ function readCodewithConfig(scope, configPath) {
|
|
|
6080
6096
|
}
|
|
6081
6097
|
function appendCodewithFragment(fragment, scope, configPath) {
|
|
6082
6098
|
const path = getSettingsPath(scope, "codewith", configPath);
|
|
6083
|
-
const dir =
|
|
6099
|
+
const dir = dirname3(path);
|
|
6084
6100
|
if (!existsSync6(dir))
|
|
6085
6101
|
mkdirSync4(dir, { recursive: true });
|
|
6086
6102
|
const existing = existsSync6(path) ? readFileSync4(path, "utf-8") : "";
|
|
@@ -6466,10 +6482,17 @@ function collectBundledCatalog() {
|
|
|
6466
6482
|
return out;
|
|
6467
6483
|
}
|
|
6468
6484
|
async function fetchJson(base, path) {
|
|
6485
|
+
const headers = { accept: "application/json" };
|
|
6486
|
+
const apiKey = resolveApiKey();
|
|
6487
|
+
if (apiKey)
|
|
6488
|
+
headers["x-api-key"] = apiKey;
|
|
6469
6489
|
const res = await fetch(`${base}${path}`, {
|
|
6470
|
-
headers
|
|
6490
|
+
headers,
|
|
6471
6491
|
signal: AbortSignal.timeout(15000)
|
|
6472
6492
|
});
|
|
6493
|
+
if (res.status === 401) {
|
|
6494
|
+
throw new Error("registry requires API key \u2014 set HASNA_HOOKS_API_KEY or HOOKS_API_KEY (vault-delivered, never stored)");
|
|
6495
|
+
}
|
|
6473
6496
|
if (!res.ok) {
|
|
6474
6497
|
throw new Error(`GET ${path} failed with status ${res.status}`);
|
|
6475
6498
|
}
|
|
@@ -6584,6 +6607,21 @@ async function syncHooks(options = {}) {
|
|
|
6584
6607
|
init_manifest();
|
|
6585
6608
|
init_store();
|
|
6586
6609
|
init_config();
|
|
6610
|
+
|
|
6611
|
+
// src/lib/secure-compare.ts
|
|
6612
|
+
function secureEqual(a, b) {
|
|
6613
|
+
const enc = new TextEncoder;
|
|
6614
|
+
const ab = enc.encode(a);
|
|
6615
|
+
const bb = enc.encode(b);
|
|
6616
|
+
let diff = ab.length ^ bb.length;
|
|
6617
|
+
const n = Math.max(ab.length, bb.length);
|
|
6618
|
+
for (let i = 0;i < n; i++) {
|
|
6619
|
+
diff |= (ab[i] ?? 0) ^ (bb[i] ?? 0);
|
|
6620
|
+
}
|
|
6621
|
+
return diff === 0;
|
|
6622
|
+
}
|
|
6623
|
+
|
|
6624
|
+
// src/serve.ts
|
|
6587
6625
|
var DEFAULT_SERVE_PORT = 39428;
|
|
6588
6626
|
var SERVE_HOST = "127.0.0.1";
|
|
6589
6627
|
var SERVE_SERVICE_NAME = "hooks-registry";
|
|
@@ -6639,8 +6677,8 @@ function authorized(req, apiKey) {
|
|
|
6639
6677
|
return false;
|
|
6640
6678
|
const header = req.headers.get("authorization") ?? "";
|
|
6641
6679
|
if (header.startsWith("Bearer "))
|
|
6642
|
-
return header.slice("Bearer ".length)
|
|
6643
|
-
return req.headers.get("x-api-key")
|
|
6680
|
+
return secureEqual(header.slice("Bearer ".length), apiKey);
|
|
6681
|
+
return secureEqual(req.headers.get("x-api-key") ?? "", apiKey);
|
|
6644
6682
|
}
|
|
6645
6683
|
function json(body, status = 200, headers = {}) {
|
|
6646
6684
|
return new Response(JSON.stringify(body), {
|
package/dist/lib/resolve.d.ts
CHANGED
|
@@ -13,6 +13,14 @@ export interface ResolvedHook {
|
|
|
13
13
|
scriptPath: string;
|
|
14
14
|
meta: HookMeta;
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Locate the bundled hooks directory at runtime from the executing module's
|
|
18
|
+
* own location. A bare `__dirname` is baked to the builder's path at bundle
|
|
19
|
+
* time, so bundled hooks resolve only on the machine that built the package
|
|
20
|
+
* (0.6.0/0.6.1 regression); `import.meta.url` is preserved at runtime by the
|
|
21
|
+
* bun build target, so resolution follows the installed package instead.
|
|
22
|
+
*/
|
|
23
|
+
export declare function resolveBundledHooksDir(moduleDir?: string): string;
|
|
16
24
|
export declare function resolveHookDir(name: string): string | undefined;
|
|
17
25
|
export declare function resolveScriptPath(name: string): string | undefined;
|
|
18
26
|
export declare function resolveHookMeta(name: string): HookMeta | undefined;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constant-time string comparison for API-key checks.
|
|
3
|
+
*
|
|
4
|
+
* The loop always runs over the longer input and the result accumulates
|
|
5
|
+
* XORs, so the timing does not reveal where or whether the values differ.
|
|
6
|
+
* Available on both the bun/node and workerd runtimes.
|
|
7
|
+
*/
|
|
8
|
+
export declare function secureEqual(a: string, b: string): boolean;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# workspace-repos-guard
|
|
2
|
+
|
|
3
|
+
Codewith-native hook installed as `hooks run workspace-repos-guard`.
|
|
4
|
+
|
|
5
|
+
PreToolUse guard for the canonical workspace structure (knowledge
|
|
6
|
+
`k_mssu9jdq_dgnnu2`): `$HOME/workspace` contains ONLY `repos/` + `scratch/` +
|
|
7
|
+
`AGENTS.md`, and `repos/` contains ONLY GitHub-org folders. Checkouts at
|
|
8
|
+
`$HOME/workspace/repos/<org>/<repo>/` are read/context only.
|
|
9
|
+
|
|
10
|
+
## What it blocks
|
|
11
|
+
|
|
12
|
+
- Any write to `$HOME/workspace/repos` itself (file tools and Bash).
|
|
13
|
+
- Any write that would create a top-level entry directly under `repos/`
|
|
14
|
+
(depth 1 — a stray folder or file at the org level).
|
|
15
|
+
- Any write whose second path segment is not an allowed GitHub org.
|
|
16
|
+
- Any delete (`rm`, `rmdir`, `git clean`, `git rm`, `unlink`, `shred`,
|
|
17
|
+
`trash`, ...) anywhere under `$HOME/workspace/repos`, at any depth,
|
|
18
|
+
including deep inside org checkouts.
|
|
19
|
+
|
|
20
|
+
## What it allows
|
|
21
|
+
|
|
22
|
+
- Reads, always.
|
|
23
|
+
- Writes deeper inside an allowed org folder
|
|
24
|
+
(`repos/<org>/<repo>/...`). Structure only: it deliberately does NOT
|
|
25
|
+
duplicate the `worktree-guard` hook, which owns edits-in-shared-checkouts
|
|
26
|
+
semantics.
|
|
27
|
+
|
|
28
|
+
Home spellings (`~`, `$HOME`, `${HOME}`, quoted or not, including split-quote
|
|
29
|
+
forms like `"$HOME"/workspace/repos`, which Bash treats identically to the
|
|
30
|
+
unquoted spelling) are expanded before classification; `apply_patch` tools
|
|
31
|
+
are inspected through their `Add File` / `Update File` / `Delete File`
|
|
32
|
+
markers; Bash relative operands are resolved against the command's cwd when
|
|
33
|
+
it sits under `repos/`; parenthesized command groups
|
|
34
|
+
(`(cd ... && rm -rf ...)`) are unwrapped.
|
|
35
|
+
|
|
36
|
+
## Configuration
|
|
37
|
+
|
|
38
|
+
Allowed orgs default to `hasna,hasnaxyz,hasna-internal,hasna-products` and
|
|
39
|
+
are overridable with the `WORKSPACE_REPOS_GUARD_ORGS` env var
|
|
40
|
+
(comma-separated). The home directory is resolved with `os.homedir()` —
|
|
41
|
+
never hardcoded.
|
|
42
|
+
|
|
43
|
+
## Failure mode
|
|
44
|
+
|
|
45
|
+
Fail-open: on any parse or evaluation error the hook responds `continue` so a
|
|
46
|
+
guard defect can never wedge the agent.
|
|
47
|
+
|
|
48
|
+
## Known limitation
|
|
49
|
+
|
|
50
|
+
The guard is a best-effort **structural** guard, not an execution sandbox.
|
|
51
|
+
Variable indirection cannot be caught by pre-expansion inspection: a command
|
|
52
|
+
that builds its target dynamically (`R=...; rm -rf $R`, loops over computed
|
|
53
|
+
paths, scripts downloaded and executed at runtime) is undetectable at hook
|
|
54
|
+
time. The hook inspects literal spellings of the protected path (`~/...`,
|
|
55
|
+
`$HOME/...`, `${HOME}/...`, split-quote forms such as `"$HOME"/...`, the
|
|
56
|
+
resolved absolute home) and relative operands resolved from the command's
|
|
57
|
+
cwd, so anything the shell would expand or indirect through a variable is
|
|
58
|
+
outside its reach. Because it also fails open, it must never be relied on as
|
|
59
|
+
the only protection layer.
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
Apache-2.0
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "workspace-repos-guard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Codewith-native Workspace Repos Guard hook for @hasna/hooks",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/hook.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"typecheck": "tsc --noEmit"
|
|
9
|
+
},
|
|
10
|
+
"author": "Hasna",
|
|
11
|
+
"license": "Apache-2.0"
|
|
12
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { evaluate, resolveAllowedOrgs } from "./hook";
|
|
5
|
+
|
|
6
|
+
const HOME = homedir();
|
|
7
|
+
const REPOS = join(HOME, "workspace", "repos");
|
|
8
|
+
|
|
9
|
+
function preToolUse(tool_name: string, tool_input: Record<string, unknown>, cwd = join(REPOS, "hasna", "apps")) {
|
|
10
|
+
return { hook_event_name: "PreToolUse", tool_name, tool_input, cwd };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isBlocked(result: { output: { decision?: string; reason?: string } }): string | null {
|
|
14
|
+
if (result.output.decision === "block") return result.output.reason ?? "blocked";
|
|
15
|
+
return null;
|
|
16
|
+
}describe("hook-workspace-repos-guard", () => {
|
|
17
|
+
describe("resolveAllowedOrgs", () => {
|
|
18
|
+
test("defaults to the four canonical GitHub orgs", () => {
|
|
19
|
+
expect([...resolveAllowedOrgs({})].sort()).toEqual(["hasna", "hasna-internal", "hasna-products", "hasnaxyz"]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("parses comma-separated env override, trimming whitespace and empties", () => {
|
|
23
|
+
expect([...resolveAllowedOrgs({ WORKSPACE_REPOS_GUARD_ORGS: " hasna , myorg ,, " })].sort()).toEqual(["hasna", "myorg"]);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("positive controls — must continue", () => {
|
|
28
|
+
test("Write deep inside an allowed org checkout continues", () => {
|
|
29
|
+
const result = evaluate(preToolUse("Write", { file_path: join(REPOS, "hasna", "apps", "apps", "foo", "src", "index.ts") }));
|
|
30
|
+
expect(result.output.continue).toBe(true);
|
|
31
|
+
expect(isBlocked(result)).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("Edit inside hasna-internal org continues", () => {
|
|
35
|
+
const result = evaluate(preToolUse("Edit", { file_path: join(REPOS, "hasna-internal", "platform", "docs", "readme.md") }));
|
|
36
|
+
expect(result.output.continue).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("MultiEdit with relative file_path resolves against cwd and continues inside allowed org", () => {
|
|
40
|
+
const result = evaluate(preToolUse("MultiEdit", { file_path: "apps/hooks/src/lib/registry.ts" }));
|
|
41
|
+
expect(result.output.continue).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("NotebookEdit inside allowed org continues", () => {
|
|
45
|
+
const result = evaluate(preToolUse("NotebookEdit", { notebook_path: join(REPOS, "hasnaxyz", "iapp-probe", "probe.ipynb") }));
|
|
46
|
+
expect(result.output.continue).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("Bash read commands under repos are never blocked", () => {
|
|
50
|
+
const reads = [
|
|
51
|
+
`ls ${REPOS}`,
|
|
52
|
+
`cat ${join(REPOS, "hasna", "apps", "README.md")}`,
|
|
53
|
+
`git -C ${join(REPOS, "hasna", "apps")} status`,
|
|
54
|
+
`cd ${REPOS} && git pull`,
|
|
55
|
+
`find ${join(REPOS, "hasna")} -name AGENTS.md`,
|
|
56
|
+
];
|
|
57
|
+
for (const command of reads) {
|
|
58
|
+
const result = evaluate(preToolUse("Bash", { command }));
|
|
59
|
+
expect(result.output.continue).toBe(true);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("Bash write deep inside an allowed org checkout continues", () => {
|
|
64
|
+
const commands = [
|
|
65
|
+
`echo note > ${join(REPOS, "hasna", "apps", "scratch.md")}`,
|
|
66
|
+
`touch ${join(REPOS, "hasna", "apps", "apps", "foo", "x.txt")}`,
|
|
67
|
+
`git clone https://github.com/hasna/foo.git ${join(REPOS, "hasna", "foo")}`,
|
|
68
|
+
`mkdir -p ${join(REPOS, "hasna-internal", "platform", "apps")}`,
|
|
69
|
+
];
|
|
70
|
+
for (const command of commands) {
|
|
71
|
+
const result = evaluate(preToolUse("Bash", { command }));
|
|
72
|
+
expect(result.output.continue).toBe(true);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("tilde/$HOME spellings of deep allowed-org writes continue", () => {
|
|
77
|
+
const commands = [
|
|
78
|
+
`touch ~/workspace/repos/hasna/apps/foo.md`,
|
|
79
|
+
`echo x > ~/workspace/repos/hasnaxyz/iapp-probe/notes.md`,
|
|
80
|
+
`mkdir -p "${HOME}/workspace/repos/hasna-internal/platform/apps"`,
|
|
81
|
+
`touch "$HOME"/workspace/repos/hasna/apps/x.txt`,
|
|
82
|
+
`echo x > "\${HOME}"/workspace/repos/hasnaxyz/iapp-probe/notes.md`,
|
|
83
|
+
];
|
|
84
|
+
for (const command of commands) {
|
|
85
|
+
const result = evaluate(preToolUse("Bash", { command }));
|
|
86
|
+
expect(result.output.continue).toBe(true);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("file-tool tilde/$HOME paths deep inside an allowed org continue", () => {
|
|
91
|
+
const results = [
|
|
92
|
+
evaluate(preToolUse("Write", { file_path: `~/workspace/repos/hasna/apps/src/x.ts` })),
|
|
93
|
+
evaluate(preToolUse("Edit", { file_path: `$HOME/workspace/repos/hasnaxyz/iapp-probe/n.ipynb` })),
|
|
94
|
+
evaluate(preToolUse("Write", { file_path: `"${HOME}/workspace/repos/hasna/apps/notes.md"` })),
|
|
95
|
+
evaluate(preToolUse("Write", { file_path: `"$HOME"/workspace/repos/hasna/apps/notes2.md` })),
|
|
96
|
+
];
|
|
97
|
+
for (const result of results) {
|
|
98
|
+
expect(result.output.continue).toBe(true);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("apply_patch Add/Update File deep inside an allowed org continues", () => {
|
|
103
|
+
const result = evaluate(
|
|
104
|
+
preToolUse("apply_patch", {
|
|
105
|
+
patch: `*** Begin Patch\n*** Add File: apps/hooks/src/lib/registry.ts\n+export const x = 1;\n*** End Patch`,
|
|
106
|
+
})
|
|
107
|
+
);
|
|
108
|
+
expect(result.output.continue).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("non-mutating sed without -i, and interpreter without a path, continue", () => {
|
|
112
|
+
const commands = [
|
|
113
|
+
`sed 's/a/b/' ${join(REPOS, "hasna", "apps", "README.md")}`,
|
|
114
|
+
`node -e "console.log('no path here')"`,
|
|
115
|
+
`python3 -c "print('no path here')"`,
|
|
116
|
+
];
|
|
117
|
+
for (const command of commands) {
|
|
118
|
+
const result = evaluate(preToolUse("Bash", { command }));
|
|
119
|
+
expect(result.output.continue).toBe(true);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("non-PreToolUse events and non-file tools are ignored", () => {
|
|
124
|
+
const result = evaluate({ hook_event_name: "PostToolUse", tool_name: "Write", tool_input: { file_path: join(REPOS, "hasna", "x.ts") } });
|
|
125
|
+
expect(result.output.continue).toBe(true);
|
|
126
|
+
const read = evaluate(preToolUse("Read", { file_path: join(REPOS, "hasna", "apps", "README.md") }));
|
|
127
|
+
expect(read.output.continue).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("negative controls — must block", () => {
|
|
132
|
+
test("Write to ~/workspace/repos itself is blocked", () => {
|
|
133
|
+
const result = evaluate(preToolUse("Write", { file_path: join(REPOS, "stray.txt") }));
|
|
134
|
+
const reason = isBlocked(result);
|
|
135
|
+
expect(reason).not.toBeNull();
|
|
136
|
+
expect(reason).toContain("workspace-repos-guard");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("Write to the repos root directory entry is blocked", () => {
|
|
140
|
+
const result = evaluate(preToolUse("Edit", { file_path: REPOS }));
|
|
141
|
+
expect(isBlocked(result)).not.toBeNull();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("Write whose second segment is not an allowed org is blocked", () => {
|
|
145
|
+
const result = evaluate(preToolUse("Write", { file_path: join(REPOS, "notanorg", "x", "file.ts") }));
|
|
146
|
+
const reason = isBlocked(result);
|
|
147
|
+
expect(reason).not.toBeNull();
|
|
148
|
+
expect(reason).toContain("notanorg");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("Write creating a top-level entry under repos (org-level, no deeper path) is blocked", () => {
|
|
152
|
+
const result = evaluate(preToolUse("Write", { file_path: join(REPOS, "hasna") }));
|
|
153
|
+
expect(isBlocked(result)).not.toBeNull();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("rm -rf deep inside an allowed org checkout is blocked", () => {
|
|
157
|
+
const result = evaluate(preToolUse("Bash", { command: `rm -rf ${join(REPOS, "hasna", "apps", ".git")}` }));
|
|
158
|
+
const reason = isBlocked(result);
|
|
159
|
+
expect(reason).not.toBeNull();
|
|
160
|
+
expect(reason).toContain("delete");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("rmdir of an org folder is blocked", () => {
|
|
164
|
+
const result = evaluate(preToolUse("Bash", { command: `rmdir ${join(REPOS, "oldorg")}` }));
|
|
165
|
+
expect(isBlocked(result)).not.toBeNull();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("git clean inside a checkout is blocked", () => {
|
|
169
|
+
const result = evaluate(preToolUse("Bash", { command: `git -C ${join(REPOS, "hasna", "apps")} clean -fd` }));
|
|
170
|
+
expect(isBlocked(result)).not.toBeNull();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("rm -rf of the repos root itself is blocked", () => {
|
|
174
|
+
const result = evaluate(preToolUse("Bash", { command: `rm -rf ${REPOS}` }));
|
|
175
|
+
expect(isBlocked(result)).not.toBeNull();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("cd into repos followed by rm -rf . is blocked via relative resolution", () => {
|
|
179
|
+
const result = evaluate(preToolUse("Bash", { command: `cd ${REPOS} && rm -rf .` }));
|
|
180
|
+
expect(isBlocked(result)).not.toBeNull();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("top-level non-org folder creation via Bash is blocked", () => {
|
|
184
|
+
const commands = [
|
|
185
|
+
`mkdir -p ${join(REPOS, "notanorg")}`,
|
|
186
|
+
`mkdir -p ${join(REPOS, "notanorg", "sub")}`,
|
|
187
|
+
`touch ${join(REPOS, "neworg", "x")}`,
|
|
188
|
+
`mv ${join(REPOS, "hasna", "apps", "a")} ${join(REPOS, "anotherorg")}`,
|
|
189
|
+
];
|
|
190
|
+
for (const command of commands) {
|
|
191
|
+
const result = evaluate(preToolUse("Bash", { command }));
|
|
192
|
+
expect(isBlocked(result)).not.toBeNull();
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("Bash write into a non-allowed org folder is blocked", () => {
|
|
197
|
+
const result = evaluate(preToolUse("Bash", { command: `echo x > ${join(REPOS, "notanorg", "deep", "f.ts")}` }));
|
|
198
|
+
const reason = isBlocked(result);
|
|
199
|
+
expect(reason).not.toBeNull();
|
|
200
|
+
expect(reason).toContain("notanorg");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("delete via git rm is blocked", () => {
|
|
204
|
+
const result = evaluate(preToolUse("Bash", { command: `git -C ${join(REPOS, "hasna", "apps")} rm src/foo.ts` }));
|
|
205
|
+
expect(isBlocked(result)).not.toBeNull();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("tilde/$HOME spellings of the protected path are blocked", () => {
|
|
209
|
+
const commands = [
|
|
210
|
+
`rm -rf ~/workspace/repos`,
|
|
211
|
+
`rm -rf ~/workspace/repos/hasna/apps`,
|
|
212
|
+
`rm -rf $HOME/workspace/repos/hasna`,
|
|
213
|
+
`rm -rf "${HOME}/workspace/repos"`,
|
|
214
|
+
`rm -rf "$HOME"/workspace/repos`,
|
|
215
|
+
`rm -rf "$HOME"/workspace/repos/hasna/apps`,
|
|
216
|
+
`rm -rf "\${HOME}"/workspace/repos/hasna`,
|
|
217
|
+
`rm -rf "${HOME}"/workspace/repos/hasna`,
|
|
218
|
+
`touch ~/workspace/repos/stray.txt`,
|
|
219
|
+
`mkdir -p ~/workspace/repos/notanorg`,
|
|
220
|
+
`echo x > ~/workspace/repos/notanorg/f.ts`,
|
|
221
|
+
`mv /tmp/x ~/workspace/repos`,
|
|
222
|
+
`truncate -s 0 ~/workspace/repos/stray.log`,
|
|
223
|
+
`rsync -a /tmp/x ~/workspace/repos/`,
|
|
224
|
+
`scp f.txt ~/workspace/repos/`,
|
|
225
|
+
];
|
|
226
|
+
for (const command of commands) {
|
|
227
|
+
const result = evaluate(preToolUse("Bash", { command }));
|
|
228
|
+
expect(isBlocked(result), `expected BLOCK for: ${command}`).not.toBeNull();
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("file-tool tilde/$HOME spellings are blocked", () => {
|
|
233
|
+
const results = [
|
|
234
|
+
evaluate(preToolUse("Write", { file_path: `~/workspace/repos/stray.txt` })),
|
|
235
|
+
evaluate(preToolUse("Write", { file_path: `$HOME/workspace/repos/notanorg/f.ts` })),
|
|
236
|
+
evaluate(preToolUse("Edit", { file_path: `~/workspace/repos/hasna` })),
|
|
237
|
+
evaluate(preToolUse("Write", { file_path: `~/workspace/repos` })),
|
|
238
|
+
evaluate(preToolUse("Write", { file_path: `"$HOME"/workspace/repos/stray.txt` })),
|
|
239
|
+
];
|
|
240
|
+
for (const result of results) {
|
|
241
|
+
expect(isBlocked(result)).not.toBeNull();
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("subshell-grouped cd + rm is blocked", () => {
|
|
246
|
+
const commands = [
|
|
247
|
+
`(cd ~/workspace/repos && rm -rf hasna)`,
|
|
248
|
+
`(cd ${REPOS} && rm -rf hasna)`,
|
|
249
|
+
`( cd ${REPOS} && touch stray.txt )`,
|
|
250
|
+
];
|
|
251
|
+
for (const command of commands) {
|
|
252
|
+
const result = evaluate(preToolUse("Bash", { command }));
|
|
253
|
+
expect(isBlocked(result), `expected BLOCK for: ${command}`).not.toBeNull();
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("cwd-relative deletes without an explicit cd are blocked", () => {
|
|
258
|
+
const fromCheckout = evaluate(preToolUse("Bash", { command: `rm -rf ../` }, join(REPOS, "hasna", "apps")));
|
|
259
|
+
expect(isBlocked(fromCheckout)).not.toBeNull();
|
|
260
|
+
const fromRoot = evaluate(preToolUse("Bash", { command: `mkdir -p notanorg` }, REPOS));
|
|
261
|
+
expect(isBlocked(fromRoot)).not.toBeNull();
|
|
262
|
+
const bareRm = evaluate(preToolUse("Bash", { command: `rm -rf .` }, join(REPOS, "hasnaxyz")));
|
|
263
|
+
expect(isBlocked(bareRm)).not.toBeNull();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("mutating verbs writing under the protected root are blocked", () => {
|
|
267
|
+
const commands = [
|
|
268
|
+
`sed -i 's/x/y/' ~/workspace/repos/stray.txt`,
|
|
269
|
+
`rsync -a ${join(REPOS, "hasna")} /tmp/out`,
|
|
270
|
+
`scp -r ${join(REPOS, "hasna")} other:/tmp/`,
|
|
271
|
+
`truncate -s 0 ${join(REPOS, "notanorg", "f.ts")}`,
|
|
272
|
+
`python3 -c "open('~/workspace/repos/stray.txt','w')"`,
|
|
273
|
+
`node -e "require('fs').writeFileSync('${join(REPOS, "notanorg", "f.ts")}','x')"`,
|
|
274
|
+
];
|
|
275
|
+
for (const command of commands) {
|
|
276
|
+
const result = evaluate(preToolUse("Bash", { command }));
|
|
277
|
+
expect(isBlocked(result), `expected BLOCK for: ${command}`).not.toBeNull();
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("apply_patch Delete File and shallow Add File are blocked", () => {
|
|
282
|
+
const del = evaluate(
|
|
283
|
+
preToolUse("ApplyPatch", {
|
|
284
|
+
patch: `*** Begin Patch\n*** Delete File: src/foo.ts\n*** End Patch`,
|
|
285
|
+
})
|
|
286
|
+
);
|
|
287
|
+
expect(isBlocked(del)).not.toBeNull();
|
|
288
|
+
const shallow = evaluate(
|
|
289
|
+
preToolUse("functions.apply_patch", {
|
|
290
|
+
patch: `*** Begin Patch\n*** Add File: ../../stray.txt\n+x\n*** End Patch`,
|
|
291
|
+
})
|
|
292
|
+
);
|
|
293
|
+
expect(isBlocked(shallow)).not.toBeNull();
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe("orgs env override", () => {
|
|
298
|
+
test("override widens and narrows the allowed set", () => {
|
|
299
|
+
const previous = process.env.WORKSPACE_REPOS_GUARD_ORGS;
|
|
300
|
+
try {
|
|
301
|
+
process.env.WORKSPACE_REPOS_GUARD_ORGS = "hasna,myorg";
|
|
302
|
+
const allowed = evaluate(preToolUse("Write", { file_path: join(REPOS, "myorg", "proj", "f.ts") }));
|
|
303
|
+
expect(allowed.output.continue).toBe(true);
|
|
304
|
+
const narrowed = evaluate(preToolUse("Write", { file_path: join(REPOS, "hasnaxyz", "iapp-x", "f.ts") }));
|
|
305
|
+
expect(isBlocked(narrowed)).not.toBeNull();
|
|
306
|
+
} finally {
|
|
307
|
+
if (previous === undefined) delete process.env.WORKSPACE_REPOS_GUARD_ORGS;
|
|
308
|
+
else process.env.WORKSPACE_REPOS_GUARD_ORGS = previous;
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
});
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PreToolUse hook: workspace-repos-guard
|
|
5
|
+
*
|
|
6
|
+
* Guards the canonical workspace structure (knowledge k_mssu9jdq_dgnnu2):
|
|
7
|
+
* $HOME/workspace contains ONLY repos/ + scratch/ + AGENTS.md, and repos/
|
|
8
|
+
* contains ONLY GitHub-org folders. Checkouts at
|
|
9
|
+
* $HOME/workspace/repos/<org>/<repo>/ are read/context only.
|
|
10
|
+
*
|
|
11
|
+
* Structure-only guard — it deliberately does NOT duplicate the worktree-guard
|
|
12
|
+
* hook, which owns edits-in-shared-checkouts semantics.
|
|
13
|
+
*
|
|
14
|
+
* BLOCKS:
|
|
15
|
+
* - any write to $HOME/workspace/repos itself (file tools and Bash),
|
|
16
|
+
* - any write that would create a top-level entry under repos/ (depth <= 1),
|
|
17
|
+
* - any write whose second path segment is not an allowed GitHub org,
|
|
18
|
+
* - any delete (rm, rmdir, git clean, git rm, unlink, shred, trash, ...)
|
|
19
|
+
* anywhere under $HOME/workspace/repos, at any depth.
|
|
20
|
+
*
|
|
21
|
+
* ALLOWS (structure only):
|
|
22
|
+
* - reads, always,
|
|
23
|
+
* - writes deeper inside an allowed org folder (repos/<org>/<repo>/...).
|
|
24
|
+
*
|
|
25
|
+
* Home spellings (~, $HOME, ${HOME}, including quoted forms) are expanded
|
|
26
|
+
* before classification in Bash targets, file-tool paths, cd operands and
|
|
27
|
+
* apply_patch file markers. Bash relative operands (`.`, `..`, bare names)
|
|
28
|
+
* are resolved against the command's cwd when it sits under the repos root,
|
|
29
|
+
* and against an explicit `cd` into the root when one is present. apply_patch
|
|
30
|
+
* tools are inspected through their `*** Add File:` / `*** Update File:` /
|
|
31
|
+
* `*** Delete File:` markers. Parenthesized command groups are unwrapped.
|
|
32
|
+
*
|
|
33
|
+
* Allowed orgs default to hasna,hasnaxyz,hasna-internal,hasna-products and are
|
|
34
|
+
* overridable with the WORKSPACE_REPOS_GUARD_ORGS env var (comma-separated).
|
|
35
|
+
* Home is resolved with os.homedir(); never hardcoded. Fail-open on any parse
|
|
36
|
+
* or evaluation error so a guard defect cannot wedge the agent.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { homedir } from "os";
|
|
40
|
+
import { isAbsolute, join, normalize, relative, resolve, sep } from "path";
|
|
41
|
+
import {
|
|
42
|
+
getCommand,
|
|
43
|
+
readInput,
|
|
44
|
+
respond,
|
|
45
|
+
warn,
|
|
46
|
+
type CodewithHookInput,
|
|
47
|
+
type CodewithHookOutput,
|
|
48
|
+
} from "../../codewith-native-common";
|
|
49
|
+
|
|
50
|
+
const FILE_WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
51
|
+
|
|
52
|
+
const PATCH_TOOLS = new Set(["apply_patch", "ApplyPatch", "functions.apply_patch"]);
|
|
53
|
+
|
|
54
|
+
const DEFAULT_ORGS = ["hasna", "hasnaxyz", "hasna-internal", "hasna-products"];
|
|
55
|
+
|
|
56
|
+
const RULE = "workspace-repos-guard";
|
|
57
|
+
|
|
58
|
+
function reposRoot(home: string): string {
|
|
59
|
+
return join(home, "workspace", "repos");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function resolveAllowedOrgs(env: NodeJS.ProcessEnv = process.env): Set<string> {
|
|
63
|
+
const raw = env.WORKSPACE_REPOS_GUARD_ORGS;
|
|
64
|
+
if (typeof raw !== "string" || !raw.trim()) return new Set(DEFAULT_ORGS);
|
|
65
|
+
return new Set(
|
|
66
|
+
raw
|
|
67
|
+
.split(",")
|
|
68
|
+
.map((s) => s.trim())
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type Operation = "read" | "write" | "delete";
|
|
74
|
+
|
|
75
|
+
interface Verdict {
|
|
76
|
+
blocked: boolean;
|
|
77
|
+
reason?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Classify a single absolute target path against the repos root. Structure
|
|
82
|
+
* only: a write at depth >= 2 inside an allowed org passes, and anything else
|
|
83
|
+
* (deletes at any depth, writes at the root or org level, writes into a
|
|
84
|
+
* non-allowed org) is blocked. Paths outside the repos root are not this
|
|
85
|
+
* hook's concern and pass.
|
|
86
|
+
*/
|
|
87
|
+
export function classifyPath(target: string, root: string, orgs: Set<string>, op: Operation): Verdict {
|
|
88
|
+
if (op === "read") return { blocked: false };
|
|
89
|
+
let rel = relative(root, target);
|
|
90
|
+
if (rel === "") {
|
|
91
|
+
if (op === "delete") return { blocked: true, reason: `[${RULE}] delete of the repos root is forbidden: ${root}` };
|
|
92
|
+
return { blocked: true, reason: `[${RULE}] writes directly to the repos root are forbidden: ${root}` };
|
|
93
|
+
}
|
|
94
|
+
if (rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel)) {
|
|
95
|
+
return { blocked: false };
|
|
96
|
+
}
|
|
97
|
+
const segments = rel.split(sep).filter(Boolean);
|
|
98
|
+
|
|
99
|
+
if (op === "delete") {
|
|
100
|
+
return { blocked: true, reason: `[${RULE}] delete under ~/workspace/repos is forbidden at any depth: ${target}` };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (segments.length <= 1) {
|
|
104
|
+
return {
|
|
105
|
+
blocked: true,
|
|
106
|
+
reason: `[${RULE}] writing would create a top-level entry directly under repos/; only GitHub-org folders belong there: ${target}`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const org = segments[0];
|
|
111
|
+
if (!orgs.has(org)) {
|
|
112
|
+
return {
|
|
113
|
+
blocked: true,
|
|
114
|
+
reason: `[${RULE}] '${org}' is not an allowed GitHub org under ~/workspace/repos (allowed: ${[...orgs].join(", ")}): ${target}`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { blocked: false };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const WRITE_FLAGS = /\b(?:-o|--output|-O|--output-document|-out)\b/;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Classify the operation of one command segment (a `&&`/`||`/`;`-delimited
|
|
125
|
+
* unit). Git is handled by its subcommand: clean|rm delete, clone|init write,
|
|
126
|
+
* everything else (status/pull/fetch/log/diff/...) is read. Sed without an
|
|
127
|
+
* in-place flag is a stream filter (redirection is caught separately);
|
|
128
|
+
* rsync/scp/truncate and inline interpreters (python3 -c, node -e, bun -e)
|
|
129
|
+
* are treated as writes — conservative: they only matter when a path under
|
|
130
|
+
* the protected root is also present.
|
|
131
|
+
*/
|
|
132
|
+
function segmentOperation(segment: string): Operation {
|
|
133
|
+
const trimmed = segment.trim();
|
|
134
|
+
if (!trimmed) return "read";
|
|
135
|
+
|
|
136
|
+
if (/(?:^|\s)(?:rm|rmdir|unlink|shred|trash|rmtree|del)(?:\s|$)/.test(trimmed)) return "delete";
|
|
137
|
+
if (/\bgit\b/.test(trimmed)) {
|
|
138
|
+
if (/\bgit\b[^;&|]*\b(?:clean|rm)\b/.test(trimmed)) return "delete";
|
|
139
|
+
if (/\bgit\b[^;&|]*\b(?:clone|init)\b/.test(trimmed)) return "write";
|
|
140
|
+
return "read";
|
|
141
|
+
}
|
|
142
|
+
if (/(?:^|\s)(?:mkdir|mkfile|touch|mv|cp|ln|tee|install|dd)(?:\s|$)/.test(trimmed)) return "write";
|
|
143
|
+
if (/\b(?:curl|wget)\b/.test(trimmed)) {
|
|
144
|
+
if (WRITE_FLAGS.test(trimmed)) return "write";
|
|
145
|
+
return "read";
|
|
146
|
+
}
|
|
147
|
+
if (/\bsed\b/.test(trimmed)) {
|
|
148
|
+
if (/(?:^|\s)-i\S*(?:\s|$)|--in-place(?:\s|$)/.test(trimmed)) return "write";
|
|
149
|
+
// sed without -i is a filter; `>` redirection is caught by the check below.
|
|
150
|
+
} else if (/\b(?:rsync|scp|truncate)\b/.test(trimmed)) {
|
|
151
|
+
return "write";
|
|
152
|
+
} else if (/(?:^|\s)(?:python3?|node|bun)\b[^;&|]*\s+-[ce](?:\s|$)/.test(trimmed)) {
|
|
153
|
+
return "write";
|
|
154
|
+
}
|
|
155
|
+
if (/(?:^|[;&|])\s*echo\b/.test(trimmed) || /[>&]/.test(trimmed)) return "write";
|
|
156
|
+
return "read";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
interface PathTarget {
|
|
160
|
+
path: string;
|
|
161
|
+
op: Operation;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Expand one token's home spelling (`~`, `~/...`, `$HOME`, `$HOME/...`,
|
|
166
|
+
* `${HOME}`, `${HOME}/...`) to the resolved home, tolerating a single layer
|
|
167
|
+
* of wrapping single/double quotes and a closing quote between the home
|
|
168
|
+
* token and its path suffix (`"$HOME"/x`, `"${HOME}"/x` — Bash treats these
|
|
169
|
+
* identically to the unquoted form, because double quotes allow parameter
|
|
170
|
+
* expansion and adjacent quoted/unquoted parts concatenate into one word).
|
|
171
|
+
* Non-home spellings are returned unchanged.
|
|
172
|
+
*/
|
|
173
|
+
export function expandHomeSpelling(token: string, home: string): string {
|
|
174
|
+
let body = token.trim();
|
|
175
|
+
const quoted = body.match(/^(['"])([\s\S]*)\1$/);
|
|
176
|
+
if (quoted) body = quoted[2];
|
|
177
|
+
// Split-quote forms — `"$HOME"/x`, `"${HOME}"/x`, `"/home/hasna"/x`: the
|
|
178
|
+
// quotes wrap only the home token, and Bash concatenates the adjacent
|
|
179
|
+
// quoted and unquoted parts into the same word as the unquoted spelling.
|
|
180
|
+
body = body.replace(/^"*\$HOME"*\//, "$HOME/");
|
|
181
|
+
body = body.replace(/^"*\$\{HOME\}"*\//, "${HOME}/");
|
|
182
|
+
if (body === "~") return home;
|
|
183
|
+
if (body.startsWith("~/")) return join(home, body.slice(2));
|
|
184
|
+
if (body === "$HOME" || body === "${HOME}") return home;
|
|
185
|
+
if (body.startsWith("$HOME/")) return join(home, body.slice("$HOME/".length));
|
|
186
|
+
if (body.startsWith("${HOME}/")) return join(home, body.slice("${HOME}/".length));
|
|
187
|
+
const homeMatch = body.match(new RegExp(`^"*${regexEscape(home)}"*/`));
|
|
188
|
+
if (homeMatch) return home + "/" + body.slice(homeMatch[0].length);
|
|
189
|
+
return token;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Strip one level of wrapping shell-group punctuation from a segment so a
|
|
194
|
+
* subshell group like `(cd ~/workspace/repos && rm -rf hasna)` is analysed as
|
|
195
|
+
* its parts after the `&&` split.
|
|
196
|
+
*/
|
|
197
|
+
function unwrapSegment(segment: string): string {
|
|
198
|
+
let s = segment.trim();
|
|
199
|
+
while (s.startsWith("(") || s.startsWith("{")) s = s.slice(1).trim();
|
|
200
|
+
while (s.endsWith(")") || s.endsWith("}")) s = s.slice(0, -1).trim();
|
|
201
|
+
return s;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function regexEscape(text: string): string {
|
|
205
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Extract workspace-repos targets from a Bash command. Recognises explicit
|
|
210
|
+
* `~/...`, `$HOME/...`, `${HOME}/...` and literal-home references (expanded
|
|
211
|
+
* before classification), plus a trailing relative operand (`.`, `..`, a bare
|
|
212
|
+
* name) when the command `cd`s into the repos root or the command's cwd
|
|
213
|
+
* already sits under it. Tokens outside the repos root are ignored; reads are
|
|
214
|
+
* returned so callers can decide (they are never blocked).
|
|
215
|
+
*/
|
|
216
|
+
export function bashTargets(command: string, home: string, cwd: string): PathTarget[] {
|
|
217
|
+
if (!command) return [];
|
|
218
|
+
const root = reposRoot(home);
|
|
219
|
+
const segments = command.split(/\s*&&\s*|\s*\|\|\s*|;\s*|\n+/);
|
|
220
|
+
|
|
221
|
+
const targets: PathTarget[] = [];
|
|
222
|
+
let cwdUnderRepos: string | null = null;
|
|
223
|
+
|
|
224
|
+
for (const rawSegment of segments) {
|
|
225
|
+
const segment = unwrapSegment(rawSegment);
|
|
226
|
+
const op = segmentOperation(segment);
|
|
227
|
+
|
|
228
|
+
const cdMatch = segment.match(/(?:^|\s)cd(?:\s+(?:-[A-Za-z]+|--))*\s+([^\s;&|<>()\x60]+)/);
|
|
229
|
+
if (cdMatch) {
|
|
230
|
+
const cdTarget = expandHomeSpelling(cdMatch[1], home);
|
|
231
|
+
if (cdTarget && (cdTarget === root || cdTarget.startsWith(`${root}${sep}`))) cwdUnderRepos = cdTarget;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const homeLiteral = regexEscape(home);
|
|
235
|
+
const prefixRe = new RegExp(`(?:~|\\$HOME"*|\\$\\{HOME\\}"*|${homeLiteral}"*)/workspace/repos`);
|
|
236
|
+
const re = new RegExp(`(${prefixRe.source})([^\\s"';&|<>()\x60]*|$)`, "g");
|
|
237
|
+
let m: RegExpExecArray | null;
|
|
238
|
+
let foundExplicit = false;
|
|
239
|
+
while ((m = re.exec(segment)) !== null) {
|
|
240
|
+
foundExplicit = true;
|
|
241
|
+
const expanded = expandHomeSpelling(m[0], home);
|
|
242
|
+
targets.push({ path: normalize(expanded).replace(/\/+$/, ""), op });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!foundExplicit && (op === "delete" || op === "write")) {
|
|
246
|
+
const base =
|
|
247
|
+
cwdUnderRepos ?? (cwd === root || cwd.startsWith(`${root}${sep}`) ? cwd : null);
|
|
248
|
+
if (base) {
|
|
249
|
+
const relMatch = segment.match(/(?:^|\s)(\.\.?|[^\s"';&|<>()\x60/]+(?:\/[^\s"';&|<>()\x60]*)?)\s*$/);
|
|
250
|
+
if (relMatch) {
|
|
251
|
+
targets.push({ path: normalize(resolve(base, relMatch[1])), op });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return targets;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const PATCH_FILE_RE = /^\s*\*\*\*\s+(Add File|Update File|Delete File):\s+(\S+)\s*$/gm;
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Extract targets from an apply_patch payload: the `*** Add File:` /
|
|
264
|
+
* `*** Update File:` / `*** Delete File:` markers, resolved against cwd.
|
|
265
|
+
* Add/Update classify as write; Delete classifies as delete.
|
|
266
|
+
*/
|
|
267
|
+
export function patchTargets(patch: string, cwd: string): PathTarget[] {
|
|
268
|
+
const targets: PathTarget[] = [];
|
|
269
|
+
let m: RegExpExecArray | null;
|
|
270
|
+
while ((m = PATCH_FILE_RE.exec(patch)) !== null) {
|
|
271
|
+
const op: Operation = m[1] === "Delete File" ? "delete" : "write";
|
|
272
|
+
targets.push({ path: normalize(resolve(cwd, m[2])), op });
|
|
273
|
+
}
|
|
274
|
+
return targets;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function evaluate(input: CodewithHookInput): { output: CodewithHookOutput; warnings: string[] } {
|
|
278
|
+
const warnings: string[] = [];
|
|
279
|
+
if (input.hook_event_name !== "PreToolUse") return { output: { continue: true }, warnings };
|
|
280
|
+
|
|
281
|
+
const tool = typeof input.tool_name === "string" ? input.tool_name : "";
|
|
282
|
+
const home = homedir();
|
|
283
|
+
const root = reposRoot(home);
|
|
284
|
+
const orgs = resolveAllowedOrgs();
|
|
285
|
+
|
|
286
|
+
if (tool === "Bash") {
|
|
287
|
+
const command = getCommand(input);
|
|
288
|
+
if (!command) return { output: { continue: true }, warnings };
|
|
289
|
+
const cwd = typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd();
|
|
290
|
+
const targets = bashTargets(command, home, cwd);
|
|
291
|
+
for (const target of targets) {
|
|
292
|
+
const verdict = classifyPath(target.path, root, orgs, target.op);
|
|
293
|
+
if (verdict.blocked) {
|
|
294
|
+
return { output: { decision: "block", reason: verdict.reason }, warnings };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return { output: { continue: true }, warnings };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const toolInput = input.tool_input ?? {};
|
|
301
|
+
const cwd = typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd();
|
|
302
|
+
|
|
303
|
+
if (PATCH_TOOLS.has(tool)) {
|
|
304
|
+
const patch = typeof toolInput.patch === "string" ? toolInput.patch : "";
|
|
305
|
+
if (!patch) return { output: { continue: true }, warnings };
|
|
306
|
+
const targets = patchTargets(patch, cwd);
|
|
307
|
+
for (const target of targets) {
|
|
308
|
+
const verdict = classifyPath(target.path, root, orgs, target.op);
|
|
309
|
+
if (verdict.blocked) {
|
|
310
|
+
return { output: { decision: "block", reason: verdict.reason }, warnings };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return { output: { continue: true }, warnings };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (!FILE_WRITE_TOOLS.has(tool)) return { output: { continue: true }, warnings };
|
|
317
|
+
|
|
318
|
+
const raw =
|
|
319
|
+
typeof toolInput.file_path === "string"
|
|
320
|
+
? toolInput.file_path
|
|
321
|
+
: typeof toolInput.path === "string"
|
|
322
|
+
? toolInput.path
|
|
323
|
+
: typeof toolInput.notebook_path === "string"
|
|
324
|
+
? toolInput.notebook_path
|
|
325
|
+
: null;
|
|
326
|
+
if (!raw) return { output: { continue: true }, warnings };
|
|
327
|
+
|
|
328
|
+
const expanded = expandHomeSpelling(raw, home);
|
|
329
|
+
const target = isAbsolute(expanded) ? normalize(expanded) : resolve(cwd, expanded);
|
|
330
|
+
const verdict = classifyPath(target, root, orgs, "write");
|
|
331
|
+
if (verdict.blocked) {
|
|
332
|
+
return { output: { decision: "block", reason: verdict.reason }, warnings };
|
|
333
|
+
}
|
|
334
|
+
return { output: { continue: true }, warnings };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export async function run(): Promise<void> {
|
|
338
|
+
const input = readInput();
|
|
339
|
+
try {
|
|
340
|
+
const { output, warnings } = evaluate(input);
|
|
341
|
+
for (const message of warnings) warn(message);
|
|
342
|
+
respond(output);
|
|
343
|
+
} catch (error) {
|
|
344
|
+
warn(`${RULE} failed open: ${error instanceof Error ? error.message : String(error)}`);
|
|
345
|
+
respond({ continue: true });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (import.meta.main) {
|
|
350
|
+
await run();
|
|
351
|
+
process.exit(0);
|
|
352
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"lib": ["ESNext"],
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"allowImportingTsExtensions": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"noEmit": true,
|
|
15
|
+
"noUnusedLocals": true,
|
|
16
|
+
"noUnusedParameters": true,
|
|
17
|
+
"types": ["bun-types"]
|
|
18
|
+
},
|
|
19
|
+
"include": ["src/**/*"],
|
|
20
|
+
"exclude": ["node_modules", "dist"]
|
|
21
|
+
}
|
package/package.json
CHANGED