@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 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 = join4(__dirname, "..", "..", "hooks");
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 dirname2 } from "path";
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 = dirname2(path);
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 = dirname2(path);
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 = dirname2(fileURLToPath(import.meta.url));
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 dirname3, isAbsolute as isAbsolute2, join as join9 } from "path";
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 = dirname3(options.scriptPath);
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) === apiKey;
6091
- return req.headers.get("x-api-key") === apiKey;
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: { accept: "application/json" },
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 dirname4 } from "path";
6412
- import { fileURLToPath as fileURLToPath2 } from "url";
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
- const eventHooks = settings.hooks?.[meta.event] || [];
6588
- const found = eventHooks.some((entry) => entry.hooks?.some((h) => {
6589
- const match = h.command?.match(/^hooks run ([\w-]+)/);
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: dirname5 } = await import("path");
6951
- mkdirSync8(dirname5(settingsPath), { recursive: true });
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 = dirname4(fileURLToPath2(import.meta.url));
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 dirname5 } from "path";
8561
- import { fileURLToPath as fileURLToPath3 } from "url";
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 = dirname5(fileURLToPath3(import.meta.url));
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
- const eventHooks = settings.hooks?.[meta.event] || [];
10306
- const found = eventHooks.some((entry) => entry.hooks?.some((h) => {
10307
- const match = h.command?.match(/^hooks run ([\w-]+)/);
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
- var __dirname = "/home/hasna/.hasna/repos/worktrees/apps/1ec6ba31-cdbc-42ea-8b1e-705e4198abbf/apps/hooks/src/lib";
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 = join5(__dirname, "..", "..", "hooks");
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) === apiKey;
5176
- return req.headers.get("x-api-key") === apiKey;
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 dirname3, isAbsolute as isAbsolute2, join as join9 } from "path";
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 = dirname3(options.scriptPath);
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 dirname2 } from "path";
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
- var __dirname = "/home/hasna/.hasna/repos/worktrees/apps/1ec6ba31-cdbc-42ea-8b1e-705e4198abbf/apps/hooks/src/lib";
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 = join5(__dirname, "..", "..", "hooks");
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 = dirname2(fileURLToPath(import.meta.url));
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 = dirname2(path);
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 = dirname2(path);
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: { accept: "application/json" },
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) === apiKey;
6643
- return req.headers.get("x-api-key") === apiKey;
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), {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/hooks",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Open source hooks library for AI coding agents - Install safety, quality, and automation hooks with a single command",
5
5
  "type": "module",
6
6
  "bin": {