@hasna/hooks 0.7.7 → 0.7.9

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
@@ -1958,7 +1958,7 @@ var init_registry = __esm(() => {
1958
1958
  {
1959
1959
  name: "workspace-repos-guard",
1960
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",
1961
+ description: "Blocks writes and deletes that break the protected repo-checkout structure (~/.hasna/repos/clones canonical, plus legacy ~/workspace/repos during the migration window): writes to a protected root, top-level entry creation, non-org folders, and any delete under a protected root",
1962
1962
  version: "0.1.0",
1963
1963
  category: "Git Safety",
1964
1964
  event: "PreToolUse",
@@ -8908,8 +8908,12 @@ import { existsSync } from "fs";
8908
8908
  import { homedir } from "os";
8909
8909
  import { join } from "path";
8910
8910
  import { createHmac, timingSafeEqual } from "crypto";
8911
+ import { lookup as dnsLookup } from "dns/promises";
8912
+ import { isIP } from "net";
8911
8913
  import { randomUUID } from "crypto";
8912
8914
  import { spawn } from "child_process";
8915
+ import { request as nodeHttpRequest } from "http";
8916
+ import { request as nodeHttpsRequest } from "https";
8913
8917
  import { randomUUID as randomUUID2 } from "crypto";
8914
8918
  function getPathValue(input, path) {
8915
8919
  return path.split(".").reduce((value, part) => {
@@ -9312,6 +9316,214 @@ function signPayload(secret, timestamp, body) {
9312
9316
  const digest = createHmac("sha256", secret).update(buildSignatureBase(timestamp, body)).digest("hex");
9313
9317
  return `sha256=${digest}`;
9314
9318
  }
9319
+ var DEFAULT_MAX_REDIRECTS = 5;
9320
+ var IPV4_PRIVATE_RANGES = [
9321
+ [0, 16777215],
9322
+ [167772160, 184549375],
9323
+ [1681915904, 1686110207],
9324
+ [2130706432, 2147483647],
9325
+ [2851995648, 2852061183],
9326
+ [2886729728, 2887778303],
9327
+ [3221225472, 3221225727],
9328
+ [3221225984, 3221226239],
9329
+ [3227017984, 3227018239],
9330
+ [3232235520, 3232301055],
9331
+ [3323068416, 3323199487],
9332
+ [3325256704, 3325256959],
9333
+ [3405803776, 3405804031],
9334
+ [3758096384, 4294967295]
9335
+ ];
9336
+ var IPV6_SPECIAL_PREFIXES = [
9337
+ { groups: [0, 0, 0, 0, 0, 0, 0, 0], bits: 128 },
9338
+ { groups: [0, 0, 0, 0, 0, 0, 0, 1], bits: 128 },
9339
+ { groups: [0, 0, 0, 0, 0, 65535, 0, 0], bits: 96 },
9340
+ { groups: [100, 65435, 0, 0, 0, 0, 0, 0], bits: 96 },
9341
+ { groups: [256, 0, 0, 0, 0, 0, 0, 0], bits: 64 },
9342
+ { groups: [8193, 0, 0, 0, 0, 0, 0, 0], bits: 32 },
9343
+ { groups: [8193, 2, 0, 0, 0, 0, 0, 0], bits: 48 },
9344
+ { groups: [8193, 16, 0, 0, 0, 0, 0, 0], bits: 28 },
9345
+ { groups: [8193, 3512, 0, 0, 0, 0, 0, 0], bits: 32 },
9346
+ { groups: [8194, 0, 0, 0, 0, 0, 0, 0], bits: 16 },
9347
+ { groups: [16383, 0, 0, 0, 0, 0, 0, 0], bits: 20 },
9348
+ { groups: [64512, 0, 0, 0, 0, 0, 0, 0], bits: 7 },
9349
+ { groups: [65152, 0, 0, 0, 0, 0, 0, 0], bits: 10 },
9350
+ { groups: [65216, 0, 0, 0, 0, 0, 0, 0], bits: 10 },
9351
+ { groups: [65280, 0, 0, 0, 0, 0, 0, 0], bits: 8 }
9352
+ ];
9353
+ function isPrivateAddress(address) {
9354
+ const normalized = stripZoneId(address);
9355
+ const version = isIP(normalized);
9356
+ if (version === 4) {
9357
+ const integer = ipv4ToInt(normalized);
9358
+ if (integer === undefined)
9359
+ return true;
9360
+ return IPV4_PRIVATE_RANGES.some(([low, high]) => integer >= low && integer <= high);
9361
+ }
9362
+ if (version === 6) {
9363
+ const groups = ipv6Groups(normalized);
9364
+ if (!groups)
9365
+ return true;
9366
+ for (const prefix of IPV6_SPECIAL_PREFIXES) {
9367
+ if (!ipv6MatchesPrefix(groups, prefix.groups, prefix.bits))
9368
+ continue;
9369
+ if (prefix.bits === 96 && groups[5] === 65535) {
9370
+ return isPrivateAddress(ipv4IntToString(groups[6] << 16 | groups[7]));
9371
+ }
9372
+ if (prefix.bits === 16 && groups[0] === 8194) {
9373
+ return isPrivateAddress(ipv4IntToString(groups[1] << 16 | groups[2]));
9374
+ }
9375
+ return true;
9376
+ }
9377
+ return false;
9378
+ }
9379
+ return true;
9380
+ }
9381
+ async function resolveWebhookTarget(url, policy = {}) {
9382
+ const hostname = normalizeHostname(url.hostname);
9383
+ const allowlist = (policy.allowPrivateHosts ?? []).map((entry) => normalizeHostname(entry.toLowerCase()));
9384
+ if (allowlist.includes(hostname)) {
9385
+ const version2 = isIP(hostname);
9386
+ if (version2 === 4 || version2 === 6) {
9387
+ return { hostname, addresses: [hostname] };
9388
+ }
9389
+ const lookup2 = policy.lookup ?? defaultTargetLookup;
9390
+ let resolved2;
9391
+ try {
9392
+ resolved2 = await lookup2(hostname);
9393
+ } catch {
9394
+ throw new Error(`Webhook target ${hostname} could not be resolved`);
9395
+ }
9396
+ if (!Array.isArray(resolved2) || resolved2.length === 0) {
9397
+ throw new Error(`Webhook target ${hostname} resolved to no addresses`);
9398
+ }
9399
+ const addresses = resolved2.map((entry) => normalizeHostname(entry.address));
9400
+ return { hostname, addresses };
9401
+ }
9402
+ const version = isIP(hostname);
9403
+ if (version === 4 || version === 6) {
9404
+ if (isPrivateAddress(hostname)) {
9405
+ throw new Error(`Webhook target ${hostname} is a private or special-use address`);
9406
+ }
9407
+ return { hostname, addresses: [hostname] };
9408
+ }
9409
+ const lookup = policy.lookup ?? defaultTargetLookup;
9410
+ let resolved;
9411
+ try {
9412
+ resolved = await lookup(hostname);
9413
+ } catch {
9414
+ throw new Error(`Webhook target ${hostname} could not be resolved`);
9415
+ }
9416
+ if (!Array.isArray(resolved) || resolved.length === 0) {
9417
+ throw new Error(`Webhook target ${hostname} resolved to no addresses`);
9418
+ }
9419
+ const allowed = [];
9420
+ for (const entry of resolved) {
9421
+ const address = normalizeHostname(entry.address);
9422
+ if (isPrivateAddress(address)) {
9423
+ if (allowlist.includes(address)) {
9424
+ allowed.push(address);
9425
+ continue;
9426
+ }
9427
+ throw new Error(`Webhook target ${hostname} resolves to private or special-use address ${address}`);
9428
+ }
9429
+ allowed.push(address);
9430
+ }
9431
+ if (allowed.length === 0) {
9432
+ throw new Error(`Webhook target ${hostname} resolved to no public addresses`);
9433
+ }
9434
+ return { hostname, addresses: allowed };
9435
+ }
9436
+ function normalizeMaxRedirects(value) {
9437
+ if (value === undefined)
9438
+ return DEFAULT_MAX_REDIRECTS;
9439
+ if (!Number.isInteger(value) || value < 0)
9440
+ throw new Error("webhookTargetPolicy.maxRedirects must be a non-negative integer");
9441
+ return value;
9442
+ }
9443
+ var defaultTargetLookup = async (hostname) => {
9444
+ return dnsLookup(hostname, { all: true, verbatim: false });
9445
+ };
9446
+ function normalizeHostname(hostname) {
9447
+ const lower = hostname.toLowerCase();
9448
+ if (lower.startsWith("[") && lower.endsWith("]"))
9449
+ return lower.slice(1, -1);
9450
+ return lower;
9451
+ }
9452
+ function stripZoneId(address) {
9453
+ const percent = address.indexOf("%");
9454
+ return percent === -1 ? address : address.slice(0, percent);
9455
+ }
9456
+ function ipv4ToInt(address) {
9457
+ const parts = address.split(".");
9458
+ if (parts.length !== 4)
9459
+ return;
9460
+ let value = 0;
9461
+ for (const part of parts) {
9462
+ if (!/^\d{1,3}$/.test(part))
9463
+ return;
9464
+ const octet = Number(part);
9465
+ if (octet > 255)
9466
+ return;
9467
+ value = value << 8 | octet;
9468
+ }
9469
+ return value >>> 0;
9470
+ }
9471
+ function ipv4IntToString(integer) {
9472
+ return [
9473
+ integer >>> 24 & 255,
9474
+ integer >>> 16 & 255,
9475
+ integer >>> 8 & 255,
9476
+ integer & 255
9477
+ ].join(".");
9478
+ }
9479
+ function ipv6Groups(address) {
9480
+ const raw = stripZoneId(address);
9481
+ const doubleColon = raw.indexOf("::");
9482
+ const headText = doubleColon === -1 ? raw : raw.slice(0, doubleColon);
9483
+ const tailText = doubleColon === -1 ? "" : raw.slice(doubleColon + 2);
9484
+ const parseGroups = (text) => {
9485
+ if (text === "")
9486
+ return [];
9487
+ const out = [];
9488
+ for (const part of text.split(":")) {
9489
+ if (part.includes(".")) {
9490
+ const v4 = ipv4ToInt(part);
9491
+ if (v4 === undefined)
9492
+ return;
9493
+ out.push(v4 >>> 16 & 65535, v4 & 65535);
9494
+ } else {
9495
+ if (!/^[0-9a-fA-F]{1,4}$/.test(part))
9496
+ return;
9497
+ out.push(parseInt(part, 16));
9498
+ }
9499
+ }
9500
+ return out;
9501
+ };
9502
+ const head = parseGroups(headText);
9503
+ if (!head)
9504
+ return;
9505
+ const tail = parseGroups(tailText);
9506
+ if (!tail)
9507
+ return;
9508
+ const total = head.length + tail.length;
9509
+ if (doubleColon === -1) {
9510
+ return total === 8 ? head : undefined;
9511
+ }
9512
+ if (total >= 8)
9513
+ return;
9514
+ return [...head, ...new Array(8 - total).fill(0), ...tail];
9515
+ }
9516
+ function ipv6MatchesPrefix(groups, prefixGroups, prefixBits) {
9517
+ let remaining = prefixBits;
9518
+ for (let index = 0;index < prefixGroups.length && remaining > 0; index += 1) {
9519
+ const take = Math.min(16, remaining);
9520
+ const mask = 65535 << 16 - take & 65535;
9521
+ if ((groups[index] & mask) !== (prefixGroups[index] & mask))
9522
+ return false;
9523
+ remaining -= take;
9524
+ }
9525
+ return true;
9526
+ }
9315
9527
  function now() {
9316
9528
  return new Date().toISOString();
9317
9529
  }
@@ -9342,9 +9554,18 @@ function buildWebhookRequest(event, channel, options = {}) {
9342
9554
  }
9343
9555
  return { body, headers };
9344
9556
  }
9557
+ function normalizeWebhookUrl(raw) {
9558
+ const url = new URL(raw);
9559
+ if (url.username !== "" || url.password !== "") {
9560
+ url.username = "";
9561
+ url.password = "";
9562
+ }
9563
+ return url.toString();
9564
+ }
9345
9565
  async function dispatchWebhook(event, channel, options = {}) {
9346
9566
  if (!channel.webhook)
9347
9567
  throw new Error(`Channel ${channel.id} has no webhook config`);
9568
+ const webhookUrl = normalizeWebhookUrl(channel.webhook.url);
9348
9569
  const startedAt = now();
9349
9570
  let secret = channel.webhook.secret;
9350
9571
  if (channel.webhook.secretRef) {
@@ -9361,10 +9582,14 @@ async function dispatchWebhook(event, channel, options = {}) {
9361
9582
  }
9362
9583
  const timestamp = (options.now?.() ?? new Date).toISOString();
9363
9584
  const { body, headers } = buildWebhookRequest(event, channel, { secret, timestamp });
9585
+ const validateTargets = options.webhookTargetPolicy !== undefined || options.fetchImpl === undefined;
9586
+ if (validateTargets) {
9587
+ return dispatchValidatedWebhook(event, channel, { body, headers, startedAt, options });
9588
+ }
9364
9589
  const controller = new AbortController;
9365
9590
  const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
9366
9591
  try {
9367
- const response = await (options.fetchImpl ?? fetch)(channel.webhook.url, {
9592
+ const response = await (options.fetchImpl ?? fetch)(webhookUrl, {
9368
9593
  method: "POST",
9369
9594
  headers,
9370
9595
  body,
@@ -9392,6 +9617,130 @@ async function dispatchWebhook(event, channel, options = {}) {
9392
9617
  clearTimeout(timeout);
9393
9618
  }
9394
9619
  }
9620
+ function isRedirectStatus(status) {
9621
+ return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
9622
+ }
9623
+ function redirectKeepsBody(status) {
9624
+ return status === 307 || status === 308;
9625
+ }
9626
+ async function pinnedNativeRequest(target, addresses, method, headers, body, signal, tls) {
9627
+ const isHttps = target.protocol === "https:";
9628
+ if (!isHttps && target.protocol !== "http:") {
9629
+ throw new Error(`Webhook target uses unsupported protocol ${target.protocol}`);
9630
+ }
9631
+ const defaultPort = isHttps ? 443 : 80;
9632
+ const port = target.port ? Number(target.port) : defaultPort;
9633
+ const requestOptions = {
9634
+ hostname: target.hostname,
9635
+ port,
9636
+ path: `${target.pathname}${target.search}`,
9637
+ method,
9638
+ headers,
9639
+ ...tls?.ca ? { ca: tls.ca } : {},
9640
+ lookup: (hostname, _options, callback) => {
9641
+ const entries = addresses.map((address) => ({
9642
+ address,
9643
+ family: address.includes(":") ? 6 : 4
9644
+ }));
9645
+ callback(null, entries);
9646
+ }
9647
+ };
9648
+ return new Promise((resolve, reject) => {
9649
+ const request = isHttps ? nodeHttpsRequest(requestOptions, onResponse) : nodeHttpRequest(requestOptions, onResponse);
9650
+ const onAbort = () => {
9651
+ const error = new Error("The operation was aborted.");
9652
+ error.name = "AbortError";
9653
+ request.destroy(error);
9654
+ };
9655
+ if (signal.aborted)
9656
+ onAbort();
9657
+ else
9658
+ signal.addEventListener("abort", onAbort, { once: true });
9659
+ request.on("error", reject);
9660
+ if (body !== undefined)
9661
+ request.write(body);
9662
+ request.end();
9663
+ function onResponse(response) {
9664
+ const chunks = [];
9665
+ response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
9666
+ response.on("error", reject);
9667
+ response.on("end", () => {
9668
+ const headersRecord = {};
9669
+ for (const [name, value] of Object.entries(response.headers)) {
9670
+ if (typeof value === "string")
9671
+ headersRecord[name] = value;
9672
+ else if (Array.isArray(value))
9673
+ headersRecord[name] = value.join(", ");
9674
+ }
9675
+ resolve(new Response(Buffer.concat(chunks), { status: response.statusCode ?? 200, headers: headersRecord }));
9676
+ });
9677
+ }
9678
+ });
9679
+ }
9680
+ async function dispatchValidatedWebhook(event, channel, input) {
9681
+ const { body, headers, startedAt, options } = input;
9682
+ const webhook = channel.webhook;
9683
+ if (!webhook)
9684
+ throw new Error(`Channel ${channel.id} has no webhook config`);
9685
+ const policy = options.webhookTargetPolicy ?? {};
9686
+ const maxRedirects = normalizeMaxRedirects(policy.maxRedirects);
9687
+ const controller = new AbortController;
9688
+ const timeout = setTimeout(() => controller.abort(), webhook.timeoutMs ?? 15000);
9689
+ try {
9690
+ let target = new URL(normalizeWebhookUrl(webhook.url));
9691
+ let requestHeaders = headers;
9692
+ let method = "POST";
9693
+ let requestBody = body;
9694
+ let redirectsFollowed = 0;
9695
+ for (;; ) {
9696
+ const resolved = await resolveWebhookTarget(target, policy).catch((error) => {
9697
+ throw new Error(`Webhook target rejected by SSRF guard: ${error.message}`);
9698
+ });
9699
+ const response = options.fetchImpl ? await options.fetchImpl(target, {
9700
+ method,
9701
+ headers: requestHeaders,
9702
+ body: requestBody,
9703
+ signal: controller.signal,
9704
+ redirect: "manual"
9705
+ }) : await pinnedNativeRequest(target, resolved.addresses, method, requestHeaders, requestBody, controller.signal, options.tls);
9706
+ const location = response.headers.get("location");
9707
+ if (isRedirectStatus(response.status) && location) {
9708
+ if (redirectsFollowed >= maxRedirects) {
9709
+ return failedAttempt(startedAt, `Webhook target exceeded ${maxRedirects} redirects`);
9710
+ }
9711
+ redirectsFollowed += 1;
9712
+ const next = new URL(location, target);
9713
+ target = next;
9714
+ if (!redirectKeepsBody(response.status)) {
9715
+ method = "GET";
9716
+ requestBody = undefined;
9717
+ requestHeaders = Object.fromEntries(Object.entries(requestHeaders).filter(([name]) => name.toLowerCase() !== "content-type" && name.toLowerCase() !== "content-length"));
9718
+ }
9719
+ continue;
9720
+ }
9721
+ const responseBody = truncate(await response.text());
9722
+ return {
9723
+ attempt: 1,
9724
+ status: response.ok ? "success" : "failed",
9725
+ startedAt,
9726
+ completedAt: now(),
9727
+ responseStatus: response.status,
9728
+ responseBody,
9729
+ error: response.ok ? undefined : `Webhook returned HTTP ${response.status}`
9730
+ };
9731
+ }
9732
+ } catch (error) {
9733
+ return {
9734
+ attempt: 1,
9735
+ status: "failed",
9736
+ startedAt,
9737
+ completedAt: now(),
9738
+ error: error instanceof Error ? error.message : String(error)
9739
+ };
9740
+ } finally {
9741
+ clearTimeout(timeout);
9742
+ }
9743
+ }
9395
9744
  function failedAttempt(startedAt, error) {
9396
9745
  return {
9397
9746
  attempt: 1,
@@ -9601,7 +9950,9 @@ class EventsClient {
9601
9950
  this.transportOptions = {
9602
9951
  fetchImpl: options.fetchImpl,
9603
9952
  secretResolver: options.secretResolver,
9604
- now: options.now
9953
+ now: options.now,
9954
+ tls: options.tls,
9955
+ webhookTargetPolicy: options.webhookTargetPolicy
9605
9956
  };
9606
9957
  this.catalog = options.catalog ?? defaultEventTypeCatalog;
9607
9958
  this.validateCatalogTypes = options.validateCatalogTypes ?? false;
@@ -12813,6 +13164,29 @@ Cloudflare provisioning complete:
12813
13164
  console.log(chalk2.dim(" wrangler.toml, fill in the D1 database id, then run the commands above."));
12814
13165
  console.log();
12815
13166
  });
13167
+ program2.command("migrate").description("Apply PostgreSQL migrations to the storage database (HASNA_HOOKS_DATABASE_URL)").option("-j, --json", "Output as JSON", false).action(async (options) => {
13168
+ try {
13169
+ const { getStoragePg: getStoragePg2, runStorageMigrations: runStorageMigrations2 } = await Promise.resolve().then(() => (init_storage(), exports_storage));
13170
+ const remote = await getStoragePg2();
13171
+ try {
13172
+ await runStorageMigrations2(remote);
13173
+ } finally {
13174
+ await remote.close();
13175
+ }
13176
+ if (options.json) {
13177
+ console.log(JSON.stringify({ ok: true }));
13178
+ return;
13179
+ }
13180
+ console.log(chalk2.green("\u2713 PostgreSQL migrations applied"));
13181
+ } catch (error) {
13182
+ const message = error instanceof Error ? error.message : String(error);
13183
+ if (options.json)
13184
+ console.log(JSON.stringify({ error: message }));
13185
+ else
13186
+ console.error(chalk2.red(`\u2717 ${message}`));
13187
+ process.exitCode = 1;
13188
+ }
13189
+ });
12816
13190
  program2.command("mcp").option("-s, --stdio", "Use stdio transport (one process per agent)", false).option("--sse", "Use legacy SSE transport (port 39427)", false).option("--http", "Use Streamable HTTP transport (explicit; this is also the default)", false).option("-p, --port <port>", "Port for HTTP/SSE transport (defaults to 8847 for HTTP, 39427 for SSE)").description("Start MCP server for AI agent integration (default: shared Streamable HTTP)").action(async (options) => {
12817
13191
  if (options.stdio) {
12818
13192
  const { startStdioServer: startStdioServer2 } = await Promise.resolve().then(() => (init_server(), exports_server));
package/bin/serve.js CHANGED
@@ -60,7 +60,7 @@ var HOOKS = [
60
60
  {
61
61
  name: "workspace-repos-guard",
62
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",
63
+ description: "Blocks writes and deletes that break the protected repo-checkout structure (~/.hasna/repos/clones canonical, plus legacy ~/workspace/repos during the migration window): writes to a protected root, top-level entry creation, non-org folders, and any delete under a protected root",
64
64
  version: "0.1.0",
65
65
  category: "Git Safety",
66
66
  event: "PreToolUse",
package/dist/index.js CHANGED
@@ -6191,7 +6191,7 @@ var HOOKS = [
6191
6191
  {
6192
6192
  name: "workspace-repos-guard",
6193
6193
  displayName: "Workspace Repos Guard",
6194
- 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",
6194
+ description: "Blocks writes and deletes that break the protected repo-checkout structure (~/.hasna/repos/clones canonical, plus legacy ~/workspace/repos during the migration window): writes to a protected root, top-level entry creation, non-org folders, and any delete under a protected root",
6195
6195
  version: "0.1.0",
6196
6196
  category: "Git Safety",
6197
6197
  event: "PreToolUse",
@@ -2,35 +2,38 @@
2
2
 
3
3
  Codewith-native hook installed as `hooks run workspace-repos-guard`.
4
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.
5
+ PreToolUse guard for the protected repo-checkout roots. During the migration
6
+ window BOTH roots are guarded: the canonical clones root (knowledge
7
+ `k_mssu9jdq_dgnnu2`) `$HOME/.hasna/repos/clones`, and the still-live legacy
8
+ root `$HOME/workspace/repos`. Each contains ONLY GitHub-org folders, and
9
+ checkouts at `<root>/<org>/<repo>/` are read/context only. The legacy root
10
+ stays guarded until `~/workspace/repos` is decommissioned fleet-wide.
9
11
 
10
12
  ## What it blocks
11
13
 
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
+ - Any write to either protected root itself (`$HOME/.hasna/repos/clones` or
15
+ `$HOME/workspace/repos`), via file tools and Bash.
16
+ - Any write that would create a top-level entry directly under either root
14
17
  (depth 1 — a stray folder or file at the org level).
15
18
  - Any write whose second path segment is not an allowed GitHub org.
16
19
  - Any delete (`rm`, `rmdir`, `git clean`, `git rm`, `unlink`, `shred`,
17
- `trash`, ...) anywhere under `$HOME/workspace/repos`, at any depth,
20
+ `trash`, ...) anywhere under either protected root, at any depth,
18
21
  including deep inside org checkouts.
19
22
 
20
23
  ## What it allows
21
24
 
22
25
  - Reads, always.
23
26
  - Writes deeper inside an allowed org folder
24
- (`repos/<org>/<repo>/...`). Structure only: it deliberately does NOT
27
+ (`clones/<org>/<repo>/...`). Structure only: it deliberately does NOT
25
28
  duplicate the `worktree-guard` hook, which owns edits-in-shared-checkouts
26
29
  semantics.
27
30
 
28
31
  Home spellings (`~`, `$HOME`, `${HOME}`, quoted or not, including split-quote
29
- forms like `"$HOME"/workspace/repos`, which Bash treats identically to the
32
+ forms like `"$HOME"/.hasna/repos/clones`, which Bash treats identically to the
30
33
  unquoted spelling) are expanded before classification; `apply_patch` tools
31
34
  are inspected through their `Add File` / `Update File` / `Delete File`
32
35
  markers; Bash relative operands are resolved against the command's cwd when
33
- it sits under `repos/`; parenthesized command groups
36
+ it sits under the clones root; parenthesized command groups
34
37
  (`(cd ... && rm -rf ...)`) are unwrapped.
35
38
 
36
39
  ## Configuration
@@ -4,7 +4,8 @@ import { join } from "path";
4
4
  import { evaluate, resolveAllowedOrgs } from "./hook";
5
5
 
6
6
  const HOME = homedir();
7
- const REPOS = join(HOME, "workspace", "repos");
7
+ const REPOS = join(HOME, ".hasna", "repos", "clones");
8
+ const LEGACY = join(HOME, "workspace", "repos");
8
9
 
9
10
  function preToolUse(tool_name: string, tool_input: Record<string, unknown>, cwd = join(REPOS, "hasna", "apps")) {
10
11
  return { hook_event_name: "PreToolUse", tool_name, tool_input, cwd };
@@ -75,11 +76,11 @@ function isBlocked(result: { output: { decision?: string; reason?: string } }):
75
76
 
76
77
  test("tilde/$HOME spellings of deep allowed-org writes continue", () => {
77
78
  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/hasnaxyz/iapp-probe/notes"`,
81
- `touch "$HOME"/workspace/repos/hasna/apps/x.txt`,
82
- `echo x > "\${HOME}"/workspace/repos/hasnaxyz/iapp-probe/notes.md`,
79
+ `touch ~/.hasna/repos/clones/hasna/apps/foo.md`,
80
+ `echo x > ~/.hasna/repos/clones/hasnaxyz/iapp-probe/notes.md`,
81
+ `mkdir -p "${HOME}/.hasna/repos/clones/hasnaxyz/iapp-probe/notes"`,
82
+ `touch "$HOME"/.hasna/repos/clones/hasna/apps/x.txt`,
83
+ `echo x > "\${HOME}"/.hasna/repos/clones/hasnaxyz/iapp-probe/notes.md`,
83
84
  ];
84
85
  for (const command of commands) {
85
86
  const result = evaluate(preToolUse("Bash", { command }));
@@ -89,10 +90,10 @@ function isBlocked(result: { output: { decision?: string; reason?: string } }):
89
90
 
90
91
  test("file-tool tilde/$HOME paths deep inside an allowed org continue", () => {
91
92
  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` })),
93
+ evaluate(preToolUse("Write", { file_path: `~/.hasna/repos/clones/hasna/apps/src/x.ts` })),
94
+ evaluate(preToolUse("Edit", { file_path: `$HOME/.hasna/repos/clones/hasnaxyz/iapp-probe/n.ipynb` })),
95
+ evaluate(preToolUse("Write", { file_path: `"${HOME}/.hasna/repos/clones/hasna/apps/notes.md"` })),
96
+ evaluate(preToolUse("Write", { file_path: `"$HOME"/.hasna/repos/clones/hasna/apps/notes2.md` })),
96
97
  ];
97
98
  for (const result of results) {
98
99
  expect(result.output.continue).toBe(true);
@@ -129,7 +130,7 @@ function isBlocked(result: { output: { decision?: string; reason?: string } }):
129
130
  });
130
131
 
131
132
  describe("negative controls — must block", () => {
132
- test("Write to ~/workspace/repos itself is blocked", () => {
133
+ test("Write to ~/.hasna/repos/clones itself is blocked", () => {
133
134
  const result = evaluate(preToolUse("Write", { file_path: join(REPOS, "stray.txt") }));
134
135
  const reason = isBlocked(result);
135
136
  expect(reason).not.toBeNull();
@@ -207,21 +208,21 @@ function isBlocked(result: { output: { decision?: string; reason?: string } }):
207
208
 
208
209
  test("tilde/$HOME spellings of the protected path are blocked", () => {
209
210
  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/`,
211
+ `rm -rf ~/.hasna/repos/clones`,
212
+ `rm -rf ~/.hasna/repos/clones/hasna/apps`,
213
+ `rm -rf $HOME/.hasna/repos/clones/hasna`,
214
+ `rm -rf "${HOME}/.hasna/repos/clones"`,
215
+ `rm -rf "$HOME"/.hasna/repos/clones`,
216
+ `rm -rf "$HOME"/.hasna/repos/clones/hasna/apps`,
217
+ `rm -rf "\${HOME}"/.hasna/repos/clones/hasna`,
218
+ `rm -rf "${HOME}"/.hasna/repos/clones/hasna`,
219
+ `touch ~/.hasna/repos/clones/stray.txt`,
220
+ `mkdir -p ~/.hasna/repos/clones/notanorg`,
221
+ `echo x > ~/.hasna/repos/clones/notanorg/f.ts`,
222
+ `mv /tmp/x ~/.hasna/repos/clones`,
223
+ `truncate -s 0 ~/.hasna/repos/clones/stray.log`,
224
+ `rsync -a /tmp/x ~/.hasna/repos/clones/`,
225
+ `scp f.txt ~/.hasna/repos/clones/`,
225
226
  ];
226
227
  for (const command of commands) {
227
228
  const result = evaluate(preToolUse("Bash", { command }));
@@ -231,11 +232,11 @@ function isBlocked(result: { output: { decision?: string; reason?: string } }):
231
232
 
232
233
  test("file-tool tilde/$HOME spellings are blocked", () => {
233
234
  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` })),
235
+ evaluate(preToolUse("Write", { file_path: `~/.hasna/repos/clones/stray.txt` })),
236
+ evaluate(preToolUse("Write", { file_path: `$HOME/.hasna/repos/clones/notanorg/f.ts` })),
237
+ evaluate(preToolUse("Edit", { file_path: `~/.hasna/repos/clones/hasna` })),
238
+ evaluate(preToolUse("Write", { file_path: `~/.hasna/repos/clones` })),
239
+ evaluate(preToolUse("Write", { file_path: `"$HOME"/.hasna/repos/clones/stray.txt` })),
239
240
  ];
240
241
  for (const result of results) {
241
242
  expect(isBlocked(result)).not.toBeNull();
@@ -244,7 +245,7 @@ function isBlocked(result: { output: { decision?: string; reason?: string } }):
244
245
 
245
246
  test("subshell-grouped cd + rm is blocked", () => {
246
247
  const commands = [
247
- `(cd ~/workspace/repos && rm -rf hasna)`,
248
+ `(cd ~/.hasna/repos/clones && rm -rf hasna)`,
248
249
  `(cd ${REPOS} && rm -rf hasna)`,
249
250
  `( cd ${REPOS} && touch stray.txt )`,
250
251
  ];
@@ -265,11 +266,11 @@ function isBlocked(result: { output: { decision?: string; reason?: string } }):
265
266
 
266
267
  test("mutating verbs writing under the protected root are blocked", () => {
267
268
  const commands = [
268
- `sed -i 's/x/y/' ~/workspace/repos/stray.txt`,
269
+ `sed -i 's/x/y/' ~/.hasna/repos/clones/stray.txt`,
269
270
  `rsync -a ${join(REPOS, "hasna")} /tmp/out`,
270
271
  `scp -r ${join(REPOS, "hasna")} other:/tmp/`,
271
272
  `truncate -s 0 ${join(REPOS, "notanorg", "f.ts")}`,
272
- `python3 -c "open('~/workspace/repos/stray.txt','w')"`,
273
+ `python3 -c "open('~/.hasna/repos/clones/stray.txt','w')"`,
273
274
  `node -e "require('fs').writeFileSync('${join(REPOS, "notanorg", "f.ts")}','x')"`,
274
275
  ];
275
276
  for (const command of commands) {
@@ -309,4 +310,157 @@ function isBlocked(result: { output: { decision?: string; reason?: string } }):
309
310
  }
310
311
  });
311
312
  });
313
+
314
+ describe("legacy ~/workspace/repos root during the migration window — positive controls (must continue)", () => {
315
+ test("Write deep inside an allowed org checkout under the legacy root continues", () => {
316
+ const result = evaluate(preToolUse("Write", { file_path: join(LEGACY, "hasna", "apps", "apps", "foo", "src", "index.ts") }));
317
+ expect(result.output.continue).toBe(true);
318
+ expect(isBlocked(result)).toBeNull();
319
+ });
320
+
321
+ test("Bash write deep inside an allowed org checkout under the legacy root continues", () => {
322
+ const commands = [
323
+ `echo note > ${join(LEGACY, "hasna", "apps", "scratch.md")}`,
324
+ `git clone https://github.com/hasna/foo.git ${join(LEGACY, "hasna", "foo")}`,
325
+ `mkdir -p ${join(LEGACY, "hasnaxyz", "iapp-probe", "docs")}`,
326
+ ];
327
+ for (const command of commands) {
328
+ const result = evaluate(preToolUse("Bash", { command }));
329
+ expect(result.output.continue).toBe(true);
330
+ }
331
+ });
332
+
333
+ test("tilde/$HOME spellings of deep allowed-org writes under the legacy root continue", () => {
334
+ const commands = [
335
+ `touch ~/workspace/repos/hasna/apps/foo.md`,
336
+ `echo x > ~/workspace/repos/hasnaxyz/iapp-probe/notes.md`,
337
+ `mkdir -p "${HOME}/workspace/repos/hasnaxyz/iapp-probe/notes"`,
338
+ `touch "$HOME"/workspace/repos/hasna/apps/x.txt`,
339
+ `echo x > "\${HOME}"/workspace/repos/hasnaxyz/iapp-probe/notes.md`,
340
+ ];
341
+ for (const command of commands) {
342
+ const result = evaluate(preToolUse("Bash", { command }));
343
+ expect(result.output.continue).toBe(true);
344
+ }
345
+ });
346
+
347
+ test("file-tool tilde/$HOME paths deep inside an allowed org under the legacy root continue", () => {
348
+ const results = [
349
+ evaluate(preToolUse("Write", { file_path: `~/workspace/repos/hasna/apps/src/x.ts` })),
350
+ evaluate(preToolUse("Edit", { file_path: `$HOME/workspace/repos/hasnaxyz/iapp-probe/n.ipynb` })),
351
+ evaluate(preToolUse("Write", { file_path: `"${HOME}/workspace/repos/hasna/apps/notes.md"` })),
352
+ evaluate(preToolUse("Write", { file_path: `"$HOME"/workspace/repos/hasna/apps/notes2.md` })),
353
+ ];
354
+ for (const result of results) {
355
+ expect(result.output.continue).toBe(true);
356
+ }
357
+ });
358
+ });
359
+
360
+ describe("legacy ~/workspace/repos root during the migration window — negative controls restored (must block)", () => {
361
+ test("rm -rf of the legacy root itself is blocked", () => {
362
+ const result = evaluate(preToolUse("Bash", { command: `rm -rf ~/workspace/repos` }));
363
+ const reason = isBlocked(result);
364
+ expect(reason).not.toBeNull();
365
+ expect(reason).toContain("workspace-repos-guard");
366
+ });
367
+
368
+ test("rm -rf deep inside an allowed org checkout under the legacy root is blocked", () => {
369
+ const result = evaluate(preToolUse("Bash", { command: `rm -rf ~/workspace/repos/hasna/apps` }));
370
+ expect(isBlocked(result)).not.toBeNull();
371
+ });
372
+
373
+ test("tilde/$HOME spellings of the legacy root are blocked", () => {
374
+ const commands = [
375
+ `rm -rf ~/workspace/repos`,
376
+ `rm -rf ~/workspace/repos/hasna/apps`,
377
+ `rm -rf $HOME/workspace/repos/hasna`,
378
+ `rm -rf "${HOME}/workspace/repos"`,
379
+ `rm -rf "$HOME"/workspace/repos`,
380
+ `rm -rf "$HOME"/workspace/repos/hasna/apps`,
381
+ `rm -rf "\${HOME}"/workspace/repos/hasna`,
382
+ `rm -rf "${HOME}"/workspace/repos/hasna`,
383
+ `touch ~/workspace/repos/stray.txt`,
384
+ `mkdir -p ~/workspace/repos/notanorg`,
385
+ `echo x > ~/workspace/repos/notanorg/f.ts`,
386
+ `mv /tmp/x ~/workspace/repos`,
387
+ `truncate -s 0 ~/workspace/repos/stray.log`,
388
+ `rsync -a /tmp/x ~/workspace/repos/`,
389
+ `scp f.txt ~/workspace/repos/`,
390
+ ];
391
+ for (const command of commands) {
392
+ const result = evaluate(preToolUse("Bash", { command }));
393
+ expect(isBlocked(result), `expected BLOCK for: ${command}`).not.toBeNull();
394
+ }
395
+ });
396
+
397
+ test("file-tool tilde/$HOME spellings under the legacy root are blocked", () => {
398
+ const results = [
399
+ evaluate(preToolUse("Write", { file_path: `~/workspace/repos/stray.txt` })),
400
+ evaluate(preToolUse("Write", { file_path: `$HOME/workspace/repos/notanorg/f.ts` })),
401
+ evaluate(preToolUse("Edit", { file_path: `~/workspace/repos/hasna` })),
402
+ evaluate(preToolUse("Write", { file_path: `~/workspace/repos` })),
403
+ evaluate(preToolUse("Write", { file_path: `"$HOME"/workspace/repos/stray.txt` })),
404
+ ];
405
+ for (const result of results) {
406
+ expect(isBlocked(result)).not.toBeNull();
407
+ }
408
+ });
409
+
410
+ test("subshell-grouped cd + rm under the legacy root is blocked", () => {
411
+ const commands = [
412
+ `(cd ~/workspace/repos && rm -rf hasna)`,
413
+ `(cd ${LEGACY} && rm -rf hasna)`,
414
+ `( cd ${LEGACY} && touch stray.txt )`,
415
+ ];
416
+ for (const command of commands) {
417
+ const result = evaluate(preToolUse("Bash", { command }));
418
+ expect(isBlocked(result), `expected BLOCK for: ${command}`).not.toBeNull();
419
+ }
420
+ });
421
+
422
+ test("cwd-relative deletes under the legacy root without an explicit cd are blocked", () => {
423
+ const fromRoot = evaluate(preToolUse("Bash", { command: `rm -rf .` }, LEGACY));
424
+ expect(isBlocked(fromRoot)).not.toBeNull();
425
+ const fromOrg = evaluate(preToolUse("Bash", { command: `rm -rf .` }, join(LEGACY, "hasna")));
426
+ expect(isBlocked(fromOrg)).not.toBeNull();
427
+ });
428
+
429
+ test("git clean inside a legacy checkout is blocked", () => {
430
+ const result = evaluate(preToolUse("Bash", { command: `git -C ${join(LEGACY, "hasna", "apps")} clean -fd` }));
431
+ expect(isBlocked(result)).not.toBeNull();
432
+ });
433
+
434
+ test("top-level non-org folder creation under the legacy root is blocked", () => {
435
+ const commands = [
436
+ `mkdir -p ${join(LEGACY, "notanorg")}`,
437
+ `mkdir -p ${join(LEGACY, "notanorg", "sub")}`,
438
+ `touch ${join(LEGACY, "neworg", "x")}`,
439
+ `mv ${join(LEGACY, "hasna", "apps", "a")} ${join(LEGACY, "anotherorg")}`,
440
+ ];
441
+ for (const command of commands) {
442
+ const result = evaluate(preToolUse("Bash", { command }));
443
+ expect(isBlocked(result)).not.toBeNull();
444
+ }
445
+ });
446
+
447
+ test("Bash write into a non-allowed org folder under the legacy root is blocked", () => {
448
+ const result = evaluate(preToolUse("Bash", { command: `echo x > ${join(LEGACY, "notanorg", "deep", "f.ts")}` }));
449
+ expect(isBlocked(result)).not.toBeNull();
450
+ });
451
+
452
+ test("mutating verbs writing under the legacy root are blocked", () => {
453
+ const commands = [
454
+ `sed -i 's/x/y/' ~/workspace/repos/stray.txt`,
455
+ `python3 -c "open('~/workspace/repos/stray.txt','w')"`,
456
+ `truncate -s 0 ${join(LEGACY, "notanorg", "f.ts")}`,
457
+ `rsync -a ${join(LEGACY, "hasna")} /tmp/out`,
458
+ `scp -r ${join(LEGACY, "hasna")} other:/tmp/`,
459
+ ];
460
+ for (const command of commands) {
461
+ const result = evaluate(preToolUse("Bash", { command }));
462
+ expect(isBlocked(result), `expected BLOCK for: ${command}`).not.toBeNull();
463
+ }
464
+ });
465
+ });
312
466
  });
@@ -3,24 +3,28 @@
3
3
  /**
4
4
  * PreToolUse hook: workspace-repos-guard
5
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.
6
+ * Guards the protected repo-checkout roots. During the migration window BOTH
7
+ * of these roots are protected: the canonical clones root
8
+ * $HOME/.hasna/repos/clones (knowledge k_mssu9jdq_dgnnu2) and the legacy root
9
+ * $HOME/workspace/repos. Each contains ONLY GitHub-org folders, and checkouts
10
+ * at <root>/<org>/<repo>/ are read/context only. The legacy root stays guarded
11
+ * until ~/workspace/repos is decommissioned fleet-wide.
10
12
  *
11
13
  * Structure-only guard — it deliberately does NOT duplicate the worktree-guard
12
14
  * hook, which owns edits-in-shared-checkouts semantics.
13
15
  *
14
16
  * 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 to either protected root itself (file tools and Bash),
18
+ * - any write that would create a top-level entry under either root
19
+ * (depth <= 1),
17
20
  * - any write whose second path segment is not an allowed GitHub org,
18
21
  * - any delete (rm, rmdir, git clean, git rm, unlink, shred, trash, ...)
19
- * anywhere under $HOME/workspace/repos, at any depth.
22
+ * anywhere under either protected root, at any depth.
20
23
  *
21
24
  * ALLOWS (structure only):
22
25
  * - reads, always,
23
- * - writes deeper inside an allowed org folder (repos/<org>/<repo>/...).
26
+ * - writes deeper inside an allowed org folder
27
+ * (clones/<org>/<repo>/... or workspace/repos/<org>/<repo>/...).
24
28
  *
25
29
  * Home spellings (~, $HOME, ${HOME}, including quoted forms) are expanded
26
30
  * before classification in Bash targets, file-tool paths, cd operands and
@@ -58,9 +62,22 @@ const DEFAULT_ORGS = ["hasna", "hasnaxyz", "hasna-products"];
58
62
  const RULE = "workspace-repos-guard";
59
63
 
60
64
  function reposRoot(home: string): string {
65
+ return join(home, ".hasna", "repos", "clones");
66
+ }
67
+
68
+ /**
69
+ * Legacy repo-checkout root, still live and populated during the migration
70
+ * window (see the header comment). Guarded identically to the canonical clones
71
+ * root until ~/workspace/repos is decommissioned fleet-wide.
72
+ */
73
+ function legacyRoot(home: string): string {
61
74
  return join(home, "workspace", "repos");
62
75
  }
63
76
 
77
+ function protectedRoots(home: string): string[] {
78
+ return [reposRoot(home), legacyRoot(home)];
79
+ }
80
+
64
81
  export function resolveAllowedOrgs(env: NodeJS.ProcessEnv = process.env): Set<string> {
65
82
  const raw = env.WORKSPACE_REPOS_GUARD_ORGS;
66
83
  if (typeof raw !== "string" || !raw.trim()) return new Set(DEFAULT_ORGS);
@@ -80,18 +97,18 @@ interface Verdict {
80
97
  }
81
98
 
82
99
  /**
83
- * Classify a single absolute target path against the repos root. Structure
84
- * only: a write at depth >= 2 inside an allowed org passes, and anything else
85
- * (deletes at any depth, writes at the root or org level, writes into a
86
- * non-allowed org) is blocked. Paths outside the repos root are not this
87
- * hook's concern and pass.
100
+ * Classify a single absolute target path against one protected repo-checkout
101
+ * root. Structure only: a write at depth >= 2 inside an allowed org passes,
102
+ * and anything else (deletes at any depth, writes at the root or org level,
103
+ * writes into a non-allowed org) is blocked. Paths outside this root are not
104
+ * this hook's concern and pass.
88
105
  */
89
106
  export function classifyPath(target: string, root: string, orgs: Set<string>, op: Operation): Verdict {
90
107
  if (op === "read") return { blocked: false };
91
108
  let rel = relative(root, target);
92
109
  if (rel === "") {
93
- if (op === "delete") return { blocked: true, reason: `[${RULE}] delete of the repos root is forbidden: ${root}` };
94
- return { blocked: true, reason: `[${RULE}] writes directly to the repos root are forbidden: ${root}` };
110
+ if (op === "delete") return { blocked: true, reason: `[${RULE}] delete of the protected repo-checkout root is forbidden: ${root}` };
111
+ return { blocked: true, reason: `[${RULE}] writes directly to the protected repo-checkout root are forbidden: ${root}` };
95
112
  }
96
113
  if (rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel)) {
97
114
  return { blocked: false };
@@ -99,13 +116,13 @@ export function classifyPath(target: string, root: string, orgs: Set<string>, op
99
116
  const segments = rel.split(sep).filter(Boolean);
100
117
 
101
118
  if (op === "delete") {
102
- return { blocked: true, reason: `[${RULE}] delete under ~/workspace/repos is forbidden at any depth: ${target}` };
119
+ return { blocked: true, reason: `[${RULE}] delete under the protected repo-checkout root (${root}) is forbidden at any depth: ${target}` };
103
120
  }
104
121
 
105
122
  if (segments.length <= 1) {
106
123
  return {
107
124
  blocked: true,
108
- reason: `[${RULE}] writing would create a top-level entry directly under repos/; only GitHub-org folders belong there: ${target}`,
125
+ reason: `[${RULE}] writing would create a top-level entry directly under the protected repo-checkout root (${root}); only GitHub-org folders belong there: ${target}`,
109
126
  };
110
127
  }
111
128
 
@@ -113,7 +130,7 @@ export function classifyPath(target: string, root: string, orgs: Set<string>, op
113
130
  if (!orgs.has(org)) {
114
131
  return {
115
132
  blocked: true,
116
- reason: `[${RULE}] '${org}' is not an allowed GitHub org under ~/workspace/repos (allowed: ${[...orgs].join(", ")}): ${target}`,
133
+ reason: `[${RULE}] '${org}' is not an allowed GitHub org under the protected repo-checkout root (${root}); allowed: ${[...orgs].join(", ")}: ${target}`,
117
134
  };
118
135
  }
119
136
 
@@ -193,7 +210,7 @@ export function expandHomeSpelling(token: string, home: string): string {
193
210
 
194
211
  /**
195
212
  * Strip one level of wrapping shell-group punctuation from a segment so a
196
- * subshell group like `(cd ~/workspace/repos && rm -rf hasna)` is analysed as
213
+ * subshell group like `(cd ~/.hasna/repos/clones && rm -rf hasna)` is analysed as
197
214
  * its parts after the `&&` split.
198
215
  */
199
216
  function unwrapSegment(segment: string): string {
@@ -208,16 +225,18 @@ function regexEscape(text: string): string {
208
225
  }
209
226
 
210
227
  /**
211
- * Extract workspace-repos targets from a Bash command. Recognises explicit
212
- * `~/...`, `$HOME/...`, `${HOME}/...` and literal-home references (expanded
213
- * before classification), plus a trailing relative operand (`.`, `..`, a bare
214
- * name) when the command `cd`s into the repos root or the command's cwd
215
- * already sits under it. Tokens outside the repos root are ignored; reads are
216
- * returned so callers can decide (they are never blocked).
228
+ * Extract protected-repo-checkout targets from a Bash command. Recognises
229
+ * explicit `~/...`, `$HOME/...`, `${HOME}/...` and literal-home references
230
+ * (expanded before classification) under EITHER protected root (the canonical
231
+ * clones root or the legacy workspace/repos root), plus a trailing relative
232
+ * operand (`.`, `..`, a bare name) when the command `cd`s into one of the
233
+ * protected roots or the command's cwd already sits under one. Tokens outside
234
+ * both protected roots are ignored; reads are returned so callers can decide
235
+ * (they are never blocked).
217
236
  */
218
237
  export function bashTargets(command: string, home: string, cwd: string): PathTarget[] {
219
238
  if (!command) return [];
220
- const root = reposRoot(home);
239
+ const roots = protectedRoots(home);
221
240
  const segments = command.split(/\s*&&\s*|\s*\|\|\s*|;\s*|\n+/);
222
241
 
223
242
  const targets: PathTarget[] = [];
@@ -230,11 +249,14 @@ export function bashTargets(command: string, home: string, cwd: string): PathTar
230
249
  const cdMatch = segment.match(/(?:^|\s)cd(?:\s+(?:-[A-Za-z]+|--))*\s+([^\s;&|<>()\x60]+)/);
231
250
  if (cdMatch) {
232
251
  const cdTarget = expandHomeSpelling(cdMatch[1], home);
233
- if (cdTarget && (cdTarget === root || cdTarget.startsWith(`${root}${sep}`))) cwdUnderRepos = cdTarget;
252
+ if (cdTarget && roots.some((root) => cdTarget === root || cdTarget.startsWith(`${root}${sep}`))) {
253
+ cwdUnderRepos = cdTarget;
254
+ }
234
255
  }
235
256
 
236
257
  const homeLiteral = regexEscape(home);
237
- const prefixRe = new RegExp(`(?:~|\\$HOME"*|\\$\\{HOME\\}"*|${homeLiteral}"*)/workspace/repos`);
258
+ const suffix = roots.map((root) => regexEscape(root.slice(home.length + 1))).join("|");
259
+ const prefixRe = new RegExp(`(?:~|\\$HOME"*|\\$\\{HOME\\}"*|${homeLiteral}"*)/(?:${suffix})`);
238
260
  const re = new RegExp(`(${prefixRe.source})([^\\s"';&|<>()\x60]*|$)`, "g");
239
261
  let m: RegExpExecArray | null;
240
262
  let foundExplicit = false;
@@ -246,7 +268,8 @@ export function bashTargets(command: string, home: string, cwd: string): PathTar
246
268
 
247
269
  if (!foundExplicit && (op === "delete" || op === "write")) {
248
270
  const base =
249
- cwdUnderRepos ?? (cwd === root || cwd.startsWith(`${root}${sep}`) ? cwd : null);
271
+ cwdUnderRepos ??
272
+ (roots.some((root) => cwd === root || cwd.startsWith(`${root}${sep}`)) ? cwd : null);
250
273
  if (base) {
251
274
  const relMatch = segment.match(/(?:^|\s)(\.\.?|[^\s"';&|<>()\x60/]+(?:\/[^\s"';&|<>()\x60]*)?)\s*$/);
252
275
  if (relMatch) {
@@ -282,7 +305,7 @@ export function evaluate(input: CodewithHookInput): { output: CodewithHookOutput
282
305
 
283
306
  const tool = typeof input.tool_name === "string" ? input.tool_name : "";
284
307
  const home = homedir();
285
- const root = reposRoot(home);
308
+ const roots = protectedRoots(home);
286
309
  const orgs = resolveAllowedOrgs();
287
310
 
288
311
  if (tool === "Bash") {
@@ -291,9 +314,11 @@ export function evaluate(input: CodewithHookInput): { output: CodewithHookOutput
291
314
  const cwd = typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd();
292
315
  const targets = bashTargets(command, home, cwd);
293
316
  for (const target of targets) {
294
- const verdict = classifyPath(target.path, root, orgs, target.op);
295
- if (verdict.blocked) {
296
- return { output: { decision: "block", reason: verdict.reason }, warnings };
317
+ for (const root of roots) {
318
+ const verdict = classifyPath(target.path, root, orgs, target.op);
319
+ if (verdict.blocked) {
320
+ return { output: { decision: "block", reason: verdict.reason }, warnings };
321
+ }
297
322
  }
298
323
  }
299
324
  return { output: { continue: true }, warnings };
@@ -307,9 +332,11 @@ export function evaluate(input: CodewithHookInput): { output: CodewithHookOutput
307
332
  if (!patch) return { output: { continue: true }, warnings };
308
333
  const targets = patchTargets(patch, cwd);
309
334
  for (const target of targets) {
310
- const verdict = classifyPath(target.path, root, orgs, target.op);
311
- if (verdict.blocked) {
312
- return { output: { decision: "block", reason: verdict.reason }, warnings };
335
+ for (const root of roots) {
336
+ const verdict = classifyPath(target.path, root, orgs, target.op);
337
+ if (verdict.blocked) {
338
+ return { output: { decision: "block", reason: verdict.reason }, warnings };
339
+ }
313
340
  }
314
341
  }
315
342
  return { output: { continue: true }, warnings };
@@ -329,9 +356,11 @@ export function evaluate(input: CodewithHookInput): { output: CodewithHookOutput
329
356
 
330
357
  const expanded = expandHomeSpelling(raw, home);
331
358
  const target = isAbsolute(expanded) ? normalize(expanded) : resolve(cwd, expanded);
332
- const verdict = classifyPath(target, root, orgs, "write");
333
- if (verdict.blocked) {
334
- return { output: { decision: "block", reason: verdict.reason }, warnings };
359
+ for (const root of roots) {
360
+ const verdict = classifyPath(target, root, orgs, "write");
361
+ if (verdict.blocked) {
362
+ return { output: { decision: "block", reason: verdict.reason }, warnings };
363
+ }
335
364
  }
336
365
  return { output: { continue: true }, warnings };
337
366
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/hooks",
3
- "version": "0.7.7",
3
+ "version": "0.7.9",
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": {
@@ -52,11 +52,11 @@
52
52
  "@types/react": "^18.2.0",
53
53
  "typescript": "^5",
54
54
  "bun-types": "1.3.14",
55
- "@hasna/contracts": "0.13.4"
55
+ "@hasna/contracts": "0.14.0"
56
56
  },
57
57
  "dependencies": {
58
58
  "@hasna/events": "^0.1.16",
59
- "@hasna/secrets": "0.3.7",
59
+ "@hasna/secrets": "0.3.10",
60
60
  "@modelcontextprotocol/sdk": "^1.26.0",
61
61
  "chalk": "^5.3.0",
62
62
  "commander": "^12.1.0",
@@ -68,7 +68,7 @@
68
68
  "pg": "^8.16.3",
69
69
  "react": "^18.2.0",
70
70
  "zod": "3.25.76",
71
- "@hasna/contracts": "0.13.4"
71
+ "@hasna/contracts": "0.14.0"
72
72
  },
73
73
  "engines": {
74
74
  "bun": ">=1.0.0"