@ciphrix/cli 0.1.0 → 0.2.0

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.
@@ -1,4 +1,4 @@
1
- import { writeLine, writeNextHint, writeTable } from '../io.js';
1
+ import { writeLine, writeNextHint, writePageFooter, writeTable } from '../io.js';
2
2
  import { asRecord, callTool, errorMessage, resolveToolContext, asText } from '../toolSurface.js';
3
3
  const text = (value, fallback = '-') => typeof value === 'string' ? value : typeof value === 'number' ? String(value) : fallback;
4
4
  export const frameworkListCommand = async ({ io, apiUrl, json = false, credentialStore, }) => {
@@ -34,7 +34,8 @@ export const frameworkListCommand = async ({ io, apiUrl, json = false, credentia
34
34
  writeLine(io.stdout, `${io.theme.dim(`Available to add (${available.length}):`)}`);
35
35
  for (const framework of available) {
36
36
  const record = asRecord(framework);
37
- writeLine(io.stdout, ` ${text(record.frameworkName ?? record.name)}`);
37
+ const code = asText(record.frameworkCode ?? record.code);
38
+ writeLine(io.stdout, ` ${code ? `${code} — ` : ''}${text(record.frameworkName ?? record.name)}`);
38
39
  }
39
40
  }
40
41
  };
@@ -118,11 +119,15 @@ export const clauseItemsCommand = async ({ io, framework, clause, apiUrl, json =
118
119
  return [text(record.name, text(record.id)), text(record.itemType ?? record.type)];
119
120
  }));
120
121
  };
