@elinpf/dsh-ops-access 0.1.4 → 0.1.6

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.
Files changed (2) hide show
  1. package/lib/index.js +37 -4
  2. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -37,6 +37,7 @@
37
37
  * @module @elinpf/dsh-ops-access
38
38
  */
39
39
  import { readFile, writeFile, mkdir, rm, rmdir } from 'node:fs/promises';
40
+ import { resolve } from 'node:path';
40
41
  import os from 'node:os';
41
42
  import z from '@deepseek-ai/schemastery';
42
43
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
@@ -244,6 +245,17 @@ function assertValidProfileName(profileName) {
244
245
  throw new Error(`ops-access: invalid profile name "${profileName}" — must start with a letter or digit and contain only letters, digits, '.', '_', '-', '@'`);
245
246
  }
246
247
  }
248
+ /**
249
+ * Whether a file-field value names a file instead of carrying the content:
250
+ * a single line starting with `/`, `~/`, `./`, or `../`. Real file-field
251
+ * content (kubeconfig, ceph.conf, keyring, private key) is always multi-line
252
+ * structured text, so a path-shaped single line is unambiguous.
253
+ * @param content - a file-field value already known to be a non-empty string.
254
+ * @returns true when the value should be read from disk as a file path.
255
+ */
256
+ function looksLikeFilePath(content) {
257
+ return !content.includes('\n') && /^(?:\/|~\/|\.{1,2}\/)/.test(content);
258
+ }
247
259
  async function writeContentFiles(credentialsDir, kind, profileName, tier, fileFields, contentFiles, entryFields, provider) {
248
260
  const allowed = new Set(fileFields);
249
261
  const written = [];
@@ -258,9 +270,29 @@ async function writeContentFiles(credentialsDir, kind, profileName, tier, fileFi
258
270
  if (!allowed.has(fieldName)) {
259
271
  throw new Error(`ops-access: "${fieldName}" is not a declared file field for kind "${kind}" (declared: ${fileFields.join(', ') || '(none)'})`);
260
272
  }
273
+ // A single-line value starting with a path prefix names a file to read
274
+ // instead of the content itself — the credential then never round-trips
275
+ // through the model request or the admin form. File-field content
276
+ // (kubeconfig, ceph.conf, keyring, private key) is always multi-line
277
+ // structured text, so the two never collide. A path with no file behind
278
+ // it fails loud: the commonest mistake is passing a path where content
279
+ // is expected.
280
+ let resolved = content;
281
+ if (looksLikeFilePath(content)) {
282
+ const source = content.startsWith('~/')
283
+ ? (process.env.HOME ?? os.homedir()) + content.slice(1)
284
+ : resolve(content);
285
+ try {
286
+ resolved = await readFile(source, 'utf8');
287
+ }
288
+ catch (error) {
289
+ const code = error.code ?? 'unreadable';
290
+ throw new Error(`ops-access: "${fieldName}" looks like a file path, but no readable file at ${source} (${code}) — pass the full file CONTENT, or a path to an existing file`);
291
+ }
292
+ }
261
293
  // Provider-declared write-time normalization runs FIRST — validator
262
294
  // and disk both see the normalized bytes.
263
- const normalized = provider?.normalizeTrailingNewline ? content.replace(/[\r\n]+$/, '') + '\n' : content;
295
+ const normalized = provider?.normalizeTrailingNewline ? resolved.replace(/[\r\n]+$/, '') + '\n' : resolved;
264
296
  // Save-time content validation (provider hook, possibly async — ssh
265
297
  // runs ssh-keygen): reject corrupt pastes BEFORE anything lands on disk.
266
298
  const problem = await provider?.validateContent?.(fieldName, normalized);
@@ -513,7 +545,8 @@ export function apply(ctx, config) {
513
545
  }
514
546
  lines.push('');
515
547
  lines.push('Agents register ro tiers with the register_access tool — rw tiers stay human-managed via the admin UI.');
516
- lines.push('Secrets never go inline fields carry file paths and connection params only, so logs and model context never contain secret material.');
548
+ lines.push('Registering: pass the full file CONTENT for file fields, or a single-line path to an existing readable file (read server-side, content never passes through the model). Multi-line pastes are always treated as content.');
549
+ lines.push('In the REGISTRY itself, file fields carry the managed file paths — secrets never go inline, so logs and model context never contain secret material.');
517
550
  return lines.join('\n');
518
551
  },
519
552
  async writeEntry(kind, profileName, tier, fields, envelope) {
@@ -711,10 +744,10 @@ export function apply(ctx, config) {
711
744
  // session event log, so every registration is reconstructable.
712
745
  ctx.effect(() => ctx.tools.register(defineTool({
713
746
  name: 'register_access',
714
- description: 'Register or overwrite the read-only (ro) credential tier of an access profile — typically a credential you derived from the rw tier (a read-only ServiceAccount token, a read-only cephx keyring, a dedicated SSH key). The rw tier is human-managed via the admin UI; this tool writes ro only. File fields (kubeconfig, conf, keyring, key) take full file CONTENT, stored to a managed path automatically; other fields are inline values. Run list_access with help: true for per-kind field docs and derivation recipes.',
747
+ description: 'Register or overwrite the read-only (ro) credential tier of an access profile — typically a credential you derived from the rw tier (a read-only ServiceAccount token, a read-only cephx keyring, a dedicated SSH key). The rw tier is human-managed via the admin UI; this tool writes ro only. File fields (kubeconfig, conf, keyring, key) take the full file CONTENT, stored to a managed path automatically; a path to an existing readable file also works and is read server-side, so the content never needs to pass through this call. Other fields are inline values. Run list_access with help: true for per-kind field docs and derivation recipes.',
715
748
  parameters: {
716
749
  profile: { type: 'string', required: true, description: '"kind/id", e.g. "k8s/prod". The entry is created when it does not exist yet.' },
717
- fields: { type: 'object', additionalProperties: true, required: true, description: 'The ro tier field values for this kind. File fields take full content, not paths.' },
750
+ fields: { type: 'object', additionalProperties: true, required: true, description: 'The ro tier field values for this kind. File fields (kubeconfig, conf, keyring, key) take the full file CONTENT — or a single-line path to an existing readable file, which is read server-side. Multi-line pastes are always treated as content.' },
718
751
  description: { type: 'string', description: 'Optional envelope description (empty string clears it).' },
719
752
  environment: { type: 'string', description: 'Optional envelope environment label (empty string clears it).' },
720
753
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elinpf/dsh-ops-access",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Ops access capability seam — owns the YAML credential registry and exposes ctx.opsAccess (resolve/list/register) to provider plugins.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",