@nurix/nustack 0.23.22 → 0.23.23
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/dist/commands/gate.js
CHANGED
|
@@ -5,7 +5,7 @@ import { readDesktopSettings } from "../lib/desktop_settings.js";
|
|
|
5
5
|
import { loadOutlineLibrary } from "../lib/gate/outline.js";
|
|
6
6
|
import { isRedirectOn } from "../lib/gate/redirect.js";
|
|
7
7
|
import { resolveProjectRoot } from "../lib/graph/projectRoot.js";
|
|
8
|
-
import { resolveThreshold } from "../lib/gate/settings.js";
|
|
8
|
+
import { resolveCoin, resolveThreshold } from "../lib/gate/settings.js";
|
|
9
9
|
import { printJsonSuccess } from "../lib/json_output.js";
|
|
10
10
|
import { readStoreVersion } from "../lib/local_store/database.js";
|
|
11
11
|
export const GATE_HOOK_TIMEOUT_SECONDS = 10;
|
|
@@ -56,6 +56,7 @@ export async function readGateStatus() {
|
|
|
56
56
|
isInSync: localReader ? isInstalled : isUninstalled,
|
|
57
57
|
thresholdTokens,
|
|
58
58
|
thresholdSource,
|
|
59
|
+
coin: resolveCoin().coin,
|
|
59
60
|
outline: library !== null && typeof library.outlineSource === "function" ? "tree-sitter" : "fallback",
|
|
60
61
|
storeVersion: await readStoreVersion(),
|
|
61
62
|
redirect: isRedirectOn(resolveProjectRoot(process.env.CLAUDE_PROJECT_DIR, process.cwd())),
|
|
@@ -72,6 +73,7 @@ export async function runGateStatus(options) {
|
|
|
72
73
|
`Read hook: ${onOff(status.installed.read)} · Bash hook: ${onOff(status.installed.bash)}`,
|
|
73
74
|
`Local Reader switch: ${status.localReader ? "on" : "off"} (${status.isInSync ? "in sync with the hooks" : "out of sync with the hooks — run `nustack gate install` or `nustack gate remove`"})`,
|
|
74
75
|
`Threshold: ${status.thresholdTokens} tokens (from ${status.thresholdSource})`,
|
|
76
|
+
...(status.coin === null ? [] : [`Coin: rate ${status.coin.rate}, seed ${JSON.stringify(status.coin.seed)} — replaces the size test`]),
|
|
75
77
|
`Outline: ${status.outline}`,
|
|
76
78
|
`Local store version: ${status.storeVersion ?? "no store"}`,
|
|
77
79
|
`Large-read redirect: ${status.redirect.isOn ? "on" : "off"} (from ${status.redirect.source})`,
|
|
@@ -199,3 +199,16 @@ export function assessBashRead(file, range, thresholdTokens, minimumThresholdTok
|
|
|
199
199
|
return { decision: "pass", reason: "under-threshold", source: "read", estimatedTokens };
|
|
200
200
|
return { decision: "gate", source: "read", estimatedTokens, thresholdTokens: threshold };
|
|
201
201
|
}
|
|
202
|
+
export function assessBashReadByCoin(file, range, isSelected, env = process.env) {
|
|
203
|
+
const size = regularFileSize(file);
|
|
204
|
+
if (size === null)
|
|
205
|
+
return { decision: "pass", reason: "not-a-file", source: "stat", estimatedTokens: null };
|
|
206
|
+
if (isSkippedExtension(file))
|
|
207
|
+
return { decision: "pass", reason: "skipped-extension", source: "stat", estimatedTokens: null };
|
|
208
|
+
if (hasLeadingNul(file))
|
|
209
|
+
return { decision: "pass", reason: "binary", source: "read", estimatedTokens: null };
|
|
210
|
+
const estimatedTokens = Math.min(tokensOfLength(printedBytes(file, size, range)), tokensOfLength(bashMaxOutputLength(env)));
|
|
211
|
+
if (!isSelected())
|
|
212
|
+
return { decision: "pass", reason: "coin-not-selected", source: "read", estimatedTokens };
|
|
213
|
+
return { decision: "gate", source: "read", estimatedTokens, thresholdTokens: null };
|
|
214
|
+
}
|
|
@@ -88,3 +88,17 @@ export function assessFullRead(file, thresholdTokens, minimumThresholdTokens) {
|
|
|
88
88
|
return { decision: "pass", reason: "under-threshold", source: "read", estimatedTokens };
|
|
89
89
|
return { decision: "gate", source: "read", estimatedTokens, thresholdTokens: threshold };
|
|
90
90
|
}
|
|
91
|
+
export function assessFullReadByCoin(file, isSelected) {
|
|
92
|
+
const size = regularFileSize(file);
|
|
93
|
+
if (size === null)
|
|
94
|
+
return { decision: "pass", reason: "not-a-file", source: "stat", estimatedTokens: null };
|
|
95
|
+
if (isSkippedExtension(file))
|
|
96
|
+
return { decision: "pass", reason: "skipped-extension", source: "stat", estimatedTokens: null };
|
|
97
|
+
const scan = readLeadingLines(file, READ_DEFAULT_LINE_LIMIT);
|
|
98
|
+
if (scan.isBinary)
|
|
99
|
+
return { decision: "pass", reason: "binary", source: "read", estimatedTokens: null };
|
|
100
|
+
const estimatedTokens = estimateReadTokens(scan);
|
|
101
|
+
if (!isSelected())
|
|
102
|
+
return { decision: "pass", reason: "coin-not-selected", source: "read", estimatedTokens };
|
|
103
|
+
return { decision: "gate", source: "read", estimatedTokens, thresholdTokens: null };
|
|
104
|
+
}
|
package/dist/lib/gate/hook.js
CHANGED
|
@@ -2,6 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
import { performance } from "node:perf_hooks";
|
|
3
3
|
import { classifyBashRead } from "./bashRead.js";
|
|
4
4
|
import { GateInputError, gateBashCall, gateReadCall } from "./readGate.js";
|
|
5
|
+
import { GATE_COIN_RATE_ENV, resolveCoin } from "./settings.js";
|
|
5
6
|
const TTY_NOTICE = "nustack gate: hook entry point; reads Claude Code's PreToolUse JSON on stdin\n";
|
|
6
7
|
async function readStdin(stdin) {
|
|
7
8
|
const chunks = [];
|
|
@@ -113,11 +114,14 @@ export async function runGateHook(subcommand, io = { stdin: process.stdin, stdou
|
|
|
113
114
|
return;
|
|
114
115
|
}
|
|
115
116
|
const call = parseCall(await readStdin(io.stdin), startedAt);
|
|
117
|
+
const { coin, rejected } = resolveCoin(io.env);
|
|
118
|
+
if (rejected !== null)
|
|
119
|
+
io.stderr.write(`nustack gate: GATE_COIN_RATE_INVALID: ${GATE_COIN_RATE_ENV}=${JSON.stringify(rejected)} is not a number in [0, 1]; the size test applies\n`);
|
|
116
120
|
let answer;
|
|
117
121
|
if (subcommand === "read")
|
|
118
|
-
answer = await gateReadCall(call, io.env);
|
|
122
|
+
answer = await gateReadCall(call, io.env, {}, coin);
|
|
119
123
|
else if (subcommand === "bash")
|
|
120
|
-
answer = await gateBashCall(call, io.env);
|
|
124
|
+
answer = await gateBashCall(call, io.env, coin);
|
|
121
125
|
else
|
|
122
126
|
throw new GateInputError(`unknown hook "${subcommand}"`);
|
|
123
127
|
if (answer.decision === "deny")
|
|
@@ -2,10 +2,10 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { closeSync, openSync, readFileSync, readSync, realpathSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { performance } from "node:perf_hooks";
|
|
5
|
-
import { assessBashRead, classifyBashRead } from "./bashRead.js";
|
|
6
|
-
import { assessFullRead, readLeadingLines, regularFileSize, tokensOfLength } from "./estimate.js";
|
|
5
|
+
import { assessBashRead, assessBashReadByCoin, classifyBashRead } from "./bashRead.js";
|
|
6
|
+
import { assessFullRead, assessFullReadByCoin, readLeadingLines, regularFileSize, tokensOfLength } from "./estimate.js";
|
|
7
7
|
import { assessRedirect, redirectHandler } from "./redirect.js";
|
|
8
|
-
import { GATE_MIN_THRESHOLD_TOKENS, isGateOff, resolveThreshold } from "./settings.js";
|
|
8
|
+
import { GATE_MIN_THRESHOLD_TOKENS, isCoinSelected, isGateOff, resolveCoin, resolveThreshold } from "./settings.js";
|
|
9
9
|
export class GateInputError extends Error {
|
|
10
10
|
code = "HOOK_INPUT_INVALID";
|
|
11
11
|
constructor(message) {
|
|
@@ -27,7 +27,10 @@ function realPathOf(file) {
|
|
|
27
27
|
return file;
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
-
|
|
30
|
+
function coinSelects(coin, call, resolved) {
|
|
31
|
+
return () => isCoinSelected(coin, call.sessionId, realPathOf(resolved));
|
|
32
|
+
}
|
|
33
|
+
export async function gateReadCall(call, env = process.env, deps = {}, coin = resolveCoin(env).coin) {
|
|
31
34
|
if (isGateOff(env))
|
|
32
35
|
return PASS;
|
|
33
36
|
const filePath = call.toolInput.file_path;
|
|
@@ -37,13 +40,15 @@ export async function gateReadCall(call, env = process.env, deps = {}) {
|
|
|
37
40
|
return PASS;
|
|
38
41
|
const resolved = resolvePath(call.cwd, filePath);
|
|
39
42
|
const redirect = await assessRedirect(resolved, call.cwd, env, deps);
|
|
40
|
-
const assessment =
|
|
43
|
+
const assessment = coin === null
|
|
44
|
+
? assessFullRead(resolved, () => resolveThreshold(env).thresholdTokens, GATE_MIN_THRESHOLD_TOKENS)
|
|
45
|
+
: assessFullReadByCoin(resolved, coinSelects(coin, call, resolved));
|
|
41
46
|
if (assessment.decision === "gate" || !redirect.isRedirect)
|
|
42
47
|
return answerAssessment(call, resolved, assessment, "read");
|
|
43
48
|
const { LABEL_ROW_HANDLERS } = await import("../local_store/routing.js");
|
|
44
49
|
return answerLargeRead(call, resolved, redirect.estimatedTokens, "read", redirectHandler(LABEL_ROW_HANDLERS));
|
|
45
50
|
}
|
|
46
|
-
export async function gateBashCall(call, env = process.env) {
|
|
51
|
+
export async function gateBashCall(call, env = process.env, coin = resolveCoin(env).coin) {
|
|
47
52
|
if (isGateOff(env))
|
|
48
53
|
return PASS;
|
|
49
54
|
const command = call.toolInput.command;
|
|
@@ -53,7 +58,9 @@ export async function gateBashCall(call, env = process.env) {
|
|
|
53
58
|
if (read === null)
|
|
54
59
|
return PASS;
|
|
55
60
|
const resolved = resolvePath(call.cwd, read.path);
|
|
56
|
-
const assessment =
|
|
61
|
+
const assessment = coin === null
|
|
62
|
+
? assessBashRead(resolved, read.range, () => resolveThreshold(env).thresholdTokens, GATE_MIN_THRESHOLD_TOKENS, env)
|
|
63
|
+
: assessBashReadByCoin(resolved, read.range, coinSelects(coin, call, resolved), env);
|
|
57
64
|
return answerAssessment(call, resolved, assessment, "bash");
|
|
58
65
|
}
|
|
59
66
|
async function answerAssessment(call, resolved, assessment, tool) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { DIGEST_BUDGET_MS_MAX, DIGEST_BUDGET_MS_MIN, GATE_THRESHOLD_TOKENS_MAX, GATE_THRESHOLD_TOKENS_MIN, readDesktopSettings, } from "../desktop_settings.js";
|
|
2
3
|
export const NUSTACK_GATE_ENV = "NUSTACK_GATE";
|
|
3
4
|
export const NUSTACK_GATE_THRESHOLD_ENV = "NUSTACK_GATE_THRESHOLD_TOKENS";
|
|
@@ -45,3 +46,24 @@ export function resolveReadBulkBudget(flagBudgetMs, env = process.env, readSetti
|
|
|
45
46
|
return { budgetMs: fromSettings, budgetSource: "settings" };
|
|
46
47
|
return { budgetMs: null, budgetSource: "none" };
|
|
47
48
|
}
|
|
49
|
+
export const GATE_COIN_RATE_ENV = "NUSTACK_GATE_COIN_RATE";
|
|
50
|
+
export const GATE_COIN_SEED_ENV = "NUSTACK_GATE_COIN_SEED";
|
|
51
|
+
export const GATE_COIN_DEFAULT_SEED = "0";
|
|
52
|
+
const COIN_RATE_PATTERN = /^(?:\d+(?:\.\d*)?|\.\d+)$/;
|
|
53
|
+
export function resolveCoin(env = process.env) {
|
|
54
|
+
const text = env[GATE_COIN_RATE_ENV]?.trim();
|
|
55
|
+
if (text === undefined || text === "")
|
|
56
|
+
return { coin: null, rejected: null };
|
|
57
|
+
const rate = COIN_RATE_PATTERN.test(text) ? Number(text) : Number.NaN;
|
|
58
|
+
if (!(rate >= 0 && rate <= 1))
|
|
59
|
+
return { coin: null, rejected: text };
|
|
60
|
+
const seed = env[GATE_COIN_SEED_ENV];
|
|
61
|
+
return { coin: { rate, seed: seed === undefined || seed === "" ? GATE_COIN_DEFAULT_SEED : seed }, rejected: null };
|
|
62
|
+
}
|
|
63
|
+
export function coinDraw(seed, sessionId, realPath) {
|
|
64
|
+
const digest = createHash("sha256").update(`${seed}\0${sessionId}\0${realPath}`).digest("hex");
|
|
65
|
+
return Number.parseInt(digest.slice(0, 8), 16) / 2 ** 32;
|
|
66
|
+
}
|
|
67
|
+
export function isCoinSelected(coin, sessionId, realPath) {
|
|
68
|
+
return coinDraw(coin.seed, sessionId, realPath) < coin.rate;
|
|
69
|
+
}
|