121
- export const clauseAvailableItemsCommand = async ({ io, framework, clause, itemType, apiUrl, json = false, credentialStore, search, }) => {
122
+ export const clauseAvailableItemsCommand = async ({ io, framework, clause, itemType, apiUrl, json = false, credentialStore, search, page, limit, }) => {
122
123
  const ctx = await resolveToolContext(apiUrl, credentialStore);
123
124
  const input = { framework, clause, itemType };
124
125
  if (search)
125
126
  input.search = search;
127
+ if (page)
128
+ input.page = page;
129
+ if (limit)
130
+ input.limit = limit;
126
131
  const envelope = await callTool(ctx, 'list_clause_items_available', input);
127
132
  if (envelope.status !== 'ok')
128
133
  throw new Error(errorMessage(envelope, 'Could not read available items.'));
@@ -130,13 +135,24 @@ export const clauseAvailableItemsCommand = async ({ io, framework, clause, itemT
130
135
  writeLine(io.stdout, JSON.stringify(envelope.data, null, 2));
131
136
  return;
132
137
  }
133
- const items = asRecord(envelope.data).items;
138
+ const data = asRecord(envelope.data);
139
+ const items = data.items;
134
140
  const list = Array.isArray(items) ? items : [];
135
141
  writeLine(io.stdout, `${io.theme.bold(`Available ${itemType}s`)} ${io.theme.dim(`(${list.length})`)}`);
136
142
  for (const item of list) {
137
143
  const record = asRecord(item);
138
144
  writeLine(io.stdout, ` ${text(record.name, text(record.id))}`);
139
145
  }
146
+ const total = typeof data.total === 'number' ? data.total : list.length;
147
+ const currentPage = typeof data.page === 'number' ? data.page : 1;
148
+ const pageSize = typeof data.limit === 'number' ? data.limit : 25;
149
+ writePageFooter(io, {
150
+ shown: list.length,
151
+ total,
152
+ page: currentPage,
153
+ limit: pageSize,
154
+ hasMore: data.hasMore === true || currentPage * pageSize < total,
155
+ });
140
156
  };
141
157
  export const clauseUpdateCommand = async ({ io, framework, clause, changes, apiUrl, json = false, yes = false, credentialStore, }) => {
142
158
  const { applyTool } = await import('../actions.js');
@@ -20,7 +20,7 @@ export const policyListCommand = async ({ io, apiUrl, json = false, name, status
20
20
  if (envelope.status !== 'ok')
21
21
  throw new Error(errorMessage(envelope, 'Could not list documents.'));
22
22
  if (json) {
23
- writeLine(io.stdout, JSON.stringify(envelope, null, 2));
23
+ writeLine(io.stdout, JSON.stringify(envelope.data, null, 2));
24
24
  return;
25
25
  }
26
26
  const data = asRecord(envelope.data);
@@ -29,8 +29,8 @@ export declare const testRunCreateCommand: ({ io, name, apiUrl, json, credential
29
29
  runAt?: string | undefined;
30
30
  }) => Promise<void>;
31
31
  export declare const testRunResultCommand: ({ io, name, runId, apiUrl, json, credentialStore, status, note, }: TestReadOptions & {
32
- status: string;
33
- note?: string | undefined;
32
+ status?: string | undefined;
33
+ note?: string | null | undefined;
34
34
  }) => Promise<void>;
35
35
  export declare const testRunDeleteCommand: ({ io, name, runId, apiUrl, json, yes, confirm, credentialStore, }: TestActionOptions) => Promise<void>;
36
36
  export interface TestAttachOptions extends TestReadOptions {
@@ -41,9 +41,11 @@ export interface TestAttachOptions extends TestReadOptions {
41
41
  unlink?: boolean | undefined;
42
42
  }
43
43
  export declare const testAttachCommand: ({ io, name, runId, apiUrl, json, credentialStore, itemType, itemId, uploadIds, file, unlink, }: TestAttachOptions) => Promise<void>;
44
- export declare const testAvailableItemsCommand: ({ io, name, runId, itemType, apiUrl, json, credentialStore, search, }: TestReadOptions & {
44
+ export declare const testAvailableItemsCommand: ({ io, name, runId, itemType, apiUrl, json, credentialStore, search, page, limit, }: TestReadOptions & {
45
45
  itemType: string;
46
46
  search?: string | undefined;
47
+ page?: number | undefined;
48
+ limit?: number | undefined;
47
49
  }) => Promise<void>;
48
50
  export declare const testLinksCommand: ({ io, name, apiUrl, json, credentialStore, }: TestReadOptions) => Promise<void>;
49
51
  export declare const testUpdateCommand: ({ io, name, apiUrl, json, yes, confirm, credentialStore, changes, }: TestReadOptions & {
@@ -113,6 +113,12 @@ const renderRuns = (io, runs, assurance) => {
113
113
  assuranceLabel(assurance[text(record.id)]),
114
114
  ];
115
115
  }));
116
+ for (const run of runs) {
117
+ const record = asRecord(run);
118
+ if (typeof record.note === 'string' && record.note.trim() !== '') {
119
+ writeLine(io.stdout, `Note (${text(record.id)}): ${record.note}`);
120
+ }
121
+ }
116
122
  };
117
123
  const assuranceLabel = (assessment) => assessmentLabel(assessment);
118
124
  export const testItemsCommand = async ({ io, name, runId, apiUrl, json = false, credentialStore, }) => {
@@ -162,11 +168,15 @@ export const testRunCreateCommand = async ({ io, name, apiUrl, json = false, cre
162
168
  writeLine(io.stdout, `${io.theme.green('✓')} run created: ${text(asRecord(result.data).runId)}`);
163
169
  };
164
170
  export const testRunResultCommand = async ({ io, name, runId, apiUrl, json = false, credentialStore, status, note, }) => {
171
+ if (status === undefined && note === undefined)
172
+ throw new Error('Provide --status, --note or --clear-note.');
165
173
  const ctx = await resolveToolContext(apiUrl, credentialStore);
166
- const input = { name, status };
174
+ const input = { name };
175
+ if (status !== undefined)
176
+ input.status = status;
167
177
  if (runId)
168
178
  input.runId = runId;
169
- if (note)
179
+ if (note !== undefined)
170
180
  input.note = note;
171
181
  const result = await applyTool({
172
182
  ctx,
@@ -180,7 +190,12 @@ export const testRunResultCommand = async ({ io, name, runId, apiUrl, json = fal
180
190
  if (json)
181
191
  writeLine(io.stdout, JSON.stringify(result.data, null, 2));
182
192
  else
183
- writeLine(io.stdout, `${io.theme.green('✓')} run result: ${status}`);
193
+ writeLine(io.stdout, status === undefined
194
+ ? 'Run note updated; result unchanged.'
195
+ : `${io.theme.green('✓')} run result: ${status}`);
196
+ const jobId = asText(asRecord(result.meta).aiTaskId);
197
+ if (!json && jobId)
198
+ writeNextHint(io, `ciphrix job status ${jobId}`);
184
199
  };
185
200
  export const testRunDeleteCommand = async ({ io, name, runId, apiUrl, json = false, yes = false, confirm, credentialStore, }) => {
186
201
  if (!runId)
@@ -235,13 +250,17 @@ export const testAttachCommand = async ({ io, name, runId, apiUrl, json = false,
235
250
  const job = asText(data.aiTaskId);
236
251
  writeLine(io.stdout, `${io.theme.green('✓')} ${unlink ? 'unlinked' : 'linked'} ${resolvedItemType} on run ${text(data.runId)}${job ? io.theme.dim(` · job ${job}`) : ''}`);
237
252
  };
238
- export const testAvailableItemsCommand = async ({ io, name, runId, itemType, apiUrl, json = false, credentialStore, search, }) => {
253
+ export const testAvailableItemsCommand = async ({ io, name, runId, itemType, apiUrl, json = false, credentialStore, search, page, limit, }) => {
239
254
  const ctx = await resolveToolContext(apiUrl, credentialStore);
240
255
  const input = { name, itemType };
241
256
  if (runId)
242
257
  input.runId = runId;
243
258
  if (search)
244
259
  input.search = search;
260
+ if (page)
261
+ input.page = page;
262
+ if (limit)
263
+ input.limit = limit;
245
264
  const envelope = await callTool(ctx, 'list_test_items_available', input);
246
265
  if (envelope.status !== 'ok')
247
266
  throw new Error(errorMessage(envelope, 'Could not read available items.'));
@@ -264,6 +283,16 @@ export const testAvailableItemsCommand = async ({ io, name, runId, itemType, api
264
283
  text(record.status),
265
284
  ];
266
285
  }));
286
+ const total = typeof data.total === 'number' ? data.total : items.length;
287
+ const currentPage = typeof data.page === 'number' ? data.page : 1;
288
+ const pageSize = typeof data.limit === 'number' ? data.limit : 25;
289
+ writePageFooter(io, {
290
+ shown: items.length,
291
+ total,
292
+ page: currentPage,
293
+ limit: pageSize,
294
+ hasMore: data.hasMore === true || currentPage * pageSize < total,
295
+ });
267
296
  };
268
297
  export const testLinksCommand = async ({ io, name, apiUrl, json = false, credentialStore, }) => {
269
298
  const ctx = await resolveToolContext(apiUrl, credentialStore);
@@ -22,10 +22,13 @@ export declare const vendorListCommand: ({ io, apiUrl, json, credentialStore, na
22
22
  limit?: number | undefined;
23
23
  }) => Promise<void>;
24
24
  export declare const vendorGetCommand: ({ io, name, apiUrl, json, credentialStore, }: VendorReadOptions) => Promise<void>;
25
- export declare const vendorCreateCommand: ({ io, apiUrl, json, credentialStore, name, category, criticality, description, }: VendorCommonOptions & {
25
+ export declare const vendorCreateCommand: ({ io, apiUrl, json, credentialStore, name, category, subcategory, criticality, dataSensitivity, serviceAvailability, description, }: VendorCommonOptions & {
26
26
  name: string;
27
27
  category?: string | undefined;
28
+ subcategory?: string | undefined;
28
29
  criticality?: string | undefined;
30
+ dataSensitivity?: string | undefined;
31
+ serviceAvailability?: string | undefined;
29
32
  description?: string | undefined;
30
33
  }) => Promise<void>;
31
34
  export interface VendorUpdateOptions extends VendorReadOptions {
@@ -78,13 +78,19 @@ export const vendorGetCommand = async ({ io, name, apiUrl, json = false, credent
78
78
  writeLine(io.stdout, ` ${io.theme.dim(`${label}:`)} ${text(value)}`);
79
79
  }
80
80
  };
81
- export const vendorCreateCommand = async ({ io, apiUrl, json = false, credentialStore, name, category, criticality, description, }) => {
81
+ export const vendorCreateCommand = async ({ io, apiUrl, json = false, credentialStore, name, category, subcategory, criticality, dataSensitivity, serviceAvailability, description, }) => {
82
82
  const ctx = await resolveToolContext(apiUrl, credentialStore);
83
83
  const input = { name };
84
84
  if (category)
85
85
  input.category = category;
86
+ if (subcategory)
87
+ input.subcategory = subcategory;
86
88
  if (criticality)
87
89
  input.criticality = criticality;
90
+ if (dataSensitivity)
91
+ input.dataSensitivity = dataSensitivity;
92
+ if (serviceAvailability)
93
+ input.serviceAvailability = serviceAvailability;
88
94
  if (description)
89
95
  input.description = description;
90
96
  const result = await applyTool({
@@ -4,4 +4,4 @@
4
4
  */
5
5
  export declare const CLI_NAME = "ciphrix";
6
6
  export declare const CLI_TAGLINE = "Ciphrix compliance platform CLI";
7
- export declare const CLI_VERSION = "0.1.0";
7
+ export declare const CLI_VERSION = "0.2.0";
package/dist/constants.js CHANGED
@@ -4,4 +4,4 @@
4
4
  */
5
5
  export const CLI_NAME = 'ciphrix';
6
6
  export const CLI_TAGLINE = 'Ciphrix compliance platform CLI';
7
- export const CLI_VERSION = '0.1.0';
7
+ export const CLI_VERSION = '0.2.0';
@@ -0,0 +1,19 @@
1
+ import type { ToolContext } from './toolSurface.js';
2
+ export interface ToolDescriptor {
3
+ name: string;
4
+ version: number;
5
+ description: string;
6
+ effect: 'read' | 'write' | 'destructive';
7
+ confirmation: 'none' | 'preflight_confirm';
8
+ inputSchema: Record<string, unknown>;
9
+ cli?: {
10
+ group: string[];
11
+ operation: string;
12
+ };
13
+ limits?: Record<string, unknown>;
14
+ }
15
+ export declare class ManifestError extends Error {
16
+ }
17
+ export declare const getManifest: (ctx: ToolContext) => Promise<ToolDescriptor[]>;
18
+ export declare const getTool: (ctx: ToolContext, name: string) => Promise<ToolDescriptor>;
19
+ export declare const resolveBinding: (ctx: ToolContext, group: string[], operation: string) => Promise<ToolDescriptor>;
@@ -0,0 +1,93 @@
1
+ export class ManifestError extends Error {
2
+ }
3
+ const cache = new WeakMap();
4
+ const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
5
+ ? value
6
+ : {};
7
+ const checkSchema = (value, depth = 0, budget = { properties: 0 }) => {
8
+ if (depth > 32)
9
+ throw new ManifestError('Tool schema exceeds the supported nesting limit.');
10
+ if (Array.isArray(value)) {
11
+ for (const child of value)
12
+ checkSchema(child, depth + 1, budget);
13
+ }
14
+ else if (value && typeof value === 'object') {
15
+ const schema = record(value);
16
+ budget.properties += Object.keys(record(schema.properties)).length;
17
+ if (budget.properties > 1000)
18
+ throw new ManifestError('Tool schema exceeds the supported property limit.');
19
+ for (const child of Object.values(schema))
20
+ checkSchema(child, depth + 1, budget);
21
+ }
22
+ };
23
+ export const getManifest = (ctx) => {
24
+ const previous = cache.get(ctx);
25
+ if (previous?.baseUrl === ctx.baseUrl && previous.token === ctx.token)
26
+ return previous.manifest;
27
+ const loaded = (async () => {
28
+ const body = record(await ctx.client.request('/tools/v1', { token: ctx.token }));
29
+ if (body.manifestVersion !== 1 ||
30
+ body.apiVersion !== 'v1' ||
31
+ !Array.isArray(body.tools) ||
32
+ body.tools.length > 1024) {
33
+ throw new ManifestError('Unsupported tool manifest. Update the client or contact your administrator.');
34
+ }
35
+ const names = new Set();
36
+ const bindings = new Set();
37
+ return body.tools.map((value) => {
38
+ const tool = record(value);
39
+ if (typeof tool.name !== 'string' ||
40
+ !/^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/.test(tool.name) ||
41
+ !Number.isInteger(tool.version) ||
42
+ Number(tool.version) < 1 ||
43
+ !['read', 'write', 'destructive'].includes(String(tool.effect)) ||
44
+ !['none', 'preflight_confirm'].includes(String(tool.confirmation)) ||
45
+ typeof tool.description !== 'string' ||
46
+ !tool.inputSchema ||
47
+ typeof tool.inputSchema !== 'object' ||
48
+ Array.isArray(tool.inputSchema) ||
49
+ names.has(tool.name))
50
+ throw new ManifestError('Invalid or unsupported tool descriptor.');
51
+ names.add(tool.name);
52
+ checkSchema(tool.inputSchema);
53
+ if (tool.cli !== undefined) {
54
+ const binding = record(tool.cli);
55
+ const segment = (part) => typeof part === 'string' &&
56
+ part.length <= 64 &&
57
+ /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(part);
58
+ if (!Array.isArray(binding.group) ||
59
+ binding.group.length < 1 ||
60
+ binding.group.length > 6 ||
61
+ !binding.group.every(segment) ||
62
+ !segment(binding.operation))
63
+ throw new ManifestError('Invalid command binding.');
64
+ const key = JSON.stringify([binding.group, binding.operation]);
65
+ if (tool.version === 1 && bindings.has(key))
66
+ throw new ManifestError('Ambiguous command binding in tool manifest.');
67
+ if (tool.version === 1)
68
+ bindings.add(key);
69
+ }
70
+ return tool;
71
+ });
72
+ })();
73
+ cache.set(ctx, { baseUrl: ctx.baseUrl, token: ctx.token, manifest: loaded });
74
+ return loaded;
75
+ };
76
+ export const getTool = async (ctx, name) => {
77
+ const tool = (await getManifest(ctx)).find((candidate) => candidate.name === name);
78
+ if (!tool)
79
+ throw new ManifestError('This operation is not available in the current tool manifest.');
80
+ if (tool.version !== 1)
81
+ throw new ManifestError('This operation version is not supported. Update the CLI.');
82
+ if (tool.effect === 'destructive')
83
+ throw new ManifestError('This operation is not available through tools.');
84
+ return tool;
85
+ };
86
+ export const resolveBinding = async (ctx, group, operation) => {
87
+ const matches = (await getManifest(ctx)).filter((candidate) => candidate.cli?.operation === operation &&
88
+ JSON.stringify(candidate.cli.group) === JSON.stringify(group));
89
+ const tool = matches.find((candidate) => candidate.version === 1) ?? matches[0];
90
+ if (!tool)
91
+ throw new ManifestError('This command is not supported by the current tool manifest. Update the server or check available capabilities.');
92
+ return getTool(ctx, tool.name);
93
+ };
@@ -1,6 +1,7 @@
1
1
  import { createApiClient } from './api.js';
2
2
  import { resolveApiBaseUrl } from './config.js';
3
3
  import { createCredentialStore } from './credentials.js';
4
+ import { getTool } from './manifest.js';
4
5
  export const asRecord = (value) => value && typeof value === 'object' ? value : {};
5
6
  export const asText = (value, fallback = '') => typeof value === 'string' ? value : fallback;
6
7
  /**
@@ -35,7 +36,15 @@ export const resolveToolContext = async (apiUrl, store) => {
35
36
  return { baseUrl, client: createApiClient(baseUrl), token: credential.token };
36
37
  };
37
38
  export const callTool = async (ctx, toolName, input, options = {}) => {
38
- const body = { input };
39
+ const tool = await getTool(ctx, toolName);
40
+ if (tool.effect !== 'read' && !options.idempotencyKey) {
41
+ throw new Error(`Refusing to call the write tool '${toolName}' without an idempotency key. Use applyTool.`);
42
+ }
43
+ if (options.idempotencyKey !== undefined &&
44
+ (!options.idempotencyKey.trim() || options.idempotencyKey.length > 256)) {
45
+ throw new Error('The idempotency key must contain 1 to 256 characters.');
46
+ }
47
+ const body = { input, version: tool.version };
39
48
  if (options.confirmationToken)
40
49
  body.confirmationToken = options.confirmationToken;
41
50
  if (options.idempotencyKey)
package/dist/upload.d.ts CHANGED
@@ -1,16 +1,26 @@
1
+ import { type ToolDescriptor } from './manifest.js';
1
2
  import { type ToolContext } from './toolSurface.js';
2
- /** Maximum evidence file size read and staged by the CLI (50 MiB). */
3
+ /** Maximum evidence file size read and staged by the CLI (25 MiB, matching the tool surface). */
3
4
  export declare const DEFAULT_MAX_UPLOAD_BYTES: number;
4
5
  export interface UploadLimits {
5
6
  maxUploadBytes?: number;
6
7
  timeoutMs?: number;
7
8
  maxResponseBytes?: number;
8
9
  }
10
+ export declare class FileInputError extends Error {
11
+ }
12
+ export declare const readUploadFile: (filePath: string, maxBytes: number) => Promise<Buffer>;
9
13
  /**
10
14
  * Stages a local file through the `upload_file` tool and returns its upload id, ready to be attached
11
15
  * to a target (a test run, a risk, a vendor). The file is sent as multipart to the tool surface with
12
16
  * the bearer credential, so no cookie or CSRF token is involved.
13
17
  */
18
+ export declare const stageLocalFile: (ctx: ToolContext, filePath: string, purpose: string, limits?: UploadLimits, descriptor?: ToolDescriptor, idempotencyKey?: string) => Promise<{
19
+ id: string;
20
+ filename: string;
21
+ size: number;
22
+ envelope: Record<string, unknown>;
23
+ }>;
14
24
  export declare const uploadLocalFile: (ctx: ToolContext, filePath: string, purpose: string, limits?: UploadLimits) => Promise<{
15
25
  id: string;
16
26
  filename: string;
package/dist/upload.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { getTool, ManifestError } from './manifest.js';
2
3
  import { constants } from 'node:fs';
3
4
  import { lstat, open } from 'node:fs/promises';
4
5
  import { basename } from 'node:path';
@@ -19,15 +20,17 @@ const MIME_TYPES = {
19
20
  '.xls': 'application/vnd.ms-excel',
20
21
  '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
21
22
  };
22
- /** Maximum evidence file size read and staged by the CLI (50 MiB). */
23
- export const DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
24
- const readUploadFile = async (filePath, maxBytes) => {
23
+ /** Maximum evidence file size read and staged by the CLI (25 MiB, matching the tool surface). */
24
+ export const DEFAULT_MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
25
+ export class FileInputError extends Error {
26
+ }
27
+ export const readUploadFile = async (filePath, maxBytes) => {
25
28
  const initialStat = await lstat(filePath);
26
29
  if (initialStat.isSymbolicLink() || !initialStat.isFile()) {
27
- throw new Error('Upload path must be a regular file, not a symlink or special file.');
30
+ throw new FileInputError('Upload path must be a regular file, not a symlink or special file.');
28
31
  }
29
32
  if (initialStat.size > maxBytes) {
30
- throw new Error(`Upload exceeds the ${maxBytes}-byte file size limit.`);
33
+ throw new FileInputError(`Upload exceeds the ${maxBytes}-byte file size limit.`);
31
34
  }
32
35
  const noFollow = constants.O_NOFOLLOW ?? 0;
33
36
  const file = await open(filePath, constants.O_RDONLY | noFollow);
@@ -36,24 +39,24 @@ const readUploadFile = async (filePath, maxBytes) => {
36
39
  if (!openedStat.isFile() ||
37
40
  openedStat.dev !== initialStat.dev ||
38
41
  openedStat.ino !== initialStat.ino) {
39
- throw new Error('Upload path changed while it was being opened. Please retry.');
42
+ throw new FileInputError('Upload path changed while it was being opened. Please retry.');
40
43
  }
41
44
  if (openedStat.size > maxBytes) {
42
- throw new Error(`Upload exceeds the ${maxBytes}-byte file size limit.`);
45
+ throw new FileInputError(`Upload exceeds the ${maxBytes}-byte file size limit.`);
43
46
  }
44
47
  const chunks = [];
45
48
  let total = 0;
46
49
  while (true) {
47
50
  const remaining = maxBytes + 1 - total;
48
51
  if (remaining <= 0)
49
- throw new Error(`Upload exceeds the ${maxBytes}-byte file size limit.`);
52
+ throw new FileInputError(`Upload exceeds the ${maxBytes}-byte file size limit.`);
50
53
  const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, remaining));
51
54
  const { bytesRead } = await file.read(chunk, 0, chunk.length, null);
52
55
  if (bytesRead === 0)
53
56
  break;
54
57
  total += bytesRead;
55
58
  if (total > maxBytes)
56
- throw new Error(`Upload exceeds the ${maxBytes}-byte file size limit.`);
59
+ throw new FileInputError(`Upload exceeds the ${maxBytes}-byte file size limit.`);
57
60
  chunks.push(chunk.subarray(0, bytesRead));
58
61
  }
59
62
  return Buffer.concat(chunks, total);
@@ -72,16 +75,26 @@ const contentTypeFor = (filename) => {
72
75
  * to a target (a test run, a risk, a vendor). The file is sent as multipart to the tool surface with
73
76
  * the bearer credential, so no cookie or CSRF token is involved.
74
77
  */
75
- export const uploadLocalFile = async (ctx, filePath, purpose, limits = {}) => {
78
+ export const stageLocalFile = async (ctx, filePath, purpose, limits = {}, descriptor, idempotencyKey = randomUUID()) => {
79
+ const tool = descriptor ?? (await getTool(ctx, 'upload_file'));
80
+ if (tool.effect !== 'write' || tool.confirmation !== 'none') {
81
+ throw new ManifestError('The server does not support immediate file staging.');
82
+ }
83
+ const properties = asRecord(tool.inputSchema.properties);
84
+ const purposes = asRecord(properties.purpose).enum;
85
+ if (!Array.isArray(purposes) || !purposes.includes(purpose))
86
+ throw new Error('Unsupported upload purpose.');
87
+ if (!idempotencyKey.trim() || idempotencyKey.length > 256)
88
+ throw new Error('The idempotency key must contain 1 to 256 characters.');
76
89
  const filename = basename(filePath);
77
90
  const contents = await readUploadFile(filePath, limits.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES);
78
91
  const form = new FormData();
79
92
  form.append('purpose', purpose);
80
93
  form.append('file', new Blob([new Uint8Array(contents)], { type: contentTypeFor(filename) }), filename);
81
94
  return withRequestTimeout(limits.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, async (signal) => {
82
- const response = await fetchWithoutRedirects(`${ctx.baseUrl}/tools/v1/upload_file`, {
95
+ const response = await fetchWithoutRedirects(`${ctx.baseUrl}/tools/v1/${encodeURIComponent(tool.name)}`, {
83
96
  method: 'POST',
84
- headers: { authorization: `Bearer ${ctx.token}`, 'idempotency-key': randomUUID() },
97
+ headers: { authorization: `Bearer ${ctx.token}`, 'idempotency-key': idempotencyKey },
85
98
  body: form,
86
99
  signal,
87
100
  });
@@ -100,7 +113,9 @@ export const uploadLocalFile = async (ctx, filePath, purpose, limits = {}) => {
100
113
  const message = typeof asRecord(envelope.error).message === 'string'
101
114
  ? asRecord(envelope.error).message
102
115
  : `Upload failed with status ${response.status}`;
103
- throw new ApiError(message, response.status);
116
+ throw new ApiError(message, response.status, typeof asRecord(envelope.error).code === 'string'
117
+ ? String(asRecord(envelope.error).code)
118
+ : undefined, asRecord(envelope.error).details, envelope);
104
119
  }
105
120
  const payload = asRecord(envelope.data);
106
121
  const id = typeof payload.uploadId === 'string' ? payload.uploadId : null;
@@ -108,8 +123,13 @@ export const uploadLocalFile = async (ctx, filePath, purpose, limits = {}) => {
108
123
  throw new ApiError('Upload did not return an id', 502);
109
124
  return {
110
125
  id,
126
+ envelope,
111
127
  filename: typeof payload.filename === 'string' ? payload.filename : filename,
112
128
  size: typeof payload.size === 'number' ? payload.size : contents.length,
113
129
  };
114
130
  });
115
131
  };
132
+ export const uploadLocalFile = async (ctx, filePath, purpose, limits = {}) => {
133
+ const { id, filename, size } = await stageLocalFile(ctx, filePath, purpose, limits);
134
+ return { id, filename, size };
135
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ciphrix/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Official command-line interface for the Ciphrix compliance platform.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: ciphrix
3
- description: Operate a Ciphrix compliance programme with the `ciphrix` CLI. Use when an agent needs to understand or change a tenant's business context, documents, frameworks, controls, tests and evidence, risks, vendors, assets, monitoring checks, or background jobs.
3
+ description: Operate a Ciphrix compliance programme with the `ciphrix` CLI. Use for onboarding and organizational context, documents, frameworks, controls, tests and evidence, access reviews and imports, agent findings, assigned tasks, risks, vendors, assets, monitoring checks, or background jobs.
4
4
  ---
5
5
 
6
6
  # Ciphrix
@@ -140,6 +140,31 @@ are what a check raised. `check disable` silences a check for the tenant — rec
140
140
 
141
141
  ## Guardrails
142
142
 
143
+ **Context and onboarding.** Read the server status and question schema, then save only facts agreed
144
+ with the user. Business and Operating Context are separate. Let the server decide readiness; initial
145
+ setup is optional and journey completion is explicit. A context save never authorizes starting agents,
146
+ initializing a framework or completing onboarding. Replayed packs are snapshots: read status again for
147
+ current readiness.
148
+
149
+ **Access reviews and proposals.** Inventory is not a review decision. Inspect proposal rows, matches,
150
+ source evidence and current revisions before selecting a merge. Never manufacture person-match or
151
+ visual-evidence confirmation. Keep/revoke decisions, reviewer-task completion and campaign ending are
152
+ distinct human intents. A revoke decision is a removal request, not provider-side revocation. Agent
153
+ findings/options and AI assessments are advisory; inspect the complete current proposal and exact
154
+ declared option before any explicit decision. Assigned-task discovery grants no completion authority.
155
+
156
+ **Writes and uncertainty.** Discover capabilities through the server, use the exact reviewed inputs,
157
+ and treat confirmation as explicit delegated consent, not an obstacle to automate around. Retain one
158
+ idempotency key per logical operation; never retry unknown outcomes with a new key. The CLI performs no
159
+ automatic write retries and does not promise confirmed recovery across invocations or expired tokens.
160
+ Inspect state/jobs when uncertain. Batch results can partially apply; preserve and report failures.
161
+ Advisory assessments never authorize a formal passing result.
162
+
163
+ **Files.** Stage and process only the files the user selected. General processing may update context
164
+ or create vendor records; staging alone does not authorize that processing. Imports are proposed work,
165
+ not merged inventory. Temporary evidence/export references expire; do not forward credentials to them,
166
+ claim bytes were downloaded when only a reference was returned, or treat a replayed URL as renewed.
167
+
143
168
  - Work only in the tenant and API environment the authenticated user selected. Never discover or switch
144
169
  tenants, endpoints or credential stores by guessing.
145
170
  - Do not expose access tokens, stored credentials or sensitive response data in prose, logs or commands.