@nurix/nustack 0.23.11 → 0.23.13
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/check.js +73 -0
- package/dist/commands/deploy.js +2 -1
- package/dist/commands/doctor.js +2 -1
- package/dist/commands/harness.js +127 -48
- package/dist/commands/init.js +103 -3
- package/dist/commands/login.js +9 -2
- package/dist/commands/mcp.js +7 -0
- package/dist/commands/project-sync.js +2 -1
- package/dist/commands/records.js +10 -0
- package/dist/commands/review.js +3 -2
- package/dist/commands/sessions.js +4 -1
- package/dist/commands/sync.js +2 -1
- package/dist/index.js +4 -0
- package/dist/lib/agent/claudeHooks.js +4 -1
- package/dist/lib/auth_grant.js +15 -3
- package/dist/lib/auth_recovery.js +10 -3
- package/dist/lib/etna/manager.js +24 -6
- package/dist/lib/etna/profile.js +6 -0
- package/dist/lib/etna/reconcile.js +6 -0
- package/dist/lib/harness/codes.js +24 -0
- package/dist/lib/harness/hookCheck.js +325 -0
- package/dist/lib/harness/hookEntry.js +27 -0
- package/dist/lib/harness/legacyMount.js +231 -0
- package/dist/lib/harness/mount.js +199 -131
- package/dist/lib/harness/storeWaivers.js +43 -0
- package/dist/lib/harness/vendor.js +29 -0
- package/dist/lib/harness/versionsClient.js +54 -0
- package/dist/lib/local_store/database.js +18 -0
- package/dist/lib/nustack_client.js +35 -2
- package/dist/lib/onboarding/attach_existing_git.js +14 -2
- package/dist/lib/onboarding/binding.js +46 -2
- package/dist/lib/onboarding/commit_trailer_hook.js +66 -0
- package/dist/lib/onboarding/manifest_writer.js +19 -3
- package/dist/lib/onboarding/session_hook.js +76 -0
- package/dist/lib/origin_guard.js +43 -0
- package/dist/lib/project_contract.js +104 -0
- package/dist/lib/records/door.js +26 -19
- package/dist/lib/records/drain.js +10 -4
- package/dist/lib/records/refusalLog.js +30 -1
- package/dist/lib/session-engine/supervisor.js +6 -18
- package/dist/lib/sessions/client.js +0 -12
- package/dist/program.js +14 -3
- package/dist/vendor/harness/assemble/runtimes.js +32 -0
- package/dist/vendor/harness/assemble/settingsMerge.js +136 -0
- package/dist/vendor/harness/digest/index.js +38 -0
- package/dist/vendor/harness/lib/findings.js +32 -0
- package/dist/vendor/harness/lib/frontmatter.js +79 -0
- package/dist/vendor/harness/lib/paths.js +31 -0
- package/dist/vendor/harness/mount/cache.js +65 -0
- package/dist/vendor/harness/mount/exclude.js +77 -0
- package/dist/vendor/harness/mount/files.js +38 -0
- package/dist/vendor/harness/mount/index.js +352 -0
- package/dist/vendor/harness/rules/changeRules.js +162 -0
- package/dist/vendor/harness/rules/context.js +38 -0
- package/dist/vendor/harness/rules/docsRoot.js +34 -0
- package/dist/vendor/harness/rules/index.js +109 -0
- package/dist/vendor/harness/rules/node.js +144 -0
- package/dist/vendor/harness/rules/outputGate.js +28 -0
- package/dist/vendor/harness/rules/ownership.js +178 -0
- package/dist/vendor/harness/rules/packageTable.js +113 -0
- package/dist/vendor/harness/rules/plans.js +96 -0
- package/dist/vendor/harness/rules/sessionWalls.js +31 -0
- package/package.json +5 -5
|
@@ -9,6 +9,9 @@ function managedEntry(matcher, command, options) {
|
|
|
9
9
|
hook.timeout = options.timeout;
|
|
10
10
|
return { matcher, hooks: [hook] };
|
|
11
11
|
}
|
|
12
|
+
function isManagedCommand(command) {
|
|
13
|
+
return command.trimEnd().endsWith(`# ${MANAGED_MARKER}`);
|
|
14
|
+
}
|
|
12
15
|
function carriesMarker(entry) {
|
|
13
16
|
if (entry === null || typeof entry !== "object")
|
|
14
17
|
return false;
|
|
@@ -18,7 +21,7 @@ function carriesMarker(entry) {
|
|
|
18
21
|
return hooks.some((hook) => hook !== null &&
|
|
19
22
|
typeof hook === "object" &&
|
|
20
23
|
typeof hook.command === "string" &&
|
|
21
|
-
hook.command
|
|
24
|
+
isManagedCommand(hook.command));
|
|
22
25
|
}
|
|
23
26
|
function isManagedEntry(entry, matcher) {
|
|
24
27
|
if (!carriesMarker(entry))
|
package/dist/lib/auth_grant.js
CHANGED
|
@@ -3,6 +3,7 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
3
3
|
import { getOrCreateDeviceId } from "./device_identity.js";
|
|
4
4
|
import { deleteDeviceCredential, readCredentialFailure, readDeviceCredential, writeDeviceCredential } from "./device_keychain.js";
|
|
5
5
|
import { openLoopbackListener } from "./loopback.js";
|
|
6
|
+
import { originMismatchError, refusedContractOrigin } from "./origin_guard.js";
|
|
6
7
|
const REQUEST_TIMEOUT_MS = 10_000;
|
|
7
8
|
const BOOTSTRAP_TIMEOUT_MS = 25_000;
|
|
8
9
|
const LOOPBACK_TIMEOUT_MS = 10 * 60_000;
|
|
@@ -160,11 +161,14 @@ export async function readStoredCredential() {
|
|
|
160
161
|
credential: { deviceToken: credential.device_token, studioBaseUrl: credential.studio_base_url, deviceId, userEmail: credential.user_email },
|
|
161
162
|
};
|
|
162
163
|
}
|
|
163
|
-
export async function resolveAccessToken() {
|
|
164
|
+
export async function resolveAccessToken(origin) {
|
|
164
165
|
const fromEnv = envToken();
|
|
165
166
|
if (fromEnv) {
|
|
166
167
|
if (fromEnv.kind === "malformed")
|
|
167
168
|
return { kind: "malformed_token", message: fromEnv.message };
|
|
169
|
+
const refused = refusedContractOrigin(null, origin);
|
|
170
|
+
if (refused)
|
|
171
|
+
return { kind: "origin_mismatch", error: originMismatchError(refused, null, "pat") };
|
|
168
172
|
const deviceId = resolvedDeviceId ?? (await getOrCreateDeviceId());
|
|
169
173
|
resolvedDeviceId = deviceId;
|
|
170
174
|
return { kind: "ok", session: { accessToken: fromEnv.token, userEmail: "", deviceId } };
|
|
@@ -173,12 +177,20 @@ export async function resolveAccessToken() {
|
|
|
173
177
|
if (!credential) {
|
|
174
178
|
if (failure)
|
|
175
179
|
lastDeadReason = failure;
|
|
180
|
+
const refused = refusedContractOrigin(null, origin);
|
|
181
|
+
if (refused)
|
|
182
|
+
return { kind: "origin_mismatch", error: originMismatchError(refused, null, "none") };
|
|
176
183
|
return { kind: "none", reason: failure };
|
|
177
184
|
}
|
|
185
|
+
const refused = refusedContractOrigin(credential.studioBaseUrl, origin);
|
|
186
|
+
if (refused)
|
|
187
|
+
return { kind: "origin_mismatch", error: originMismatchError(refused, credential.studioBaseUrl, "device") };
|
|
178
188
|
return { kind: "ok", session: { accessToken: credential.deviceToken, userEmail: credential.userEmail, deviceId } };
|
|
179
189
|
}
|
|
180
|
-
export async function ensureAccessToken() {
|
|
181
|
-
const outcome = await resolveAccessToken();
|
|
190
|
+
export async function ensureAccessToken(origin) {
|
|
191
|
+
const outcome = await resolveAccessToken(origin);
|
|
192
|
+
if (outcome.kind === "origin_mismatch")
|
|
193
|
+
throw outcome.error;
|
|
182
194
|
return outcome.kind === "ok" ? { accessToken: outcome.session.accessToken, userEmail: outcome.session.userEmail } : null;
|
|
183
195
|
}
|
|
184
196
|
export async function forgetRefusedToken(staleToken) {
|
|
@@ -2,6 +2,7 @@ import * as p from "@clack/prompts";
|
|
|
2
2
|
import { deadCredentialCopy, ensureAccessToken, resolveAccessToken } from "./auth_grant.js";
|
|
3
3
|
import { performDeviceLogin } from "../commands/login.js";
|
|
4
4
|
import { isInteractive } from "./interactive.js";
|
|
5
|
+
import { originMismatchError, refusedContractOrigin } from "./origin_guard.js";
|
|
5
6
|
let lastFailure = { code: "SIGNED_OUT", message: deadCredentialCopy(null), retryable: false };
|
|
6
7
|
export function lastAuthFailure() {
|
|
7
8
|
return lastFailure;
|
|
@@ -12,6 +13,8 @@ function describeFailure(outcome) {
|
|
|
12
13
|
}
|
|
13
14
|
if (outcome.kind === "malformed_token")
|
|
14
15
|
return { code: "TOKEN_MALFORMED", message: outcome.message, retryable: false };
|
|
16
|
+
if (outcome.kind === "origin_mismatch")
|
|
17
|
+
return { code: "ORIGIN_MISMATCH", message: outcome.error.message, retryable: false };
|
|
15
18
|
return { code: "SIGNED_OUT", message: deadCredentialCopy(outcome.reason), retryable: false };
|
|
16
19
|
}
|
|
17
20
|
function reconnectingCopy(outcome) {
|
|
@@ -24,7 +27,7 @@ function reconnectingCopy(outcome) {
|
|
|
24
27
|
export async function ensureAuthWithRecovery(baseUrl, json) {
|
|
25
28
|
let outcome;
|
|
26
29
|
try {
|
|
27
|
-
outcome = await resolveAccessToken();
|
|
30
|
+
outcome = await resolveAccessToken(baseUrl);
|
|
28
31
|
}
|
|
29
32
|
catch {
|
|
30
33
|
lastFailure = describeFailure("unreachable");
|
|
@@ -33,13 +36,17 @@ export async function ensureAuthWithRecovery(baseUrl, json) {
|
|
|
33
36
|
if (outcome.kind === "ok")
|
|
34
37
|
return { accessToken: outcome.session.accessToken, userEmail: outcome.session.userEmail };
|
|
35
38
|
lastFailure = describeFailure(outcome);
|
|
36
|
-
if (json || !isInteractive() || outcome.kind === "malformed_token")
|
|
39
|
+
if (json || !isInteractive() || outcome.kind === "malformed_token" || outcome.kind === "origin_mismatch")
|
|
37
40
|
return null;
|
|
41
|
+
if (refusedContractOrigin(null, baseUrl)) {
|
|
42
|
+
lastFailure = describeFailure({ kind: "origin_mismatch", error: originMismatchError(baseUrl, null, "none") });
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
38
45
|
p.log.warn(reconnectingCopy(outcome));
|
|
39
46
|
const didLogin = await performDeviceLogin(baseUrl, process.cwd(), { reauthorize: true });
|
|
40
47
|
if (!didLogin)
|
|
41
48
|
return null;
|
|
42
|
-
return ensureAccessToken().catch(() => {
|
|
49
|
+
return ensureAccessToken(baseUrl).catch(() => {
|
|
43
50
|
lastFailure = describeFailure("unreachable");
|
|
44
51
|
return null;
|
|
45
52
|
});
|
package/dist/lib/etna/manager.js
CHANGED
|
@@ -2,7 +2,17 @@ import { detectPackageManager, installPackage } from "../package_manager.js";
|
|
|
2
2
|
import { assertSafePackageName } from "../guards.js";
|
|
3
3
|
import { updateBindingEtnaProfile } from "../local_store/bindings.js";
|
|
4
4
|
import { serializeProfileForBinding } from "./profile.js";
|
|
5
|
-
import {
|
|
5
|
+
import { mountProjectHarness } from "../harness/mount.js";
|
|
6
|
+
export function profileNamesHarness(profile) {
|
|
7
|
+
return Boolean(profile.harnessDigest || profile.versionManifestPath || profile.manifestUrl);
|
|
8
|
+
}
|
|
9
|
+
export function mountReportOf(outcome) {
|
|
10
|
+
return { status: outcome.status, source: outcome.source, version: outcome.version, digest: outcome.digest };
|
|
11
|
+
}
|
|
12
|
+
export function mountFailureReport(error) {
|
|
13
|
+
const code = error?.code;
|
|
14
|
+
return { status: "failed", code: typeof code === "string" ? code : "HARNESS_MOUNT_FAILED", message: error instanceof Error ? error.message : String(error) };
|
|
15
|
+
}
|
|
6
16
|
export function toolkitPackageName(profile) {
|
|
7
17
|
if (!profile.name.startsWith("@"))
|
|
8
18
|
return null;
|
|
@@ -16,14 +26,22 @@ export function toolkitPackageName(profile) {
|
|
|
16
26
|
export async function installToolkit(store, bindingId, profile, cwd, deps = {}) {
|
|
17
27
|
const packageName = toolkitPackageName(profile);
|
|
18
28
|
let result;
|
|
19
|
-
|
|
20
|
-
|
|
29
|
+
if (deps.skipMount) {
|
|
30
|
+
result = { ok: true, installed: false, skipped: true, reason: "skipped_etna_mount", mount: { status: "skipped_etna_mount" } };
|
|
31
|
+
}
|
|
32
|
+
else if (profileNamesHarness(profile)) {
|
|
21
33
|
try {
|
|
22
|
-
await (deps.mount ??
|
|
23
|
-
|
|
34
|
+
const outcome = await (deps.mount ?? mountProjectHarness)(cwd, {
|
|
35
|
+
projectId: deps.projectId,
|
|
36
|
+
globals: deps.globals,
|
|
37
|
+
writeContract: deps.writeContract ?? "never",
|
|
38
|
+
runtime: "claude-code",
|
|
39
|
+
});
|
|
40
|
+
result = { ok: true, installed: true, skipped: false, reason: "mounted", mount: mountReportOf(outcome) };
|
|
24
41
|
}
|
|
25
42
|
catch (error) {
|
|
26
|
-
|
|
43
|
+
const mount = mountFailureReport(error);
|
|
44
|
+
result = { ok: false, installed: false, skipped: false, reason: mount.message, mount };
|
|
27
45
|
}
|
|
28
46
|
}
|
|
29
47
|
else if (!packageName) {
|
package/dist/lib/etna/profile.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getProject } from "../nustack_client.js";
|
|
2
|
+
import { contractFieldsFromSummary } from "../project_contract.js";
|
|
2
3
|
export class ToolkitProfileUnavailableError extends Error {
|
|
3
4
|
projectId;
|
|
4
5
|
constructor(projectId) {
|
|
@@ -21,6 +22,11 @@ export async function fetchToolkitProfile(baseUrl, accessToken, projectId) {
|
|
|
21
22
|
catalogVersionId: null,
|
|
22
23
|
harnessVersion: project.harness?.version ?? null,
|
|
23
24
|
manifestUrl: project.harness?.manifestUrl ?? null,
|
|
25
|
+
harnessTrack: project.harness?.track ?? null,
|
|
26
|
+
harnessDigest: project.harness?.digest ?? null,
|
|
27
|
+
versionManifestPath: project.harness?.versionManifestPath ?? null,
|
|
28
|
+
branchPolicy: project.branchPolicy === "trunk" || project.branchPolicy === "branch" ? project.branchPolicy : null,
|
|
29
|
+
contractFields: contractFieldsFromSummary(project),
|
|
24
30
|
};
|
|
25
31
|
}
|
|
26
32
|
export function toolkitProfileFromManifest(manifest) {
|
|
@@ -22,6 +22,12 @@ export function parseRecordedToolkit(binding) {
|
|
|
22
22
|
export function toolkitCompatibility(mounted, cloud) {
|
|
23
23
|
if (!mounted || !cloud)
|
|
24
24
|
return { compatible: true, blocksWrites: false, reason: null };
|
|
25
|
+
if (mounted.digest && cloud.harnessDigest) {
|
|
26
|
+
if (mounted.digest !== cloud.harnessDigest) {
|
|
27
|
+
return { compatible: false, blocksWrites: true, reason: `mounted harness digest ${mounted.digest.slice(0, 12)} differs from the expected ${cloud.harnessDigest.slice(0, 12)}` };
|
|
28
|
+
}
|
|
29
|
+
return { compatible: true, blocksWrites: false, reason: null };
|
|
30
|
+
}
|
|
25
31
|
const pinned = cloud.harnessVersion ?? cloud.version;
|
|
26
32
|
if (mounted.version !== pinned) {
|
|
27
33
|
return { compatible: false, blocksWrites: true, reason: `mounted harness ${mounted.version} differs from the cloud pin ${pinned}` };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const HARNESS_CODES = {
|
|
2
|
+
HARNESS_UNAVAILABLE: "HARNESS_UNAVAILABLE",
|
|
3
|
+
HARNESS_MOUNT_FAILED: "HARNESS_MOUNT_FAILED",
|
|
4
|
+
HARNESS_DIGEST_MISMATCH: "HARNESS_DIGEST_MISMATCH",
|
|
5
|
+
HARNESS_OFFLINE_NO_CACHE: "HARNESS_OFFLINE_NO_CACHE",
|
|
6
|
+
ORIGIN_MISMATCH: "ORIGIN_MISMATCH",
|
|
7
|
+
ETNA_MOUNT_PRESENT: "ETNA_MOUNT_PRESENT",
|
|
8
|
+
INIT_NEEDS_PROJECT: "INIT_NEEDS_PROJECT",
|
|
9
|
+
INIT_NEEDS_DEVICE: "INIT_NEEDS_DEVICE",
|
|
10
|
+
};
|
|
11
|
+
export const TRAILER_HOOK_VALUES = ["installed", "skipped_hooks_path", "skipped_foreign_hook", "skipped_not_git"];
|
|
12
|
+
export const SESSION_HOOK_VALUES = ["installed", "present", "skipped_unparsable", "skipped_etna_mount"];
|
|
13
|
+
export const MOUNT_STATUS_VALUES = ["mounted", "current", "failed", "skipped_etna_mount"];
|
|
14
|
+
export const MOUNT_SOURCE_VALUES = ["network", "cache", "legacy"];
|
|
15
|
+
export const STATUS_SOURCE_VALUES = ["version", "legacy"];
|
|
16
|
+
export const STATUS_EXIT = { IN_STEP: 0, DIFFERING: 1, UNREACHABLE: 2 };
|
|
17
|
+
export class HarnessError extends Error {
|
|
18
|
+
code;
|
|
19
|
+
constructor(code, message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "HarnessError";
|
|
22
|
+
this.code = code;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { openLocalStore, openLocalStoreReadOnly } from "../local_store/database.js";
|
|
6
|
+
import { writeRecords } from "../records/recordsTable.js";
|
|
7
|
+
import { RECORD_SCHEMA_VERSION } from "../records/door.js";
|
|
8
|
+
import { idempotencyKeyOf } from "../records/naturalKey.js";
|
|
9
|
+
import { recordCredentialOf } from "../records/environment.js";
|
|
10
|
+
import { readProjectContract, CONTRACT_PATH } from "../project_contract.js";
|
|
11
|
+
import { loadHarness } from "./vendor.js";
|
|
12
|
+
import { readStoreDocumentationWaivers } from "./storeWaivers.js";
|
|
13
|
+
export const HOOK_DEADLINE_MS = 3000;
|
|
14
|
+
export const HOOK_DEADLINE_VARIABLE = "NUSTACK_HOOK_DEADLINE_MS";
|
|
15
|
+
export const ENFORCEMENT_MAP_PATH = ".nustack/enforcement.json";
|
|
16
|
+
export const HOOK_EVENTS = ["pre-tool-use", "stop"];
|
|
17
|
+
export const RULE_UNAVAILABLE_GATE = "hook.rule_unavailable";
|
|
18
|
+
export const DEADLINE_GATE = "hook.deadline";
|
|
19
|
+
const EVENT_NAMESPACE = "3f0c9a52-6d1e-4b7a-9c2f-8e5d4a1b7c60";
|
|
20
|
+
const RECORDS_STORE_VERSION = 9;
|
|
21
|
+
const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
22
|
+
export const COMMIT_RULES = new Set(["changelog", "commit-gate", "changelog-presence", "harness-checks"]);
|
|
23
|
+
class CannotRun extends Error {
|
|
24
|
+
}
|
|
25
|
+
export function uuidV5(name, namespace = EVENT_NAMESPACE) {
|
|
26
|
+
const namespaceBytes = Buffer.from(namespace.replace(/-/g, ""), "hex");
|
|
27
|
+
const hash = createHash("sha1").update(Buffer.concat([namespaceBytes, Buffer.from(name, "utf8")])).digest();
|
|
28
|
+
const bytes = Uint8Array.prototype.slice.call(hash, 0, 16);
|
|
29
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x50;
|
|
30
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
31
|
+
const hex = Buffer.from(bytes).toString("hex");
|
|
32
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
33
|
+
}
|
|
34
|
+
function parsePayload(text) {
|
|
35
|
+
let parsed;
|
|
36
|
+
try {
|
|
37
|
+
parsed = JSON.parse(text);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
throw new CannotRun("the hook payload on stdin is not JSON");
|
|
41
|
+
}
|
|
42
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
43
|
+
throw new CannotRun("the hook payload on stdin is not an object");
|
|
44
|
+
const payload = parsed;
|
|
45
|
+
return {
|
|
46
|
+
sessionId: typeof payload.session_id === "string" ? payload.session_id : null,
|
|
47
|
+
cwd: typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd(),
|
|
48
|
+
toolName: typeof payload.tool_name === "string" ? payload.tool_name : null,
|
|
49
|
+
toolInput: typeof payload.tool_input === "object" && payload.tool_input !== null ? payload.tool_input : {},
|
|
50
|
+
isStopHookActive: payload.stop_hook_active === true,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function boundRootOf(dir) {
|
|
54
|
+
let current = path.resolve(dir);
|
|
55
|
+
for (;;) {
|
|
56
|
+
if (existsSync(path.join(current, CONTRACT_PATH)))
|
|
57
|
+
return current;
|
|
58
|
+
const parent = path.dirname(current);
|
|
59
|
+
if (parent === current)
|
|
60
|
+
return null;
|
|
61
|
+
current = parent;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function readMap(root) {
|
|
65
|
+
let text;
|
|
66
|
+
try {
|
|
67
|
+
text = readFileSync(path.join(root, ENFORCEMENT_MAP_PATH), "utf8");
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new CannotRun(`no enforcement map at ${ENFORCEMENT_MAP_PATH}; run \`nustack harness mount\``);
|
|
71
|
+
}
|
|
72
|
+
let parsed;
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(text);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
throw new CannotRun(`${ENFORCEMENT_MAP_PATH} is not JSON`);
|
|
78
|
+
}
|
|
79
|
+
if (parsed.schemaVersion !== 1 || !Array.isArray(parsed.rules))
|
|
80
|
+
throw new CannotRun(`${ENFORCEMENT_MAP_PATH} is not a schemaVersion 1 map`);
|
|
81
|
+
return parsed.rules.filter((row) => typeof row === "object" && row !== null && typeof row.rule === "string" && (row.rung === "flag" || row.rung === "block"));
|
|
82
|
+
}
|
|
83
|
+
function editedPath(root, payload) {
|
|
84
|
+
const raw = payload.toolInput.file_path ?? payload.toolInput.notebook_path;
|
|
85
|
+
if (typeof raw !== "string" || raw === "")
|
|
86
|
+
return null;
|
|
87
|
+
const absolute = path.isAbsolute(raw) ? raw : path.resolve(payload.cwd, raw);
|
|
88
|
+
const relative = path.relative(root, absolute);
|
|
89
|
+
if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative))
|
|
90
|
+
return null;
|
|
91
|
+
return relative.split(path.sep).join("/");
|
|
92
|
+
}
|
|
93
|
+
function postEditText(root, relative, payload) {
|
|
94
|
+
const onDisk = (() => {
|
|
95
|
+
try {
|
|
96
|
+
return readFileSync(path.join(root, relative), "utf8");
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
})();
|
|
102
|
+
const input = payload.toolInput;
|
|
103
|
+
const apply = (text, oldString, newString, isAll) => {
|
|
104
|
+
if (typeof oldString !== "string" || typeof newString !== "string" || oldString === "")
|
|
105
|
+
return text;
|
|
106
|
+
return isAll === true ? text.split(oldString).join(newString) : text.replace(oldString, () => newString);
|
|
107
|
+
};
|
|
108
|
+
switch (payload.toolName) {
|
|
109
|
+
case "Write":
|
|
110
|
+
return typeof input.content === "string" ? input.content : onDisk;
|
|
111
|
+
case "Edit":
|
|
112
|
+
return apply(onDisk ?? "", input.old_string, input.new_string, input.replace_all);
|
|
113
|
+
case "MultiEdit": {
|
|
114
|
+
let text = onDisk ?? "";
|
|
115
|
+
for (const edit of Array.isArray(input.edits) ? input.edits : []) {
|
|
116
|
+
if (typeof edit === "object" && edit !== null) {
|
|
117
|
+
const step = edit;
|
|
118
|
+
text = apply(text, step.old_string, step.new_string, step.replace_all);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return text;
|
|
122
|
+
}
|
|
123
|
+
default:
|
|
124
|
+
return onDisk;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function overlaidTree(base, relative, text) {
|
|
128
|
+
return {
|
|
129
|
+
read: (candidate) => (candidate === relative ? text : base.read(candidate)),
|
|
130
|
+
list: (prefix) => {
|
|
131
|
+
const listed = base.list(prefix).filter((candidate) => candidate !== relative);
|
|
132
|
+
if (text !== null && relative.startsWith(prefix))
|
|
133
|
+
listed.push(relative);
|
|
134
|
+
return listed.sort();
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function branchOf(root) {
|
|
139
|
+
try {
|
|
140
|
+
return execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return "";
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function todayOf() {
|
|
147
|
+
return new Date().toISOString().slice(0, 10);
|
|
148
|
+
}
|
|
149
|
+
function findingsText(verdicts) {
|
|
150
|
+
return verdicts.map((verdict) => verdict.reason).join("\n");
|
|
151
|
+
}
|
|
152
|
+
function reasonOf(gate, findings) {
|
|
153
|
+
return findings.length === 0 ? gate : findings.map((finding) => `${gate}: ${finding.message}${finding.path ? ` (${finding.path})` : ""}`).join("\n");
|
|
154
|
+
}
|
|
155
|
+
async function writeVerdicts(root, projectId, payload, target, verdicts) {
|
|
156
|
+
if (verdicts.length === 0)
|
|
157
|
+
return null;
|
|
158
|
+
try {
|
|
159
|
+
const held = await openLocalStoreReadOnly();
|
|
160
|
+
const version = held?.version ?? RECORDS_STORE_VERSION;
|
|
161
|
+
held?.close();
|
|
162
|
+
if (version < RECORDS_STORE_VERSION)
|
|
163
|
+
return null;
|
|
164
|
+
const store = await openLocalStore();
|
|
165
|
+
try {
|
|
166
|
+
const now = Date.now();
|
|
167
|
+
const writes = verdicts.map((verdict) => {
|
|
168
|
+
const eventId = uuidV5(JSON.stringify([payload.sessionId ?? "", verdict.gate, target ?? "", verdict.reason]));
|
|
169
|
+
const naturalKey = `event:${eventId}`;
|
|
170
|
+
return {
|
|
171
|
+
kind: "gate_event",
|
|
172
|
+
schemaVersion: RECORD_SCHEMA_VERSION,
|
|
173
|
+
projectId,
|
|
174
|
+
sessionId: payload.sessionId,
|
|
175
|
+
planAnchor: null,
|
|
176
|
+
naturalKey,
|
|
177
|
+
idempotencyKey: idempotencyKeyOf(projectId ?? root, "gate_event", naturalKey),
|
|
178
|
+
payload: { eventId, gate: verdict.gate, verdict: verdict.verdict, ...(target ? { target } : {}), reason: verdict.reason.slice(0, 20_000) },
|
|
179
|
+
createdAt: now,
|
|
180
|
+
repositoryRoot: root,
|
|
181
|
+
placement: "server",
|
|
182
|
+
credential: recordCredentialOf(),
|
|
183
|
+
};
|
|
184
|
+
});
|
|
185
|
+
writeRecords(store, writes);
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
store.close();
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
return error instanceof Error ? error.message : String(error);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function preToolUseAnswer(fields) {
|
|
197
|
+
const output = { hookEventName: "PreToolUse" };
|
|
198
|
+
if (fields.deny !== undefined) {
|
|
199
|
+
output.permissionDecision = "deny";
|
|
200
|
+
output.permissionDecisionReason = fields.deny;
|
|
201
|
+
}
|
|
202
|
+
if (fields.context !== undefined)
|
|
203
|
+
output.additionalContext = fields.context;
|
|
204
|
+
return JSON.stringify({ hookSpecificOutput: output });
|
|
205
|
+
}
|
|
206
|
+
function stopAnswer(fields) {
|
|
207
|
+
const output = {};
|
|
208
|
+
if (fields.block !== undefined) {
|
|
209
|
+
output.decision = "block";
|
|
210
|
+
output.reason = fields.block;
|
|
211
|
+
}
|
|
212
|
+
if (fields.message !== undefined)
|
|
213
|
+
output.systemMessage = fields.message;
|
|
214
|
+
return JSON.stringify(output);
|
|
215
|
+
}
|
|
216
|
+
function cannotRunAnswer(event, isProtection, reason) {
|
|
217
|
+
const line = `NuStack enforcement could not run: ${reason}`;
|
|
218
|
+
if (isProtection)
|
|
219
|
+
return { stdout: "", stderr: `${line}\n`, exitCode: 2 };
|
|
220
|
+
return { stdout: event === "pre-tool-use" ? preToolUseAnswer({ context: line }) : stopAnswer({ message: line }), stderr: "", exitCode: 0 };
|
|
221
|
+
}
|
|
222
|
+
export async function runHookCheck(event, isProtection, stdinText, env = process.env) {
|
|
223
|
+
const started = Date.now();
|
|
224
|
+
const lowered = Number(env[HOOK_DEADLINE_VARIABLE]);
|
|
225
|
+
const deadline = Number.isFinite(lowered) && lowered > 0 ? Math.min(lowered, HOOK_DEADLINE_MS) : HOOK_DEADLINE_MS;
|
|
226
|
+
let payload;
|
|
227
|
+
let root;
|
|
228
|
+
let rows;
|
|
229
|
+
let rules;
|
|
230
|
+
let rulesNode;
|
|
231
|
+
try {
|
|
232
|
+
payload = parsePayload(stdinText);
|
|
233
|
+
const bound = boundRootOf(payload.cwd);
|
|
234
|
+
if (!bound)
|
|
235
|
+
throw new CannotRun("this folder is not bound to a NuStack Project");
|
|
236
|
+
root = bound;
|
|
237
|
+
rows = readMap(root).filter((row) => (isProtection ? row.protection && row.rung === "block" : !(row.protection && row.rung === "block")));
|
|
238
|
+
try {
|
|
239
|
+
rules = await loadHarness("rules");
|
|
240
|
+
rulesNode = await loadHarness("rulesNode");
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
throw new CannotRun(`the rule library did not load: ${error instanceof Error ? error.message : String(error)}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
return cannotRunAnswer(event, isProtection, error instanceof Error ? error.message : String(error));
|
|
248
|
+
}
|
|
249
|
+
let target = null;
|
|
250
|
+
if (event === "pre-tool-use") {
|
|
251
|
+
if (payload.toolName !== null && !EDIT_TOOLS.has(payload.toolName))
|
|
252
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
253
|
+
target = editedPath(root, payload);
|
|
254
|
+
if (target === null)
|
|
255
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
256
|
+
}
|
|
257
|
+
const contract = await readProjectContract(root);
|
|
258
|
+
const verdicts = [];
|
|
259
|
+
let isPastDeadline = false;
|
|
260
|
+
try {
|
|
261
|
+
const waivers = [...rulesNode.readBookkeepingWaivers(root), ...(await readStoreDocumentationWaivers(contract.projectId))];
|
|
262
|
+
let context;
|
|
263
|
+
if (event === "pre-tool-use") {
|
|
264
|
+
const tree = rulesNode.workingTree(root);
|
|
265
|
+
const existed = tree.read(target) !== null;
|
|
266
|
+
context = {
|
|
267
|
+
head: overlaidTree(tree, target, postEditText(root, target, payload)),
|
|
268
|
+
base: tree,
|
|
269
|
+
changes: [{ path: target, status: existed ? "M" : "A" }],
|
|
270
|
+
commit: { author: "", message: "" },
|
|
271
|
+
today: todayOf(),
|
|
272
|
+
pullRequest: { headRef: branchOf(root), baseRef: "" },
|
|
273
|
+
waivers,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
context = { ...rulesNode.nodeRuleContext({ root, baseRef: "HEAD" }), waivers };
|
|
278
|
+
}
|
|
279
|
+
for (const row of rows) {
|
|
280
|
+
if (COMMIT_RULES.has(row.rule))
|
|
281
|
+
continue;
|
|
282
|
+
if (!rules.isRuleId(row.rule)) {
|
|
283
|
+
if (event === "stop")
|
|
284
|
+
verdicts.push({ gate: RULE_UNAVAILABLE_GATE, verdict: "flag", findings: [], reason: `${row.rule} is not a rule this CLI serves` });
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (Date.now() - started >= deadline) {
|
|
288
|
+
isPastDeadline = true;
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
const result = rules.runRule(row.rule, { ...context, rung: row.rung });
|
|
292
|
+
if (result.verdict === "pass")
|
|
293
|
+
continue;
|
|
294
|
+
verdicts.push({ gate: row.rule, verdict: result.verdict, findings: result.findings, reason: reasonOf(row.rule, result.findings) });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
return cannotRunAnswer(event, isProtection, error instanceof Error ? error.message : String(error));
|
|
299
|
+
}
|
|
300
|
+
if (isPastDeadline) {
|
|
301
|
+
for (const verdict of verdicts)
|
|
302
|
+
verdict.verdict = "flag";
|
|
303
|
+
verdicts.push({ gate: DEADLINE_GATE, verdict: "flag", findings: [], reason: `the checks did not finish within ${deadline} ms; the call was let through` });
|
|
304
|
+
}
|
|
305
|
+
const blocks = verdicts.filter((verdict) => verdict.verdict === "block");
|
|
306
|
+
const flags = verdicts.filter((verdict) => verdict.verdict === "flag");
|
|
307
|
+
const shownFlags = flags.filter((verdict) => verdict.gate !== RULE_UNAVAILABLE_GATE);
|
|
308
|
+
const writeError = await writeVerdicts(root, contract.projectId, payload, target, verdicts);
|
|
309
|
+
const writeLine = writeError ? `the gate_event record could not be written: ${writeError}` : null;
|
|
310
|
+
if (event === "pre-tool-use") {
|
|
311
|
+
if (blocks.length > 0)
|
|
312
|
+
return { stdout: preToolUseAnswer({ deny: [findingsText(blocks), writeLine].filter(Boolean).join("\n") }), stderr: "", exitCode: 0 };
|
|
313
|
+
if (flags.length > 0 || writeLine)
|
|
314
|
+
return { stdout: preToolUseAnswer({ context: [findingsText(flags), writeLine].filter(Boolean).join("\n") }), stderr: "", exitCode: 0 };
|
|
315
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
316
|
+
}
|
|
317
|
+
if (blocks.length > 0) {
|
|
318
|
+
const reason = [findingsText(blocks), writeLine].filter(Boolean).join("\n");
|
|
319
|
+
const shown = [reason, findingsText(shownFlags)].filter(Boolean).join("\n");
|
|
320
|
+
return { stdout: payload.isStopHookActive ? stopAnswer({ message: shown }) : stopAnswer({ block: reason }), stderr: "", exitCode: 0 };
|
|
321
|
+
}
|
|
322
|
+
if (shownFlags.length > 0 || writeLine)
|
|
323
|
+
return { stdout: stopAnswer({ message: [findingsText(shownFlags), writeLine].filter(Boolean).join("\n") }), stderr: "", exitCode: 0 };
|
|
324
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
325
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { HOOK_EVENTS, runHookCheck } from "./hookCheck.js";
|
|
2
|
+
function readStdin() {
|
|
3
|
+
return new Promise((resolve) => {
|
|
4
|
+
if (process.stdin.isTTY) {
|
|
5
|
+
resolve("");
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
let text = "";
|
|
9
|
+
process.stdin.setEncoding("utf8");
|
|
10
|
+
process.stdin.on("data", (chunk) => (text += chunk));
|
|
11
|
+
process.stdin.on("end", () => resolve(text));
|
|
12
|
+
process.stdin.on("error", () => resolve(text));
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
export async function runHookMode(event, isProtection) {
|
|
16
|
+
if (!HOOK_EVENTS.includes(event)) {
|
|
17
|
+
console.error(`--hook must be one of ${HOOK_EVENTS.join(", ")}.`);
|
|
18
|
+
process.exitCode = isProtection ? 2 : 0;
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const answer = await runHookCheck(event, isProtection, await readStdin());
|
|
22
|
+
if (answer.stdout)
|
|
23
|
+
process.stdout.write(`${answer.stdout}\n`);
|
|
24
|
+
if (answer.stderr)
|
|
25
|
+
process.stderr.write(answer.stderr);
|
|
26
|
+
process.exitCode = answer.exitCode;
|
|
27
|
+
}
|