@hasna/hooks 0.7.6 → 0.7.8
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
|
|
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)(
|
|
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;
|
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
|
|
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
|
|
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
|
|
6
|
-
|
|
7
|
-
`
|
|
8
|
-
`$HOME/workspace/repos
|
|
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/
|
|
13
|
-
|
|
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
|
|
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
|
-
(`
|
|
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"/
|
|
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
|
|
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, "
|
|
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
|
|
79
|
-
`echo x >
|
|
80
|
-
`mkdir -p "${HOME}/
|
|
81
|
-
`touch "$HOME"/
|
|
82
|
-
`echo x > "\${HOME}"/
|
|
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:
|
|
93
|
-
evaluate(preToolUse("Edit", { file_path: `$HOME/
|
|
94
|
-
evaluate(preToolUse("Write", { file_path: `"${HOME}/
|
|
95
|
-
evaluate(preToolUse("Write", { file_path: `"$HOME"/
|
|
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
|
|
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
|
|
211
|
-
`rm -rf
|
|
212
|
-
`rm -rf $HOME/
|
|
213
|
-
`rm -rf "${HOME}/
|
|
214
|
-
`rm -rf "$HOME"/
|
|
215
|
-
`rm -rf "$HOME"/
|
|
216
|
-
`rm -rf "\${HOME}"/
|
|
217
|
-
`rm -rf "${HOME}"/
|
|
218
|
-
`touch
|
|
219
|
-
`mkdir -p
|
|
220
|
-
`echo x >
|
|
221
|
-
`mv /tmp/x
|
|
222
|
-
`truncate -s 0
|
|
223
|
-
`rsync -a /tmp/x
|
|
224
|
-
`scp f.txt
|
|
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:
|
|
235
|
-
evaluate(preToolUse("Write", { file_path: `$HOME/
|
|
236
|
-
evaluate(preToolUse("Edit", { file_path:
|
|
237
|
-
evaluate(preToolUse("Write", { file_path:
|
|
238
|
-
evaluate(preToolUse("Write", { file_path: `"$HOME"/
|
|
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
|
|
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/'
|
|
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('
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* $HOME/workspace/repos
|
|
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
|
|
16
|
-
* - any write that would create a top-level entry under
|
|
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
|
|
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
|
|
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
|
|
84
|
-
* only: a write at depth >= 2 inside an allowed org passes,
|
|
85
|
-
* (deletes at any depth, writes at the root or org level,
|
|
86
|
-
* non-allowed org) is blocked. Paths outside
|
|
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
|
|
94
|
-
return { blocked: true, reason: `[${RULE}] writes directly to the
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
212
|
-
* `~/...`, `$HOME/...`, `${HOME}/...` and literal-home references
|
|
213
|
-
* before classification)
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
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
|
|
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}`)))
|
|
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
|
|
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 ??
|
|
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
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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.
|
|
3
|
+
"version": "0.7.8",
|
|
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.
|
|
55
|
+
"@hasna/contracts": "0.14.0"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
58
|
"@hasna/events": "^0.1.16",
|
|
59
|
-
"@hasna/secrets": "0.3.
|
|
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.
|
|
71
|
+
"@hasna/contracts": "0.14.0"
|
|
72
72
|
},
|
|
73
73
|
"engines": {
|
|
74
74
|
"bun": ">=1.0.0"
|