@ciphrix/cli 0.1.1 → 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.
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';
@@ -21,13 +22,15 @@ const MIME_TYPES = {
21
22
  };
22
23
  /** Maximum evidence file size read and staged by the CLI (25 MiB, matching the tool surface). */
23
24
  export const DEFAULT_MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
24
- const readUploadFile = async (filePath, maxBytes) => {
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.1",
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.