@elinpf/dsh-ops-access 0.3.0 → 0.4.1

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/lib/backend.d.ts CHANGED
@@ -18,6 +18,8 @@
18
18
  */
19
19
  import type { EntryEnvelope, ProbeState } from './types.js';
20
20
  export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
21
+ /** Expand a leading `~` (or `~/`) to the user's home directory. */
22
+ export declare function expandHome(p: string): string;
21
23
  /** Build an EntryEnvelope from raw entry data, taking each envelope field from the first source that has it. */
22
24
  export declare function buildEnvelope(sources: Array<Record<string, unknown> | undefined>): EntryEnvelope;
23
25
  /** Read a persisted probe result off a raw tier object (durable boundary — sanitize). */
package/lib/backend.js CHANGED
@@ -17,11 +17,21 @@
17
17
  * @module @elinpf/dsh-ops-access/backend
18
18
  */
19
19
  import { readFile, writeFile } from 'node:fs/promises';
20
+ import os from 'node:os';
20
21
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
21
22
  // ── Shared helpers ───────────────────────────────────────────────────────────
22
23
  export function isPlainObject(value) {
23
24
  return typeof value === 'object' && value !== null && !Array.isArray(value);
24
25
  }
26
+ /** Expand a leading `~` (or `~/`) to the user's home directory. */
27
+ export function expandHome(p) {
28
+ const home = process.env.HOME ?? os.homedir();
29
+ if (p === '~')
30
+ return home;
31
+ if (p.startsWith('~/'))
32
+ return home + p.slice(1);
33
+ return p;
34
+ }
25
35
  /** Build an EntryEnvelope from raw entry data, taking each envelope field from the first source that has it. */
26
36
  export function buildEnvelope(sources) {
27
37
  const envelope = {};
@@ -75,7 +75,7 @@ export declare class HubBackend implements AccessBackend {
75
75
  reason?: string;
76
76
  }): Promise<string>;
77
77
  /** Pending-request metadata for the approval UI — field values never cross. */
78
- listRequests(status?: 'pending' | 'approved' | 'rejected'): Promise<unknown>;
78
+ listRequests(): Promise<unknown>;
79
79
  /** Full request incl. field values, for pre-approval review. Null when absent. */
80
80
  getRequest(id: string): Promise<unknown>;
81
81
  /** Approve (hub writes the tier) or reject. Returns false when already settled/absent. */
@@ -23,17 +23,7 @@
23
23
  */
24
24
  import { mkdir, readdir, readFile, rename, rm, rmdir, stat, writeFile } from 'node:fs/promises';
25
25
  import { dirname } from 'node:path';
26
- import os from 'node:os';
27
- import { buildEnvelope, isPlainObject, mergeEnvelope, probeOf } from './backend.js';
28
- /** Expand a leading `~` (or `~/`) to the user's home directory. */
29
- function expandHome(p) {
30
- const home = process.env.HOME ?? os.homedir();
31
- if (p === '~')
32
- return home;
33
- if (p.startsWith('~/'))
34
- return home + p.slice(1);
35
- return p;
36
- }
26
+ import { buildEnvelope, expandHome, isPlainObject, mergeEnvelope, probeOf } from './backend.js';
37
27
  /**
38
28
  * Write content to a managed file, skipping the write when the on-disk
39
29
  * bytes already match (resolve runs on every tool call — touching the file
@@ -253,8 +243,8 @@ export class HubBackend {
253
243
  return data.id;
254
244
  }
255
245
  /** Pending-request metadata for the approval UI — field values never cross. */
