llms-ink 0.7.0 → 0.7.2
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/README.md +14 -1
- package/dist/guard-claude.d.ts +6 -1
- package/dist/guard-claude.js +73 -25
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/examples/guard-claude.mjs +12 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -307,7 +307,7 @@ JavaScript uses a 65-second total request deadline; pass `{ signal }` to cancel
|
|
|
307
307
|
`llms-ink/guard/claude` binds one Claude Agent SDK conversation to one authenticated local Guard session. Install the optional peer separately:
|
|
308
308
|
|
|
309
309
|
```sh
|
|
310
|
-
npm install llms-ink@0.7.
|
|
310
|
+
npm install llms-ink@0.7.2 @anthropic-ai/claude-agent-sdk@0.3.319
|
|
311
311
|
```
|
|
312
312
|
|
|
313
313
|
First [start the Python 0.10.0 local service](https://llms.ink/guard/service/) with a reviewed gateway configuration and existing Claude authentication. Then try the bundled terminal host:
|
|
@@ -330,3 +330,16 @@ The default fixed session TTL is 900 seconds (`ttl`, maximum 86400), with no ren
|
|
|
330
330
|
Allowed results go to Claude using the host's authentication, after local inspection. The adapter does not call hosted Inspect. Other SDK entry points do not import the optional Claude dependency. [Guide](https://llms.ink/guard/claude/) · [Runnable example](examples/guard-claude.mjs).
|
|
331
331
|
|
|
332
332
|
Lifecycle tests use real service sockets and subprocess effects with a simulated SDK loop. Run them with `LLMS_INK_GUARD_TEST_PYTHON=/absolute/python npm run test:guard` after installing Python `llms-ink==0.10.0`. The separately opted-in `test:guard:live` requires `LLMS_INK_CLAUDE_LIVE=1`, existing Claude authentication and the optional local model when `LLMS_INK_HOST_REAL_MODEL=1`. Scripted fixture approvals are interoperability evidence, not independent human review or proof of attack prevention.
|
|
333
|
+
|
|
334
|
+
### Guard host diagnostics
|
|
335
|
+
|
|
336
|
+
`GuardHostError.code` identifies the failed local check or a fixed host-reported result category. `operationCode` preserves the preceding operation when cleanup fails; the primary `code` then describes cleanup. The terminal example prints both. No raw provider error, prompt, tool output, command or credential is copied into these codes.
|
|
337
|
+
|
|
338
|
+
- `service_status_failed` / `session_inactive`: check the local service with `llms-ink-guard doctor --config FILE --owner-file FILE` (Python 0.12.0). Start a new conversation after resolving it.
|
|
339
|
+
- `guard_connection_failed` / `unexpected_tools` / `session_mismatch` / `host_not_initialized`: the host did not establish the expected guarded conversation. Check host compatibility and configuration; permissions are not broadened.
|
|
340
|
+
- `host_stream_failed` / `host_stream_ended` / `host_startup_failed`: the host failed to start or continue. These codes do not establish a provider, authentication or network cause.
|
|
341
|
+
- `host_result_error` / `host_turn_limit` / `host_budget_limit` / `host_output_retry_limit`: the host reported an error or one of its configured limits. Only known subtype names are mapped; error text is omitted.
|
|
342
|
+
- `message_callback_failed`: the application output callback failed. `turn_timeout`, `session_expired` and `conversation_closed` distinguish adapter deadlines and closure.
|
|
343
|
+
- `revocation_unconfirmed`, `host_cleanup_failed` or `credential_cleanup_failed`: retain the reported private directory and retry `close()` or use owner-controlled revocation. Do not treat cleanup as complete.
|
|
344
|
+
|
|
345
|
+
A failed running turn closes the conversation and attempts revocation; no automatic retry or unguarded fallback occurs. Successful turns may already have performed approved actions, and an error can occur after a tool effect: these codes are not proof of non-execution. Session IDs and recovery paths remain available only for cleanup failures and should not be posted publicly. `host_error` is the fallback for argument/state errors and externally constructed errors.
|
package/dist/guard-claude.d.ts
CHANGED
|
@@ -31,10 +31,15 @@ export interface GuardedClaudeConversation {
|
|
|
31
31
|
/** Revoke authority and close the host. Idempotent after confirmed cleanup; retry on cleanup errors. */
|
|
32
32
|
close(): Promise<void>;
|
|
33
33
|
}
|
|
34
|
+
/** Local failure categories. No provider text, tool output or credentials are copied into these values. */
|
|
35
|
+
export type GuardHostErrorCode = 'host_error' | 'session_create_failed' | 'host_startup_failed' | 'service_status_failed' | 'session_inactive' | 'host_stream_failed' | 'host_stream_ended' | 'session_mismatch' | 'guard_connection_failed' | 'unexpected_tools' | 'host_not_initialized' | 'message_callback_failed' | 'host_result_error' | 'host_turn_limit' | 'host_budget_limit' | 'host_output_retry_limit' | 'turn_timeout' | 'session_expired' | 'conversation_closed' | 'revocation_unconfirmed' | 'host_cleanup_failed' | 'credential_cleanup_failed';
|
|
34
36
|
export declare class GuardHostError extends Error {
|
|
35
37
|
readonly sessionId: string | undefined;
|
|
36
38
|
readonly credentialDirectory: string | undefined;
|
|
37
|
-
|
|
39
|
+
readonly code: GuardHostErrorCode;
|
|
40
|
+
/** Original operation failure when cleanup itself failed. Cleanup remains the primary error. */
|
|
41
|
+
readonly operationCode: GuardHostErrorCode | undefined;
|
|
42
|
+
constructor(message: string, sessionId?: string, credentialDirectory?: string, code?: GuardHostErrorCode, operationCode?: GuardHostErrorCode);
|
|
38
43
|
}
|
|
39
44
|
/**
|
|
40
45
|
* Create one non-resumable, tool-scoped conversation. New conversations require new instances.
|
package/dist/guard-claude.js
CHANGED
|
@@ -8,11 +8,16 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
|
8
8
|
export class GuardHostError extends Error {
|
|
9
9
|
sessionId;
|
|
10
10
|
credentialDirectory;
|
|
11
|
-
|
|
11
|
+
code;
|
|
12
|
+
/** Original operation failure when cleanup itself failed. Cleanup remains the primary error. */
|
|
13
|
+
operationCode;
|
|
14
|
+
constructor(message, sessionId, credentialDirectory, code = 'host_error', operationCode) {
|
|
12
15
|
super(message);
|
|
13
16
|
this.name = 'GuardHostError';
|
|
14
17
|
this.sessionId = sessionId;
|
|
15
18
|
this.credentialDirectory = credentialDirectory;
|
|
19
|
+
this.code = code;
|
|
20
|
+
this.operationCode = operationCode;
|
|
16
21
|
}
|
|
17
22
|
}
|
|
18
23
|
const idPattern = /^[a-f0-9]{32}$/;
|
|
@@ -102,7 +107,7 @@ export async function createGuardedClaude(options) {
|
|
|
102
107
|
throw new Error();
|
|
103
108
|
}
|
|
104
109
|
catch {
|
|
105
|
-
throw new GuardHostError('Session revocation could not be confirmed. Keep the credential directory and retry close or revoke it with the owner CLI.', sessionId, directory);
|
|
110
|
+
throw new GuardHostError('Session revocation could not be confirmed. Keep the credential directory and retry close or revoke it with the owner CLI.', sessionId, directory, 'revocation_unconfirmed');
|
|
106
111
|
}
|
|
107
112
|
}
|
|
108
113
|
}
|
|
@@ -125,15 +130,26 @@ export async function createGuardedClaude(options) {
|
|
|
125
130
|
}
|
|
126
131
|
catch { /* no recoverable credential */ }
|
|
127
132
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
133
|
+
try {
|
|
134
|
+
await revoke();
|
|
135
|
+
await rm(directory, { recursive: true, force: true });
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
throw cleanupError(error, 'session_create_failed');
|
|
139
|
+
}
|
|
140
|
+
throw new GuardHostError('Could not create a guarded conversation.', undefined, undefined, 'session_create_failed');
|
|
131
141
|
}
|
|
132
142
|
const guardId = sessionId;
|
|
133
143
|
const hostId = randomUUID(), input = new Input(), abort = new AbortController();
|
|
134
144
|
let closing = false, closed = false, busy = false, initialized = false, closeWork;
|
|
135
145
|
let host;
|
|
136
146
|
let expiresTimer;
|
|
147
|
+
let stopReason;
|
|
148
|
+
function cleanupError(error, operationCode) {
|
|
149
|
+
if (error instanceof GuardHostError)
|
|
150
|
+
return new GuardHostError(error.message, error.sessionId, error.credentialDirectory, error.code, operationCode);
|
|
151
|
+
return new GuardHostError('Credential cleanup did not complete. Retry cleanup.', sessionId, directory, 'credential_cleanup_failed', operationCode);
|
|
152
|
+
}
|
|
137
153
|
async function whileOpen(work) {
|
|
138
154
|
if (abort.signal.aborted)
|
|
139
155
|
throw new GuardHostError('Conversation closed.');
|
|
@@ -154,6 +170,7 @@ export async function createGuardedClaude(options) {
|
|
|
154
170
|
return;
|
|
155
171
|
if (closeWork)
|
|
156
172
|
return closeWork;
|
|
173
|
+
stopReason ??= 'conversation_closed';
|
|
157
174
|
closing = true;
|
|
158
175
|
input.close();
|
|
159
176
|
abort.abort();
|
|
@@ -170,11 +187,16 @@ export async function createGuardedClaude(options) {
|
|
|
170
187
|
host?.close();
|
|
171
188
|
}
|
|
172
189
|
catch {
|
|
173
|
-
failure ??= new GuardHostError('Claude host cleanup did not complete. Retry close.', guardId, directory);
|
|
190
|
+
failure ??= new GuardHostError('Claude host cleanup did not complete. Retry close.', guardId, directory, 'host_cleanup_failed');
|
|
174
191
|
}
|
|
175
192
|
if (failure)
|
|
176
193
|
throw failure;
|
|
177
|
-
|
|
194
|
+
try {
|
|
195
|
+
await rm(directory, { recursive: true, force: true });
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
throw new GuardHostError('Credential cleanup did not complete. Retry close.', guardId, directory, 'credential_cleanup_failed');
|
|
199
|
+
}
|
|
178
200
|
closed = true;
|
|
179
201
|
})();
|
|
180
202
|
try {
|
|
@@ -185,7 +207,7 @@ export async function createGuardedClaude(options) {
|
|
|
185
207
|
}
|
|
186
208
|
}
|
|
187
209
|
async function elicit(request, context) {
|
|
188
|
-
if (closing || !busy || context.signal.aborted || request.serverName !== 'guarded' || (request.mode ?? 'form') !== 'form'
|
|
210
|
+
if (closing || !initialized || !busy || context.signal.aborted || request.serverName !== 'guarded' || (request.mode ?? 'form') !== 'form'
|
|
189
211
|
|| typeof request.message !== 'string' || Buffer.byteLength(request.message) > 8192 || !sameShape(request.requestedSchema, reviewSchema))
|
|
190
212
|
return { action: 'decline' };
|
|
191
213
|
const lifetime = new AbortController();
|
|
@@ -224,53 +246,79 @@ export async function createGuardedClaude(options) {
|
|
|
224
246
|
host = query({ prompt: input, options: sdkOptions });
|
|
225
247
|
// Revocation on idle expiry does not wait for another prompt. Failures remain
|
|
226
248
|
// observable through close(); the service independently enforces its TTL.
|
|
227
|
-
expiresTimer = setTimeout(() => { void close().catch(() => { }); }, ttl * 1000);
|
|
249
|
+
expiresTimer = setTimeout(() => { stopReason ??= 'session_expired'; void close().catch(() => { }); }, ttl * 1000);
|
|
228
250
|
expiresTimer.unref();
|
|
229
251
|
}
|
|
230
252
|
catch {
|
|
231
|
-
|
|
232
|
-
|
|
253
|
+
try {
|
|
254
|
+
await close();
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
throw cleanupError(error, 'host_startup_failed');
|
|
258
|
+
}
|
|
259
|
+
throw new GuardHostError('Claude host startup failed.', undefined, undefined, 'host_startup_failed');
|
|
233
260
|
}
|
|
234
261
|
return Object.freeze({ sessionId: guardId,
|
|
235
262
|
async run(prompt, onMessage) {
|
|
236
263
|
if (closing || busy)
|
|
237
|
-
throw new GuardHostError('The conversation is closed or another turn is running.');
|
|
264
|
+
throw new GuardHostError('The conversation is closed or another turn is running.', undefined, undefined, closing ? (stopReason ?? 'conversation_closed') : 'host_error');
|
|
238
265
|
if (typeof prompt !== 'string' || !prompt.trim() || prompt.trimStart().startsWith('/') || Buffer.byteLength(prompt) > 131072)
|
|
239
266
|
throw new GuardHostError('Provide a text prompt up to 128 KiB; host commands require a new conversation.');
|
|
240
267
|
busy = true;
|
|
241
|
-
|
|
268
|
+
let failureCode = 'service_status_failed';
|
|
269
|
+
function fail(code) { failureCode = code; throw new Error(); }
|
|
270
|
+
const timer = setTimeout(() => { stopReason ??= 'turn_timeout'; void close().catch(() => { }); }, timeout);
|
|
242
271
|
try {
|
|
243
272
|
const state = await control(python, ['service-status', '--owner-file', ownerFile]);
|
|
244
273
|
if (closing || state.ok !== true || !Array.isArray(state.sessions) || !state.sessions.some(s => s?.sessionId === guardId && s.active === true))
|
|
245
|
-
|
|
274
|
+
fail('session_inactive');
|
|
275
|
+
failureCode = 'host_stream_failed';
|
|
246
276
|
input.send(prompt, hostId);
|
|
247
277
|
while (true) {
|
|
278
|
+
failureCode = 'host_stream_failed';
|
|
248
279
|
const next = await whileOpen(host.next());
|
|
249
280
|
if (next.done || closing)
|
|
250
|
-
|
|
281
|
+
fail('host_stream_ended');
|
|
251
282
|
const message = next.value;
|
|
252
283
|
if ('session_id' in message && message.session_id !== hostId)
|
|
253
|
-
|
|
284
|
+
fail('session_mismatch');
|
|
254
285
|
if (message.type === 'system' && message.subtype === 'init') {
|
|
255
|
-
if (message.mcp_servers.length !== 1 || message.mcp_servers[0]
|
|
256
|
-
|
|
257
|
-
|
|
286
|
+
if (!Array.isArray(message.mcp_servers) || message.mcp_servers.length !== 1 || message.mcp_servers[0]?.name !== 'guarded' || message.mcp_servers[0]?.status !== 'connected')
|
|
287
|
+
fail('guard_connection_failed');
|
|
288
|
+
if (!Array.isArray(message.tools) || !message.tools.length || message.tools.some(t => typeof t !== 'string' || !t.startsWith('mcp__guarded__')))
|
|
289
|
+
fail('unexpected_tools');
|
|
258
290
|
initialized = true;
|
|
259
291
|
}
|
|
292
|
+
// Error results can precede initialization. Only fixed subtype names are retained.
|
|
293
|
+
if (message.type === 'result' && message.is_error) {
|
|
294
|
+
const code = message.subtype === 'error_max_turns' ? 'host_turn_limit' : message.subtype === 'error_max_budget_usd' ? 'host_budget_limit' : message.subtype === 'error_max_structured_output_retries' ? 'host_output_retry_limit' : 'host_result_error';
|
|
295
|
+
fail(code);
|
|
296
|
+
}
|
|
297
|
+
// Claude can emit subscription rate status before its initialization
|
|
298
|
+
// announcement. Discard it until the guard connection and tools are
|
|
299
|
+
// verified; it grants no authority and never reaches the callback.
|
|
300
|
+
if (!initialized && message.type === 'rate_limit_event')
|
|
301
|
+
continue;
|
|
260
302
|
if (!initialized && message.type !== 'system')
|
|
261
|
-
|
|
262
|
-
if (onMessage)
|
|
303
|
+
fail('host_not_initialized');
|
|
304
|
+
if (onMessage) {
|
|
305
|
+
failureCode = 'message_callback_failed';
|
|
263
306
|
await whileOpen(Promise.resolve().then(() => onMessage(message)));
|
|
307
|
+
}
|
|
264
308
|
if (message.type === 'result') {
|
|
265
|
-
if (message.is_error)
|
|
266
|
-
throw new Error();
|
|
267
309
|
return message;
|
|
268
310
|
}
|
|
269
311
|
}
|
|
270
312
|
}
|
|
271
313
|
catch {
|
|
272
|
-
|
|
273
|
-
|
|
314
|
+
const code = stopReason ?? failureCode;
|
|
315
|
+
try {
|
|
316
|
+
await close();
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
throw cleanupError(error, code);
|
|
320
|
+
}
|
|
321
|
+
throw new GuardHostError(`Guarded turn did not complete (${code}); the conversation was closed.`, undefined, undefined, code);
|
|
274
322
|
}
|
|
275
323
|
finally {
|
|
276
324
|
clearTimeout(timer);
|
package/dist/index.d.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { type InspectionReceipt } from './inspect.ts';
|
|
17
17
|
export type { InspectionReceipt } from './inspect.ts';
|
|
18
|
-
export declare const VERSION = "0.7.
|
|
18
|
+
export declare const VERSION = "0.7.2";
|
|
19
19
|
export declare const DEFAULT_BASE_URL = "https://llms.ink";
|
|
20
20
|
/** `llms` for the index (llms.txt), `full` for llms-full.txt. */
|
|
21
21
|
export type Mode = 'llms' | 'full';
|
package/dist/index.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { prepareInspection, inspectionJson, inspectionReceipt } from "./inspect.js";
|
|
17
17
|
import { env } from "./env.js";
|
|
18
|
-
export const VERSION = '0.7.
|
|
18
|
+
export const VERSION = '0.7.2';
|
|
19
19
|
export const DEFAULT_BASE_URL = 'https://llms.ink';
|
|
20
20
|
/**
|
|
21
21
|
* An API error, or llms.ink couldn't be reached (then `status` and `code` are undefined).
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Run from an installed llms-ink package with the optional Claude SDK peer installed.
|
|
3
3
|
import {parseArgs} from 'node:util';
|
|
4
4
|
import {createInterface} from 'node:readline/promises';
|
|
5
|
-
import {createGuardedClaude} from '../dist/guard-claude.js';
|
|
5
|
+
import {createGuardedClaude,GuardHostError} from '../dist/guard-claude.js';
|
|
6
6
|
const {values}=parseArgs({options:{'owner-file':{type:'string'},python:{type:'string'},cwd:{type:'string'},model:{type:'string'},'claude-executable':{type:'string'}}});
|
|
7
7
|
if(!process.stdin.isTTY || !process.stdout.isTTY) throw Error('This example requires an interactive terminal for each approval.');
|
|
8
8
|
if(!values['owner-file'] || !values.python || !values.cwd) throw Error('Pass --owner-file, --python and --cwd with absolute paths.');
|
|
@@ -10,6 +10,15 @@ const terminal=createInterface({input:process.stdin,output:process.stdout});
|
|
|
10
10
|
// Preserve complete text while making terminal controls and bidi controls visible.
|
|
11
11
|
const plain=text=>text.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g,c=>'\\u'+c.charCodeAt(0).toString(16).padStart(4,'0'));
|
|
12
12
|
let conversation,stopping=false;
|
|
13
|
+
const report=error=>{
|
|
14
|
+
console.error(error instanceof GuardHostError?error.message:'The terminal conversation did not complete.');
|
|
15
|
+
if(error instanceof GuardHostError){
|
|
16
|
+
console.error('Failure code:',error.code);
|
|
17
|
+
if(error.operationCode)console.error('Original operation:',error.operationCode);
|
|
18
|
+
if(error.sessionId)console.error('Session ID:',error.sessionId);
|
|
19
|
+
if(error.credentialDirectory)console.error('Credential directory:',error.credentialDirectory);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
13
22
|
const stop=()=>{stopping=true;terminal.close();void conversation?.close().catch(()=>{});};
|
|
14
23
|
terminal.on('SIGINT',stop);terminal.on('close',()=>{stopping=true;void conversation?.close().catch(()=>{});});process.on('SIGTERM',stop);
|
|
15
24
|
try{
|
|
@@ -27,8 +36,8 @@ try{
|
|
|
27
36
|
if(message.type==='assistant')for(const part of message.message.content)if(part.type==='text')process.stdout.write(plain(part.text)+'\n');
|
|
28
37
|
});
|
|
29
38
|
}
|
|
30
|
-
}catch(error){if(!stopping){
|
|
39
|
+
}catch(error){if(!stopping){report(error);process.exitCode=1;}}
|
|
31
40
|
finally{
|
|
32
41
|
terminal.close();process.removeListener('SIGTERM',stop);
|
|
33
|
-
try{await conversation?.close();}catch(error){
|
|
42
|
+
try{await conversation?.close();}catch(error){report(error);process.exitCode=1;}
|
|
34
43
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "llms-ink",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "Turn any link — site, docs section, page, GitHub repo, PDF or video — into llms.txt and llms-full.txt. Typed client for llms.ink, plus a Vite plugin, an Astro integration and middleware that serve the files from your site, and Vercel AI SDK tools. No dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|