fix(letta): harden official hook integration
This commit is contained in:
1 parent
69a75153f5
commit
53b08ec4a7
13 files changed
+1985
-146
No files matched your search
@@ -0,0 +1,146 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
interface ParsedArgument {
|
||||
readonly value: string;
|
||||
readonly next: number;
|
||||
}
|
||||
|
||||
export interface LettaRuntimeReference {
|
||||
readonly agentId: string;
|
||||
readonly executablePath?: string;
|
||||
readonly scriptPath: string;
|
||||
}
|
||||
|
||||
export function sameLettaPath(left: string, right: string): boolean {
|
||||
const normalizedLeft = path.resolve(left);
|
||||
const normalizedRight = path.resolve(right);
|
||||
return process.platform === 'win32'
|
||||
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
|
||||
: normalizedLeft === normalizedRight;
|
||||
}
|
||||
|
||||
export function sameLettaAgentId(left: string, right: string): boolean {
|
||||
return process.platform === 'win32'
|
||||
? left.toLowerCase() === right.toLowerCase()
|
||||
: left === right;
|
||||
}
|
||||
|
||||
function quotePosix(value: string): string {
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function quotePowerShell(value: string): string {
|
||||
return `'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
export function buildLettaCommand(scriptPath: string): string {
|
||||
if (!path.isAbsolute(process.execPath) || !path.isAbsolute(scriptPath)) {
|
||||
throw new Error('Letta Code hook commands require absolute executable and script paths');
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return `& ${quotePowerShell(process.execPath)} ${quotePowerShell(scriptPath)}; exit $LASTEXITCODE`;
|
||||
}
|
||||
return `${quotePosix(process.execPath)} ${quotePosix(scriptPath)}`;
|
||||
}
|
||||
|
||||
function readPosixArgument(command: string, start: number): ParsedArgument | undefined {
|
||||
if (command[start] !== "'") return undefined;
|
||||
const apostrophe = `'"'"'`;
|
||||
let value = '';
|
||||
for (let index = start + 1; index < command.length;) {
|
||||
if (command.startsWith(apostrophe, index)) {
|
||||
value += "'";
|
||||
index += apostrophe.length;
|
||||
continue;
|
||||
}
|
||||
if (command[index] === "'") return { value, next: index + 1 };
|
||||
value += command[index];
|
||||
index += 1;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parsePosixCommand(command: string): readonly [string, string] | undefined {
|
||||
const executable = readPosixArgument(command, 0);
|
||||
if (!executable || command[executable.next] !== ' ') return undefined;
|
||||
const script = readPosixArgument(command, executable.next + 1);
|
||||
if (!script || script.next !== command.length) return undefined;
|
||||
return [executable.value, script.value];
|
||||
}
|
||||
|
||||
function readPowerShellArgument(command: string, start: number): ParsedArgument | undefined {
|
||||
if (command[start] !== "'") return undefined;
|
||||
let value = '';
|
||||
for (let index = start + 1; index < command.length; index += 1) {
|
||||
if (command[index] !== "'") {
|
||||
value += command[index];
|
||||
continue;
|
||||
}
|
||||
if (command[index + 1] === "'") {
|
||||
value += "'";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
return { value, next: index + 1 };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parsePowerShellCommand(command: string): readonly [string, string] | undefined {
|
||||
if (!command.startsWith('& ')) return undefined;
|
||||
const executable = readPowerShellArgument(command, 2);
|
||||
if (!executable || command[executable.next] !== ' ') return undefined;
|
||||
const script = readPowerShellArgument(command, executable.next + 1);
|
||||
if (!script || command.slice(script.next) !== '; exit $LASTEXITCODE') return undefined;
|
||||
return [executable.value, script.value];
|
||||
}
|
||||
|
||||
function legacyScriptPath(command: string): string | undefined {
|
||||
if (!command.startsWith('node "') || !command.endsWith('"')) return undefined;
|
||||
const scriptPath = command.slice(6, -1);
|
||||
return scriptPath.includes('"') ? undefined : scriptPath;
|
||||
}
|
||||
|
||||
function isNodeExecutable(filePath: string): boolean {
|
||||
const basename = path.basename(filePath).toLowerCase();
|
||||
return basename === 'node' || basename === 'node.exe';
|
||||
}
|
||||
|
||||
function runtimeReference(
|
||||
scriptPath: string,
|
||||
scriptName: string,
|
||||
executablePath?: string,
|
||||
): LettaRuntimeReference | undefined {
|
||||
if (!path.isAbsolute(scriptPath) || path.basename(scriptPath) !== scriptName) return undefined;
|
||||
const agentDirectory = path.dirname(scriptPath);
|
||||
if (!sameLettaPath(path.dirname(agentDirectory), path.join(os.homedir(), '.elydora'))) {
|
||||
return undefined;
|
||||
}
|
||||
const agentId = path.basename(agentDirectory);
|
||||
if (!agentId || agentId === '.' || agentId === '..') return undefined;
|
||||
return { agentId, executablePath, scriptPath };
|
||||
}
|
||||
|
||||
export function lettaRuntimeReference(
|
||||
command: string,
|
||||
scriptName: string,
|
||||
): LettaRuntimeReference | undefined {
|
||||
const parsed = process.platform === 'win32'
|
||||
? parsePowerShellCommand(command)
|
||||
: parsePosixCommand(command);
|
||||
if (parsed
|
||||
&& path.isAbsolute(parsed[0])
|
||||
&& isNodeExecutable(parsed[0])) {
|
||||
return runtimeReference(parsed[1], scriptName, parsed[0]);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function lettaLegacyRuntimeReference(
|
||||
command: string,
|
||||
scriptName: string,
|
||||
): LettaRuntimeReference | undefined {
|
||||
const legacy = legacyScriptPath(command);
|
||||
return legacy ? runtimeReference(legacy, scriptName) : undefined;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
applyEdits,
|
||||
modify,
|
||||
type FormattingOptions,
|
||||
type JSONPath,
|
||||
} from 'jsonc-parser';
|
||||
import type { FileSnapshot } from './managed-files.js';
|
||||
import {
|
||||
MANAGED_EVENTS,
|
||||
managedLettaRemovals,
|
||||
readLettaHooks,
|
||||
type LettaGroup,
|
||||
type LettaHooks,
|
||||
type ManagedLettaEvent,
|
||||
} from './letta-contract.js';
|
||||
import { parseStrictJsonObject, type JsonObject } from './strict-json.js';
|
||||
|
||||
export type LettaDocumentKind = 'global' | 'project' | 'project-local';
|
||||
|
||||
export interface LettaDocument {
|
||||
readonly kind: LettaDocumentKind;
|
||||
readonly filePath: string;
|
||||
readonly exists: boolean;
|
||||
readonly raw: string;
|
||||
readonly snapshot?: FileSnapshot;
|
||||
readonly root: JsonObject;
|
||||
readonly hooks: LettaHooks;
|
||||
readonly hasHooksContainer: boolean;
|
||||
}
|
||||
|
||||
export interface RenderedLettaDocument {
|
||||
readonly document: LettaDocument;
|
||||
readonly changed: boolean;
|
||||
readonly next?: string;
|
||||
}
|
||||
|
||||
interface DocumentOptions {
|
||||
readonly kind: LettaDocumentKind;
|
||||
readonly exists: boolean;
|
||||
readonly filePath: string;
|
||||
readonly raw: string;
|
||||
readonly snapshot?: FileSnapshot;
|
||||
}
|
||||
|
||||
export function lettaSourceLabel(kind: LettaDocumentKind): string {
|
||||
switch (kind) {
|
||||
case 'project': return 'Letta Code project settings';
|
||||
case 'project-local': return 'Letta Code project-local settings';
|
||||
default: return 'Letta Code global settings';
|
||||
}
|
||||
}
|
||||
|
||||
export function parseLettaDocument(options: DocumentOptions): LettaDocument {
|
||||
const label = `${lettaSourceLabel(options.kind)} at ${options.filePath}`;
|
||||
const root = parseStrictJsonObject(options.raw, label);
|
||||
return {
|
||||
...options,
|
||||
root,
|
||||
hooks: readLettaHooks(root.hooks),
|
||||
hasHooksContainer: Object.hasOwn(root, 'hooks'),
|
||||
};
|
||||
}
|
||||
|
||||
export function createLettaDocument(
|
||||
kind: LettaDocumentKind,
|
||||
filePath: string,
|
||||
): LettaDocument {
|
||||
return parseLettaDocument({
|
||||
kind,
|
||||
exists: false,
|
||||
filePath,
|
||||
raw: '{}\n',
|
||||
});
|
||||
}
|
||||
|
||||
function formatting(raw: string): FormattingOptions {
|
||||
const indentation = /\r?\n([ \t]+)\S/.exec(raw)?.[1];
|
||||
const insertSpaces = !indentation?.includes('\t');
|
||||
return {
|
||||
eol: raw.includes('\r\n') ? '\r\n' : '\n',
|
||||
insertSpaces,
|
||||
tabSize: insertSpaces ? Math.max(1, indentation?.length ?? 2) : 1,
|
||||
};
|
||||
}
|
||||
|
||||
function change(
|
||||
raw: string,
|
||||
jsonPath: JSONPath,
|
||||
value: unknown,
|
||||
isArrayInsertion = false,
|
||||
): string {
|
||||
return applyEdits(raw, modify(raw, jsonPath, value, {
|
||||
formattingOptions: formatting(raw),
|
||||
isArrayInsertion,
|
||||
}));
|
||||
}
|
||||
|
||||
function currentDocument(document: LettaDocument, raw: string): LettaDocument {
|
||||
return parseLettaDocument({
|
||||
kind: document.kind,
|
||||
exists: document.exists,
|
||||
filePath: document.filePath,
|
||||
raw,
|
||||
snapshot: document.snapshot,
|
||||
});
|
||||
}
|
||||
|
||||
function groupsForEvent(hooks: LettaHooks, event: ManagedLettaEvent): LettaGroup[] {
|
||||
const groups = hooks[event];
|
||||
return Array.isArray(groups) ? groups : [];
|
||||
}
|
||||
|
||||
function removeManagedEntries(
|
||||
document: LettaDocument,
|
||||
initial: string,
|
||||
agentId?: string,
|
||||
): string {
|
||||
let raw = initial;
|
||||
const removals = managedLettaRemovals(document.hooks, agentId);
|
||||
for (const event of MANAGED_EVENTS) {
|
||||
const eventRemovals = removals
|
||||
.filter((removal) => removal.event === event)
|
||||
.sort((left, right) => right.groupIndex - left.groupIndex);
|
||||
for (const removal of eventRemovals) {
|
||||
const groupPath: JSONPath = ['hooks', event, removal.groupIndex];
|
||||
if (removal.removeGroup) {
|
||||
raw = change(raw, groupPath, undefined);
|
||||
continue;
|
||||
}
|
||||
for (const handlerIndex of [...removal.handlerIndexes].sort(
|
||||
(left, right) => right - left,
|
||||
)) {
|
||||
raw = change(raw, [...groupPath, 'hooks', handlerIndex], undefined);
|
||||
}
|
||||
}
|
||||
if (eventRemovals.length > 0) {
|
||||
const current = currentDocument(document, raw);
|
||||
if (groupsForEvent(current.hooks, event).length === 0) {
|
||||
raw = change(raw, ['hooks', event], undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
const current = currentDocument(document, raw);
|
||||
if (current.hasHooksContainer && Object.keys(current.hooks).length === 0) {
|
||||
raw = change(raw, ['hooks'], undefined);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function appendGroup(
|
||||
document: LettaDocument,
|
||||
raw: string,
|
||||
event: ManagedLettaEvent,
|
||||
group: LettaGroup,
|
||||
): string {
|
||||
const current = currentDocument(document, raw);
|
||||
const groups = groupsForEvent(current.hooks, event);
|
||||
if (groups.length > 0) {
|
||||
return change(raw, ['hooks', event, groups.length], group, true);
|
||||
}
|
||||
return change(raw, ['hooks', event], [group]);
|
||||
}
|
||||
|
||||
export function renderLettaDocument(
|
||||
document: LettaDocument,
|
||||
agentId: string | undefined,
|
||||
additions: ReadonlyMap<ManagedLettaEvent, LettaGroup>,
|
||||
): RenderedLettaDocument {
|
||||
let raw = removeManagedEntries(document, document.raw, agentId);
|
||||
for (const event of MANAGED_EVENTS) {
|
||||
const group = additions.get(event);
|
||||
if (group) raw = appendGroup(document, raw, event, group);
|
||||
}
|
||||
const current = currentDocument(document, raw);
|
||||
if (additions.size === 0 && Object.keys(current.root).length === 0) {
|
||||
return { document, changed: true, next: undefined };
|
||||
}
|
||||
return { document, changed: raw !== document.raw, next: raw };
|
||||
}
|
||||
|
||||
export function lettaDocumentLabel(document: LettaDocument): string {
|
||||
return lettaSourceLabel(document.kind);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import {
|
||||
buildLettaCommand,
|
||||
lettaLegacyRuntimeReference,
|
||||
lettaRuntimeReference,
|
||||
sameLettaAgentId,
|
||||
sameLettaPath,
|
||||
type LettaRuntimeReference,
|
||||
} from './letta-command.js';
|
||||
import { isObject, type JsonObject } from './strict-json.js';
|
||||
|
||||
export const AGENT_KEY = 'letta';
|
||||
export const GUARD_SCRIPT = 'guard.js';
|
||||
export const AUDIT_SCRIPT = 'hook.js';
|
||||
export const HOOK_TIMEOUT_MS = 10_000;
|
||||
export const LETTA_AUDIT_OPTIONS = {
|
||||
nativePayload: true,
|
||||
failClosed: true,
|
||||
} as const;
|
||||
export const MANAGED_EVENTS = [
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'PostToolUseFailure',
|
||||
] as const;
|
||||
|
||||
const TOOL_EVENTS = new Set([
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'PostToolUseFailure',
|
||||
'PermissionRequest',
|
||||
]);
|
||||
|
||||
const SIMPLE_EVENTS = new Set([
|
||||
'UserPromptSubmit',
|
||||
'Notification',
|
||||
'Stop',
|
||||
'SubagentStop',
|
||||
'PreCompact',
|
||||
'SessionStart',
|
||||
'SessionEnd',
|
||||
]);
|
||||
|
||||
const KNOWN_EVENTS = new Set([...TOOL_EVENTS, ...SIMPLE_EVENTS]);
|
||||
|
||||
export type ManagedLettaEvent = (typeof MANAGED_EVENTS)[number];
|
||||
|
||||
export interface LettaHandler extends JsonObject {
|
||||
readonly type: 'command' | 'prompt';
|
||||
readonly command?: string;
|
||||
readonly prompt?: string;
|
||||
readonly model?: string;
|
||||
readonly timeout?: number;
|
||||
readonly quiet?: boolean;
|
||||
}
|
||||
|
||||
export interface LettaGroup extends JsonObject {
|
||||
readonly matcher?: string;
|
||||
readonly hooks: LettaHandler[];
|
||||
}
|
||||
|
||||
export interface LettaHooks extends JsonObject {
|
||||
readonly disabled?: boolean;
|
||||
readonly [event: string]: LettaGroup[] | boolean | undefined;
|
||||
}
|
||||
|
||||
export interface LettaRuntimeContract {
|
||||
readonly agentId: string;
|
||||
readonly guardPath: string;
|
||||
readonly auditPath: string;
|
||||
}
|
||||
|
||||
export interface ManagedLettaRemoval {
|
||||
readonly event: ManagedLettaEvent;
|
||||
readonly groupIndex: number;
|
||||
readonly handlerIndexes: readonly number[];
|
||||
readonly removeGroup: boolean;
|
||||
}
|
||||
|
||||
function validateTimeout(value: unknown, label: string): void {
|
||||
if (value === undefined) return;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
||||
throw new Error(`${label} timeout must be a non-negative finite number`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateHandler(value: unknown, label: string): LettaHandler {
|
||||
if (!isObject(value)) throw new Error(`${label} must be an object`);
|
||||
if (value.type !== 'command' && value.type !== 'prompt') {
|
||||
throw new Error(`${label} has unsupported type "${String(value.type)}"`);
|
||||
}
|
||||
validateTimeout(value.timeout, label);
|
||||
if (value.quiet !== undefined && typeof value.quiet !== 'boolean') {
|
||||
throw new Error(`${label} quiet must be a boolean`);
|
||||
}
|
||||
if (value.type === 'command') {
|
||||
if (typeof value.command !== 'string' || value.command.length === 0) {
|
||||
throw new Error(`${label} requires a non-empty command`);
|
||||
}
|
||||
} else {
|
||||
if (typeof value.prompt !== 'string' || value.prompt.length === 0) {
|
||||
throw new Error(`${label} requires a non-empty prompt`);
|
||||
}
|
||||
if (value.model !== undefined && typeof value.model !== 'string') {
|
||||
throw new Error(`${label} model must be a string`);
|
||||
}
|
||||
}
|
||||
return value as LettaHandler;
|
||||
}
|
||||
|
||||
function validateGroup(
|
||||
value: unknown,
|
||||
event: string,
|
||||
index: number,
|
||||
): LettaGroup {
|
||||
const label = `Letta Code settings group hooks.${event}[${index}]`;
|
||||
if (!isObject(value)) throw new Error(`${label} must be an object`);
|
||||
if (TOOL_EVENTS.has(event)) {
|
||||
if (typeof value.matcher !== 'string') {
|
||||
throw new Error(`${label} matcher must be a string`);
|
||||
}
|
||||
} else if (value.matcher !== undefined) {
|
||||
throw new Error(`${label} matcher is unsupported for ${event}`);
|
||||
}
|
||||
if (!Array.isArray(value.hooks)) throw new Error(`${label} must contain a hooks array`);
|
||||
return {
|
||||
...value,
|
||||
hooks: value.hooks.map((handler, handlerIndex) => (
|
||||
validateHandler(handler, `${label}.hooks[${handlerIndex}]`)
|
||||
)),
|
||||
} as LettaGroup;
|
||||
}
|
||||
|
||||
export function readLettaHooks(value: unknown): LettaHooks {
|
||||
if (value === undefined) return {};
|
||||
if (!isObject(value)) throw new Error('Letta Code settings field "hooks" must be an object');
|
||||
if (value.disabled !== undefined && typeof value.disabled !== 'boolean') {
|
||||
throw new Error('Letta Code settings field "hooks.disabled" must be a boolean');
|
||||
}
|
||||
const hooks: JsonObject = {};
|
||||
for (const [event, groups] of Object.entries(value)) {
|
||||
if (event === 'disabled') {
|
||||
hooks.disabled = groups;
|
||||
continue;
|
||||
}
|
||||
if (!KNOWN_EVENTS.has(event)) {
|
||||
hooks[event] = groups;
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(groups)) {
|
||||
throw new Error(`Letta Code settings field "hooks.${event}" must be an array`);
|
||||
}
|
||||
hooks[event] = groups.map((group, index) => validateGroup(group, event, index));
|
||||
}
|
||||
return hooks as LettaHooks;
|
||||
}
|
||||
|
||||
export function buildLettaGroup(scriptPath: string): LettaGroup {
|
||||
return {
|
||||
matcher: '*',
|
||||
hooks: [{
|
||||
type: 'command',
|
||||
command: buildLettaCommand(scriptPath),
|
||||
timeout: HOOK_TIMEOUT_MS,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function exactGroup(group: LettaGroup): boolean {
|
||||
return Object.keys(group).sort().join('|') === 'hooks|matcher'
|
||||
&& group.matcher === '*';
|
||||
}
|
||||
|
||||
function exactCurrentReference(
|
||||
handler: LettaHandler,
|
||||
scriptName: string,
|
||||
): LettaRuntimeReference | undefined {
|
||||
return Object.keys(handler).sort().join('|') === 'command|timeout|type'
|
||||
&& handler.type === 'command'
|
||||
&& handler.timeout === HOOK_TIMEOUT_MS
|
||||
&& typeof handler.command === 'string'
|
||||
? lettaRuntimeReference(handler.command, scriptName)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function exactLegacyReference(
|
||||
handler: LettaHandler,
|
||||
scriptName: string,
|
||||
): LettaRuntimeReference | undefined {
|
||||
return Object.keys(handler).sort().join('|') === 'command|type'
|
||||
&& handler.type === 'command'
|
||||
&& typeof handler.command === 'string'
|
||||
? lettaLegacyRuntimeReference(handler.command, scriptName)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function managedReference(
|
||||
handler: LettaHandler,
|
||||
scriptName: string,
|
||||
): LettaRuntimeReference | undefined {
|
||||
return exactCurrentReference(handler, scriptName)
|
||||
?? exactLegacyReference(handler, scriptName);
|
||||
}
|
||||
|
||||
const EVENT_CONTRACTS = [
|
||||
['PreToolUse', GUARD_SCRIPT],
|
||||
['PostToolUse', AUDIT_SCRIPT],
|
||||
['PostToolUseFailure', AUDIT_SCRIPT],
|
||||
] as const;
|
||||
|
||||
function eventGroups(hooks: LettaHooks, event: ManagedLettaEvent): LettaGroup[] {
|
||||
const value = hooks[event];
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
export function managedLettaRemovals(
|
||||
hooks: LettaHooks,
|
||||
agentId?: string,
|
||||
): ManagedLettaRemoval[] {
|
||||
const removals: ManagedLettaRemoval[] = [];
|
||||
for (const [event, scriptName] of EVENT_CONTRACTS) {
|
||||
eventGroups(hooks, event).forEach((group, groupIndex) => {
|
||||
const handlerIndexes = group.hooks.flatMap((handler, handlerIndex) => {
|
||||
const reference = managedReference(handler, scriptName);
|
||||
return reference && (agentId === undefined || sameLettaAgentId(reference.agentId, agentId))
|
||||
? [handlerIndex]
|
||||
: [];
|
||||
});
|
||||
if (handlerIndexes.length > 0) {
|
||||
removals.push({
|
||||
event,
|
||||
groupIndex,
|
||||
handlerIndexes,
|
||||
removeGroup: exactGroup(group) && handlerIndexes.length === group.hooks.length,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return removals;
|
||||
}
|
||||
|
||||
function referencesForEvent(
|
||||
groups: readonly LettaGroup[],
|
||||
scriptName: string,
|
||||
): Map<string, LettaRuntimeReference[]> {
|
||||
const references = new Map<string, LettaRuntimeReference[]>();
|
||||
for (const group of groups) {
|
||||
if (!exactGroup(group)) continue;
|
||||
for (const handler of group.hooks) {
|
||||
const reference = exactCurrentReference(handler, scriptName);
|
||||
if (!reference
|
||||
|| !reference.executablePath
|
||||
|| !sameLettaPath(reference.executablePath, process.execPath)) continue;
|
||||
const key = process.platform === 'win32'
|
||||
? reference.agentId.toLowerCase()
|
||||
: reference.agentId;
|
||||
references.set(key, [...(references.get(key) ?? []), reference]);
|
||||
}
|
||||
}
|
||||
return references;
|
||||
}
|
||||
|
||||
export function lettaRuntimeContracts(hooks: LettaHooks): LettaRuntimeContract[] {
|
||||
const guards = referencesForEvent(eventGroups(hooks, 'PreToolUse'), GUARD_SCRIPT);
|
||||
const posts = referencesForEvent(eventGroups(hooks, 'PostToolUse'), AUDIT_SCRIPT);
|
||||
const failures = referencesForEvent(
|
||||
eventGroups(hooks, 'PostToolUseFailure'),
|
||||
AUDIT_SCRIPT,
|
||||
);
|
||||
const contracts: LettaRuntimeContract[] = [];
|
||||
for (const [key, guard] of guards) {
|
||||
const post = posts.get(key);
|
||||
const failure = failures.get(key);
|
||||
if (guard.length !== 1 || post?.length !== 1 || failure?.length !== 1) continue;
|
||||
if (!sameLettaPath(post[0].scriptPath, failure[0].scriptPath)) continue;
|
||||
contracts.push({
|
||||
agentId: guard[0].agentId,
|
||||
guardPath: guard[0].scriptPath,
|
||||
auditPath: post[0].scriptPath,
|
||||
});
|
||||
}
|
||||
return contracts;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import path from 'node:path';
|
||||
import type { InstallConfig } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
AUDIT_SCRIPT,
|
||||
GUARD_SCRIPT,
|
||||
LETTA_AUDIT_OPTIONS,
|
||||
} from './letta-contract.js';
|
||||
import { sameLettaPath } from './letta-command.js';
|
||||
import {
|
||||
lettaDocumentLabel,
|
||||
type RenderedLettaDocument,
|
||||
} from './letta-config.js';
|
||||
import {
|
||||
commitManagedInstallation,
|
||||
preflightManagedInstallation,
|
||||
prepareManagedInstallation,
|
||||
type ManagedRuntimePaths,
|
||||
type PreparedManagedInstallation,
|
||||
type RenameFile,
|
||||
} from './managed-installation.js';
|
||||
import {
|
||||
commitManagedTransaction,
|
||||
prepareManagedFileChange,
|
||||
type PreparedManagedTransaction,
|
||||
} from './managed-transaction.js';
|
||||
import {
|
||||
requireLettaHooksEnabled,
|
||||
type LettaSources,
|
||||
} from './letta-sources.js';
|
||||
|
||||
const DISPLAY_NAME = 'Letta Code';
|
||||
const SETTINGS_DIRECTORY_LABEL = 'Letta Code global configuration directory';
|
||||
const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
export type LettaRuntimePaths = ManagedRuntimePaths;
|
||||
export type PreparedLettaInstallation = PreparedManagedInstallation;
|
||||
export type { RenameFile };
|
||||
|
||||
export async function preflightLettaInstallation(
|
||||
config: InstallConfig,
|
||||
sources: LettaSources,
|
||||
): Promise<LettaRuntimePaths> {
|
||||
requireLettaHooksEnabled(sources);
|
||||
return preflightManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
hookLocations: [{
|
||||
directoryLabel: SETTINGS_DIRECTORY_LABEL,
|
||||
filePath: sources.global.filePath,
|
||||
}],
|
||||
config,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
}
|
||||
|
||||
function readOnlyPreconditions(sources: LettaSources, changedPath?: string) {
|
||||
return sources.preconditions.filter((condition) => (
|
||||
changedPath === undefined || !sameLettaPath(condition.filePath, changedPath)
|
||||
));
|
||||
}
|
||||
|
||||
export async function prepareLettaInstallation(
|
||||
config: InstallConfig,
|
||||
sources: LettaSources,
|
||||
rendered: RenderedLettaDocument,
|
||||
): Promise<PreparedLettaInstallation> {
|
||||
await preflightLettaInstallation(config, sources);
|
||||
const settingsSource = rendered.next ?? rendered.document.raw;
|
||||
const prepared = await prepareManagedInstallation({
|
||||
agentKey: AGENT_KEY,
|
||||
displayName: DISPLAY_NAME,
|
||||
hookSources: [{
|
||||
directoryLabel: SETTINGS_DIRECTORY_LABEL,
|
||||
label: lettaDocumentLabel(rendered.document),
|
||||
filePath: rendered.document.filePath,
|
||||
expectedSource: rendered.document.exists ? rendered.document.raw : undefined,
|
||||
expectedSnapshot: rendered.document.snapshot,
|
||||
source: settingsSource,
|
||||
}],
|
||||
config,
|
||||
auditOptions: LETTA_AUDIT_OPTIONS,
|
||||
}, GUARD_SCRIPT, AUDIT_SCRIPT);
|
||||
return {
|
||||
...prepared,
|
||||
transaction: {
|
||||
...prepared.transaction,
|
||||
preconditions: readOnlyPreconditions(sources, rendered.document.filePath),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function commitLettaInstallation(
|
||||
prepared: PreparedLettaInstallation,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedInstallation(prepared, renameFile);
|
||||
}
|
||||
|
||||
export async function prepareLettaUninstall(
|
||||
sources: LettaSources,
|
||||
rendered: RenderedLettaDocument,
|
||||
): Promise<PreparedManagedTransaction> {
|
||||
const change = rendered.changed
|
||||
? await prepareManagedFileChange({
|
||||
filePath: rendered.document.filePath,
|
||||
label: lettaDocumentLabel(rendered.document),
|
||||
next: rendered.next,
|
||||
mode: 0o600,
|
||||
maximumBytes: MAX_SOURCE_BYTES,
|
||||
expectedSource: rendered.document.exists ? rendered.document.raw : undefined,
|
||||
expectedSnapshot: rendered.document.snapshot,
|
||||
verifyExpectedSource: true,
|
||||
})
|
||||
: undefined;
|
||||
return {
|
||||
displayName: DISPLAY_NAME,
|
||||
operation: 'uninstall',
|
||||
directories: [{
|
||||
path: path.dirname(rendered.document.filePath),
|
||||
label: SETTINGS_DIRECTORY_LABEL,
|
||||
}],
|
||||
changes: change ? [change] : [],
|
||||
preconditions: readOnlyPreconditions(
|
||||
sources,
|
||||
rendered.changed ? rendered.document.filePath : undefined,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function commitLettaUninstall(
|
||||
prepared: PreparedManagedTransaction,
|
||||
renameFile?: RenameFile,
|
||||
): Promise<void> {
|
||||
await commitManagedTransaction(prepared, renameFile);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
createLettaDocument,
|
||||
lettaDocumentLabel,
|
||||
lettaSourceLabel,
|
||||
parseLettaDocument,
|
||||
type LettaDocument,
|
||||
type LettaDocumentKind,
|
||||
} from './letta-config.js';
|
||||
import {
|
||||
inspectPhysicalDirectory,
|
||||
readPhysicalFile,
|
||||
type FileSnapshot,
|
||||
} from './managed-files.js';
|
||||
|
||||
const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
export interface LettaSourcePrecondition {
|
||||
readonly filePath: string;
|
||||
readonly label: string;
|
||||
readonly maximumBytes: number;
|
||||
readonly original?: FileSnapshot;
|
||||
}
|
||||
|
||||
export interface LettaDisableControl {
|
||||
readonly disabled: boolean;
|
||||
readonly source?: LettaDocument;
|
||||
}
|
||||
|
||||
export interface LettaSources {
|
||||
readonly homeDirectory: string;
|
||||
readonly global: LettaDocument;
|
||||
readonly project: LettaDocument;
|
||||
readonly projectLocal: LettaDocument;
|
||||
readonly projectActive: boolean;
|
||||
readonly disableControl: LettaDisableControl;
|
||||
readonly preconditions: readonly LettaSourcePrecondition[];
|
||||
}
|
||||
|
||||
function comparisonPath(filePath: string): string {
|
||||
const resolved = path.resolve(filePath);
|
||||
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
||||
}
|
||||
|
||||
function hasCode(error: unknown, code: string): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
|
||||
}
|
||||
|
||||
async function canonicalPath(filePath: string): Promise<string> {
|
||||
try {
|
||||
return await fsp.realpath(filePath);
|
||||
} catch (error) {
|
||||
if (hasCode(error, 'ENOENT') || hasCode(error, 'ENOTDIR')) return path.resolve(filePath);
|
||||
throw new Error(`Resolve Letta Code path at ${filePath}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`, { cause: error instanceof Error ? error : new Error(String(error)) });
|
||||
}
|
||||
}
|
||||
|
||||
function resolveHomeDirectory(): string {
|
||||
return path.resolve(process.env.HOME || os.homedir());
|
||||
}
|
||||
|
||||
async function readDocument(
|
||||
kind: LettaDocumentKind,
|
||||
filePath: string,
|
||||
): Promise<LettaDocument> {
|
||||
const snapshot = await readPhysicalFile(filePath, lettaSourceLabel(kind), MAX_SOURCE_BYTES);
|
||||
return snapshot
|
||||
? parseLettaDocument({
|
||||
kind,
|
||||
exists: true,
|
||||
filePath,
|
||||
raw: snapshot.contents,
|
||||
snapshot,
|
||||
})
|
||||
: createLettaDocument(kind, filePath);
|
||||
}
|
||||
|
||||
function sourcePrecondition(document: LettaDocument): LettaSourcePrecondition {
|
||||
return {
|
||||
filePath: document.filePath,
|
||||
label: lettaDocumentLabel(document),
|
||||
maximumBytes: MAX_SOURCE_BYTES,
|
||||
original: document.snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
function deduplicatePreconditions(
|
||||
values: readonly LettaSourcePrecondition[],
|
||||
): LettaSourcePrecondition[] {
|
||||
const result = new Map<string, LettaSourcePrecondition>();
|
||||
for (const value of values) {
|
||||
const key = comparisonPath(value.filePath);
|
||||
if (!result.has(key)) result.set(key, value);
|
||||
}
|
||||
return [...result.values()];
|
||||
}
|
||||
|
||||
function effectiveDisable(
|
||||
global: LettaDocument,
|
||||
project: LettaDocument,
|
||||
projectLocal: LettaDocument,
|
||||
projectActive: boolean,
|
||||
): LettaDisableControl {
|
||||
if (global.hooks.disabled === false) return { disabled: false, source: global };
|
||||
if (global.hooks.disabled === true) return { disabled: true, source: global };
|
||||
if (projectActive && project.hooks.disabled === true) {
|
||||
return { disabled: true, source: project };
|
||||
}
|
||||
if (projectLocal.hooks.disabled === true) {
|
||||
return { disabled: true, source: projectLocal };
|
||||
}
|
||||
return { disabled: false };
|
||||
}
|
||||
|
||||
export async function readLettaSources(): Promise<LettaSources> {
|
||||
const homeDirectory = resolveHomeDirectory();
|
||||
const workspace = process.cwd();
|
||||
const globalDirectory = path.join(homeDirectory, '.letta');
|
||||
const projectDirectory = path.join(workspace, '.letta');
|
||||
await inspectPhysicalDirectory(homeDirectory, 'Letta Code home directory');
|
||||
await inspectPhysicalDirectory(globalDirectory, 'Letta Code global configuration directory');
|
||||
if (comparisonPath(projectDirectory) !== comparisonPath(globalDirectory)) {
|
||||
await inspectPhysicalDirectory(projectDirectory, 'Letta Code project configuration directory');
|
||||
}
|
||||
const globalPath = path.join(globalDirectory, 'settings.json');
|
||||
const projectPath = path.join(projectDirectory, 'settings.json');
|
||||
const projectLocalPath = path.join(projectDirectory, 'settings.local.json');
|
||||
const [canonicalWorkspace, canonicalHome] = await Promise.all([
|
||||
canonicalPath(workspace),
|
||||
canonicalPath(homeDirectory),
|
||||
]);
|
||||
const projectActive = comparisonPath(canonicalWorkspace) !== comparisonPath(canonicalHome);
|
||||
const global = await readDocument('global', globalPath);
|
||||
const project = projectActive
|
||||
? await readDocument('project', projectPath)
|
||||
: createLettaDocument('project', projectPath);
|
||||
const projectLocal = await readDocument('project-local', projectLocalPath);
|
||||
const preconditions = deduplicatePreconditions([
|
||||
sourcePrecondition(global),
|
||||
...(projectActive ? [sourcePrecondition(project)] : []),
|
||||
sourcePrecondition(projectLocal),
|
||||
]);
|
||||
return {
|
||||
homeDirectory,
|
||||
global,
|
||||
project,
|
||||
projectLocal,
|
||||
projectActive,
|
||||
disableControl: effectiveDisable(global, project, projectLocal, projectActive),
|
||||
preconditions,
|
||||
};
|
||||
}
|
||||
|
||||
export function requireLettaHooksEnabled(sources: LettaSources): void {
|
||||
if (!sources.disableControl.disabled) return;
|
||||
const source = sources.disableControl.source;
|
||||
const location = source
|
||||
? `${lettaDocumentLabel(source)} at ${source.filePath}`
|
||||
: 'effective settings';
|
||||
throw new Error(`Letta Code hooks are disabled by hooks.disabled in ${location}`);
|
||||
}
|
||||
+63
-146
@@ -1,173 +1,90 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||
import {
|
||||
AGENT_KEY,
|
||||
LETTA_AUDIT_OPTIONS,
|
||||
buildLettaGroup,
|
||||
lettaRuntimeContracts,
|
||||
type LettaGroup,
|
||||
type ManagedLettaEvent,
|
||||
} from './letta-contract.js';
|
||||
import { renderLettaDocument } from './letta-config.js';
|
||||
import {
|
||||
commitLettaInstallation,
|
||||
commitLettaUninstall,
|
||||
preflightLettaInstallation,
|
||||
prepareLettaInstallation,
|
||||
prepareLettaUninstall,
|
||||
} from './letta-installation.js';
|
||||
import { managedRuntimeFilesExist } from './managed-runtime-status.js';
|
||||
import { readLettaSources } from './letta-sources.js';
|
||||
import { SUPPORTED_AGENTS } from './registry.js';
|
||||
|
||||
const AGENT_KEY = 'letta';
|
||||
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||
|
||||
function resolveConfigDir(): string {
|
||||
return entry.configDir.replace(/^~/, os.homedir());
|
||||
function installedGroups(guardPath: string, auditPath: string): ReadonlyMap<
|
||||
ManagedLettaEvent,
|
||||
LettaGroup
|
||||
> {
|
||||
return new Map([
|
||||
['PreToolUse', buildLettaGroup(guardPath)],
|
||||
['PostToolUse', buildLettaGroup(auditPath)],
|
||||
['PostToolUseFailure', buildLettaGroup(auditPath)],
|
||||
]);
|
||||
}
|
||||
|
||||
function resolveConfigPath(): string {
|
||||
return path.join(resolveConfigDir(), entry.configFile);
|
||||
}
|
||||
|
||||
function buildHookCommand(scriptPath: string): string {
|
||||
return `node "${scriptPath}"`;
|
||||
}
|
||||
|
||||
function isElydoraCommand(cmd: string, agentId?: string): boolean {
|
||||
if (!cmd.includes('elydora')) return false;
|
||||
if (agentId) {
|
||||
return cmd.includes(agentId);
|
||||
async function runtimeExists(
|
||||
contracts: ReturnType<typeof lettaRuntimeContracts>,
|
||||
): Promise<boolean> {
|
||||
for (const contract of contracts) {
|
||||
if (await managedRuntimeFilesExist(contract, AGENT_KEY, {
|
||||
auditOptions: LETTA_AUDIT_OPTIONS,
|
||||
})) return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function filterElydoraEntries(arr: Array<Record<string, unknown>>, agentId?: string): Array<Record<string, unknown>> {
|
||||
return arr.filter((entry) => {
|
||||
if (Array.isArray(entry.hooks)) {
|
||||
const cmds = entry.hooks as Array<Record<string, unknown>>;
|
||||
return !cmds.some((h) => typeof h.command === 'string' && isElydoraCommand(h.command, agentId));
|
||||
}
|
||||
if (typeof entry.command === 'string') {
|
||||
return !isElydoraCommand(entry.command, agentId);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
export const lettaPlugin: AgentPlugin = {
|
||||
managesRuntime: true,
|
||||
|
||||
async preflightInstall(config: InstallConfig): Promise<void> {
|
||||
const sources = await readLettaSources();
|
||||
await preflightLettaInstallation(config, sources);
|
||||
},
|
||||
|
||||
async install(config: InstallConfig): Promise<void> {
|
||||
const configDir = resolveConfigDir();
|
||||
await fsp.mkdir(configDir, { recursive: true });
|
||||
|
||||
const configPath = resolveConfigPath();
|
||||
let settings: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
// File doesn't exist or isn't valid JSON — start fresh
|
||||
}
|
||||
|
||||
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
||||
settings.hooks = {};
|
||||
}
|
||||
const hooks = settings.hooks as Record<string, unknown>;
|
||||
|
||||
// --- PreToolUse (guard — freeze enforcement) ---
|
||||
if (!Array.isArray(hooks.PreToolUse)) {
|
||||
hooks.PreToolUse = [];
|
||||
}
|
||||
const preFiltered = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>);
|
||||
preFiltered.push({
|
||||
matcher: '*',
|
||||
hooks: [{ type: 'command', command: buildHookCommand(config.guardScriptPath) }],
|
||||
});
|
||||
hooks.PreToolUse = preFiltered;
|
||||
|
||||
// --- PostToolUse (audit logging) ---
|
||||
if (!Array.isArray(hooks.PostToolUse)) {
|
||||
hooks.PostToolUse = [];
|
||||
}
|
||||
const postFiltered = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>);
|
||||
postFiltered.push({
|
||||
matcher: '*',
|
||||
hooks: [{ type: 'command', command: buildHookCommand(config.hookScriptPath) }],
|
||||
});
|
||||
hooks.PostToolUse = postFiltered;
|
||||
|
||||
settings.hooks = hooks;
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
const sources = await readLettaSources();
|
||||
const paths = await preflightLettaInstallation(config, sources);
|
||||
const rendered = renderLettaDocument(
|
||||
sources.global,
|
||||
undefined,
|
||||
installedGroups(paths.guardPath, paths.auditPath),
|
||||
);
|
||||
await commitLettaInstallation(
|
||||
await prepareLettaInstallation(config, sources, rendered),
|
||||
);
|
||||
console.log(` Letta Code hooks: ${sources.global.filePath}`);
|
||||
console.log(' Letta Code verification: run /hooks and restart active sessions.');
|
||||
},
|
||||
|
||||
async uninstall(agentId?: string): Promise<void> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let settings: Record<string, unknown>;
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
settings = JSON.parse(raw);
|
||||
} catch {
|
||||
return; // Nothing to uninstall
|
||||
}
|
||||
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (!hooks) return;
|
||||
|
||||
if (Array.isArray(hooks.PreToolUse)) {
|
||||
hooks.PreToolUse = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
if (Array.isArray(hooks.PostToolUse)) {
|
||||
hooks.PostToolUse = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>, agentId);
|
||||
}
|
||||
|
||||
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
const sources = await readLettaSources();
|
||||
const rendered = renderLettaDocument(sources.global, agentId, new Map());
|
||||
if (!rendered.changed) return;
|
||||
await commitLettaUninstall(await prepareLettaUninstall(sources, rendered));
|
||||
},
|
||||
|
||||
async status(): Promise<PluginStatus> {
|
||||
const configPath = resolveConfigPath();
|
||||
|
||||
let hookConfigured = false;
|
||||
let hookScriptPath = '';
|
||||
try {
|
||||
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||
const settings = JSON.parse(raw);
|
||||
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||
if (hooks) {
|
||||
const checkArr = (arr: unknown) => {
|
||||
if (!Array.isArray(arr)) return false;
|
||||
return (arr as Array<Record<string, unknown>>).some((entry) => {
|
||||
if (Array.isArray(entry.hooks)) {
|
||||
return (entry.hooks as Array<Record<string, unknown>>).some(
|
||||
(h) => typeof h.command === 'string' && h.command.includes('elydora'),
|
||||
);
|
||||
}
|
||||
return typeof entry.command === 'string' && entry.command.includes('elydora');
|
||||
});
|
||||
};
|
||||
hookConfigured = checkArr(hooks.PreToolUse) && checkArr(hooks.PostToolUse);
|
||||
|
||||
// Extract hook script path from PostToolUse command
|
||||
if (hookConfigured && Array.isArray(hooks.PostToolUse)) {
|
||||
for (const e of hooks.PostToolUse as Array<Record<string, unknown>>) {
|
||||
if (Array.isArray(e.hooks)) {
|
||||
for (const h of e.hooks as Array<Record<string, unknown>>) {
|
||||
const cmd = h.command as string;
|
||||
if (cmd && cmd.includes('elydora')) {
|
||||
hookScriptPath = cmd.replace(/^node\s+"?/, '').replace(/"?\s*$/, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Config not readable
|
||||
}
|
||||
|
||||
let hookScriptExists = false;
|
||||
if (hookScriptPath) {
|
||||
try {
|
||||
await fsp.access(hookScriptPath);
|
||||
hookScriptExists = true;
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
const sources = await readLettaSources();
|
||||
const contracts = lettaRuntimeContracts(sources.global.hooks);
|
||||
const hookConfigured = !sources.disableControl.disabled && contracts.length > 0;
|
||||
const hookScriptExists = hookConfigured ? await runtimeExists(contracts) : false;
|
||||
return {
|
||||
installed: hookConfigured && hookScriptExists,
|
||||
agentName: AGENT_KEY,
|
||||
displayName: entry.name,
|
||||
hookConfigured,
|
||||
hookScriptExists,
|
||||
configPath,
|
||||
configPath: sources.global.filePath,
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user