256
- async listRequests(status) {
257
- return this.request('GET', status === undefined ? '/requests' : `/requests?status=${status}`);
246
+ async listRequests() {
247
+ return this.request('GET', '/requests?status=pending');
258
248
  }
259
249
  /** Full request incl. field values, for pre-approval review. Null when absent. */
260
250
  async getRequest(id) {
package/lib/index.d.ts CHANGED
@@ -83,7 +83,7 @@ export interface Config {
83
83
  hubCacheDir?: string;
84
84
  }
85
85
  export declare const Config: z<Config>;
86
- export type { AccessProvider, AccessProfile, EntryEnvelope, ProbeState, AdminTierStatus, AdminEntry, KindDescriptor, AccessAgent, AccessBrokerDecision, AccessBroker, OpsAccess, } from './types.js';
86
+ export type { AccessProvider, AccessProfile, EntryEnvelope, ProbeState, AdminTierStatus, AdminEntry, KindDescriptor, AccessAgent, AccessBrokerDecision, AccessBroker, AccessRequest, OpsAccess, } from './types.js';
87
87
  declare module '@deepseek-ai/cordis' {
88
88
  interface Context {
89
89
  opsAccess?: OpsAccess;
@@ -105,6 +105,13 @@ export declare function registerAccessProvider(ctx: Context, provider: AccessPro
105
105
  * `ctx.inject` and ties the registration to the plugin's effect lifecycle.
106
106
  */
107
107
  export declare function registerAccessBroker(ctx: Context, broker: AccessBroker): void;
108
- /** Expand a leading `~` (or `~/`) to the user's home directory. */
109
- export declare function expandHome(p: string): string;
108
+ import { expandHome } from './backend.js';
109
+ export { expandHome };
110
+ /**
111
+ * Single-line-secret check shared by provider validateContent hooks: a
112
+ * pasted value may carry one trailing newline; anything further (interior
113
+ * `\n`/`\r`) means a multi-line paste landed in a field whose reader only
114
+ * honors the first line (sshpass -f, an Authorization header).
115
+ */
116
+ export declare function hasSingleLineBody(content: string): boolean;
110
117
  export declare function apply(ctx: Context, config: Config): void;
package/lib/index.js CHANGED
@@ -92,14 +92,17 @@ export function registerAccessBroker(ctx, broker) {
92
92
  });
93
93
  }
94
94
  // ── Helpers ──────────────────────────────────────────────────────────────────
95
- /** Expand a leading `~` (or `~/`) to the user's home directory. */
96
- export function expandHome(p) {
97
- const home = process.env.HOME ?? os.homedir();
98
- if (p === '~')
99
- return home;
100
- if (p.startsWith('~/'))
101
- return home + p.slice(1);
102
- return p;
95
+ import { expandHome } from './backend.js';
96
+ export { expandHome };
97
+ /**
98
+ * Single-line-secret check shared by provider validateContent hooks: a
99
+ * pasted value may carry one trailing newline; anything further (interior
100
+ * `\n`/`\r`) means a multi-line paste landed in a field whose reader only
101
+ * honors the first line (sshpass -f, an Authorization header).
102
+ */
103
+ export function hasSingleLineBody(content) {
104
+ const body = content.endsWith('\n') ? content.slice(0, -1) : content;
105
+ return !body.includes('\n') && !body.includes('\r');
103
106
  }
104
107
  /**
105
108
  * Validate one tier sub-object against the provider schema and build the
@@ -497,7 +500,7 @@ export function apply(ctx, config) {
497
500
  return { ok: false, error: String(err?.message ?? err) };
498
501
  }
499
502
  },
500
- async resolve(kind, profileName, agent) {
503
+ async resolve(kind, profileName, agent, request) {
501
504
  profileName = stripKindPrefix(kind, profileName);
502
505
  const provider = providers.get(kind);
503
506
  if (!provider) {
@@ -507,15 +510,21 @@ export function apply(ctx, config) {
507
510
  // Once a broker is registered it is consulted on EVERY resolve —
508
511
  // including calls without an agent. The no-agent ruling (fail closed to
509
512
  // ro, or deny outright) is policy, and policy lives in the broker, not
510
- // here. Without a broker, rw is never issued at all.
513
+ // here. Without a broker, rw is never issued at all — so an explicit rw
514
+ // request without a broker is an error, not a silent ro.
511
515
  let tier = 'ro';
512
516
  if (broker) {
513
- const decision = broker(kind, profileName, agent);
517
+ const decision = broker(kind, profileName, agent, request);
514
518
  if (typeof decision === 'object') {
515
519
  throw new Error(`ops-access: access denied for ${kind}/${profileName}: ${decision.deny}`);
516
520
  }
517
- if (decision === 'rw')
518
- tier = 'rw';
521
+ // An explicit 'ro' request caps the outcome at ro even when the broker
522
+ // would issue rw — the deliberate downgrade is the point: the caller
523
+ // declares "this call only reads".
524
+ tier = request?.tier === 'ro' ? 'ro' : decision;
525
+ }
526
+ else if (request?.tier === 'rw') {
527
+ throw new Error(`ops-access: rw tier requested for ${kind}/${profileName}, but no access gate is mounted — rw is never issued without one`);
519
528
  }
520
529
  // A missing SOURCE (yaml: no registry file) throws from the backend
521
530
  // verbatim (SourceUnavailableError); an unreadable source propagates
@@ -1047,7 +1056,7 @@ export function apply(ctx, config) {
1047
1056
  sendJsonError(res, 405, new Error('method not allowed'));
1048
1057
  return;
1049
1058
  }
1050
- const list = await backend.listRequests('pending');
1059
+ const list = await backend.listRequests();
1051
1060
  res.writeHead(200, { 'content-type': 'application/json' });
1052
1061
  res.end(JSON.stringify(list));
1053
1062
  }
package/lib/types.d.ts CHANGED
@@ -204,15 +204,28 @@ export interface AccessAgent {
204
204
  export type AccessBrokerDecision = 'ro' | 'rw' | {
205
205
  deny: string;
206
206
  };
207
+ /**
208
+ * What the caller asked for on one resolve call, beyond the defaults.
209
+ */
210
+ export interface AccessRequest {
211
+ /**
212
+ * Explicit tier declaration. `'ro'` is a deliberate downgrade: the session
213
+ * may hold an rw grant, but this call only reads — the ro credential is
214
+ * served (a lockdown deny still applies). `'rw'` is an explicit elevation
215
+ * request: the broker must deny loudly (with guidance) when the session
216
+ * holds no grant, rather than silently serving ro.
217
+ */
218
+ tier?: 'ro' | 'rw';
219
+ }
207
220
  /**
208
221
  * The pure decision function a gate registers. Receives only kind, profile
209
- * name, and the caller agent — never credential fields. Once a broker is
210
- * registered, resolve consults it on EVERY call; `agent` is `undefined` for
211
- * system-internal calls, and the no-agent ruling belongs to the broker (core
212
- * does not answer policy on its behalf). Without a registered broker, resolve
213
- * is unchanged from the broker-less behavior (ro).
222
+ * name, the caller agent, and the per-call request — never credential fields.
223
+ * Once a broker is registered, resolve consults it on EVERY call; `agent` is
224
+ * `undefined` for system-internal calls, and the no-agent ruling belongs to
225
+ * the broker (core does not answer policy on its behalf). Without a
226
+ * registered broker, resolve is unchanged from the broker-less behavior (ro).
214
227
  */
215
- export type AccessBroker = (kind: string, name: string, agent: AccessAgent | undefined) => AccessBrokerDecision;
228
+ export type AccessBroker = (kind: string, name: string, agent: AccessAgent | undefined, request?: AccessRequest) => AccessBrokerDecision;
216
229
  /** The ops access handle exposed via ctx.get('opsAccess'). */
217
230
  export interface OpsAccess {
218
231
  /** Register a credential-kind provider. Throws if the kind is already registered. Returns a disposer. */
@@ -245,7 +258,18 @@ export interface OpsAccess {
245
258
  * a broker the ro profile (from `registryFile`) is served, byte-for-byte
246
259
  * as before.
247
260
  */
248
- resolve(kind: string, name: string, agent?: AccessAgent): Promise<AccessProfile>;
261
+ /**
262
+ * Resolve one profile by kind and name. Throws on unknown kind, unknown
263
+ * name, or invalid entry. When a broker is registered it is consulted on
264
+ * every call — including calls without an `agent` (the broker owns the
265
+ * no-agent ruling) — and decides whether the rw profile is served. Without
266
+ * a broker the ro profile (from `registryFile`) is served, byte-for-byte
267
+ * as before. `request.tier` lets the caller declare the tier explicitly:
268
+ * `'ro'` caps the outcome at ro even under an rw grant (deliberate
269
+ * downgrade); `'rw'` demands the rw tier and fails loudly when it cannot
270
+ * be served.
271
+ */
272
+ resolve(kind: string, name: string, agent?: AccessAgent, request?: AccessRequest): Promise<AccessProfile>;
249
273
  /** List all profiles across all registered kinds. Sections without a registered provider are skipped. */
250
274
  list(): Promise<AccessProfile[]>;
251
275
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elinpf/dsh-ops-access",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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",