@elinpf/dsh-ops-access 0.2.1 → 0.4.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/lib/backend.d.ts +2 -0
- package/lib/backend.js +10 -0
- package/lib/hub-backend.d.ts +1 -1
- package/lib/hub-backend.js +3 -13
- package/lib/index.d.ts +14 -4
- package/lib/index.js +34 -19
- package/lib/types.d.ts +31 -7
- package/package.json +1 -1
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 = {};
|
package/lib/hub-backend.d.ts
CHANGED
|
@@ -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(
|
|
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. */
|
package/lib/hub-backend.js
CHANGED
|
@@ -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
|
|
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(
|
|
257
|
-
return this.request('GET',
|
|
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
|
@@ -55,9 +55,12 @@ export interface Config {
|
|
|
55
55
|
* Credential source: 'yaml' (default) reads the local registry file;
|
|
56
56
|
* 'hub' fetches entries from a remote ops-access-hub service on every
|
|
57
57
|
* call and materializes file-field content to managed local files.
|
|
58
|
+
* Unset + env ACCESS_HUB_URL present → 'hub' (upgrade-proof seam: the
|
|
59
|
+
* materialized preset file is rewritten on every suite upgrade, so the
|
|
60
|
+
* durable switch lives in the process environment, e.g. the systemd unit).
|
|
58
61
|
*/
|
|
59
62
|
source?: 'yaml' | 'hub';
|
|
60
|
-
/** Hub base URL (source: 'hub'), e.g. http://127.0.0.1:3090. */
|
|
63
|
+
/** Hub base URL (source: 'hub'), e.g. http://127.0.0.1:3090. Falls back to env ACCESS_HUB_URL. */
|
|
61
64
|
hubUrl?: string;
|
|
62
65
|
/** Hub read token (source: 'hub'); falls back to env ACCESS_HUB_READ_TOKEN. Never logged. */
|
|
63
66
|
hubToken?: string;
|
|
@@ -80,7 +83,7 @@ export interface Config {
|
|
|
80
83
|
hubCacheDir?: string;
|
|
81
84
|
}
|
|
82
85
|
export declare const Config: z<Config>;
|
|
83
|
-
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';
|
|
84
87
|
declare module '@deepseek-ai/cordis' {
|
|
85
88
|
interface Context {
|
|
86
89
|
opsAccess?: OpsAccess;
|
|
@@ -102,6 +105,13 @@ export declare function registerAccessProvider(ctx: Context, provider: AccessPro
|
|
|
102
105
|
* `ctx.inject` and ties the registration to the plugin's effect lifecycle.
|
|
103
106
|
*/
|
|
104
107
|
export declare function registerAccessBroker(ctx: Context, broker: AccessBroker): void;
|
|
105
|
-
|
|
106
|
-
export
|
|
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;
|
|
107
117
|
export declare function apply(ctx: Context, config: Config): void;
|
package/lib/index.js
CHANGED
|
@@ -57,8 +57,10 @@ export const inject = ['tools'];
|
|
|
57
57
|
export const Config = z.object({
|
|
58
58
|
registryFile: z.string().default('~/.dsh-ops/access.yaml'),
|
|
59
59
|
credentialsDir: z.string().default('~/.dsh-ops/credentials'),
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
// No defaults here: an absent key must STAY absent so apply() can tell
|
|
61
|
+
// "unset" apart from an explicit value (the ACCESS_HUB_URL env seam).
|
|
62
|
+
source: z.union(['yaml', 'hub']),
|
|
63
|
+
hubUrl: z.string(),
|
|
62
64
|
hubToken: z.string().default(''),
|
|
63
65
|
hubAdminToken: z.string().default(''),
|
|
64
66
|
materializeTtlMinutes: z.number().default(15),
|
|
@@ -90,14 +92,17 @@ export function registerAccessBroker(ctx, broker) {
|
|
|
90
92
|
});
|
|
91
93
|
}
|
|
92
94
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
93
|
-
|
|
94
|
-
export
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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');
|
|
101
106
|
}
|
|
102
107
|
/**
|
|
103
108
|
* Validate one tier sub-object against the provider schema and build the
|
|
@@ -381,7 +386,11 @@ export function apply(ctx, config) {
|
|
|
381
386
|
// default and behaves byte-for-byte as before; hub fetches entries from a
|
|
382
387
|
// remote ops-access-hub on every call and materializes file-field content
|
|
383
388
|
// to managed local files under credentialsDir.
|
|
384
|
-
|
|
389
|
+
// Env seam (ACCESS_HUB_URL): setting it flips an unconfigured deployment to
|
|
390
|
+
// hub mode. The ops preset file is re-materialized on every suite upgrade,
|
|
391
|
+
// so config written into it is silently dropped — the process env (systemd
|
|
392
|
+
// unit) is the only upgrade-proof seam.
|
|
393
|
+
const source = config.source ?? (process.env.ACCESS_HUB_URL ? 'hub' : 'yaml');
|
|
385
394
|
// In hub mode every local credential file — materialized reads AND staged
|
|
386
395
|
// writes — lives under hubCacheDir as a TTL-bound cache. credentialsDir
|
|
387
396
|
// stays yaml-mode territory: the sweeper must never touch files the yaml
|
|
@@ -389,9 +398,9 @@ export function apply(ctx, config) {
|
|
|
389
398
|
const contentDir = source === 'hub' ? expandHome(config.hubCacheDir ?? '~/.dsh-ops/hub-cache') : credentialsDir;
|
|
390
399
|
let backend;
|
|
391
400
|
if (source === 'hub') {
|
|
392
|
-
const hubUrl = (config.hubUrl
|
|
401
|
+
const hubUrl = (config.hubUrl || process.env.ACCESS_HUB_URL || '').replace(/\/+$/, '');
|
|
393
402
|
if (hubUrl === '') {
|
|
394
|
-
throw new Error('ops-access: source "hub" requires hubUrl (e.g. http://127.0.0.1:3090)');
|
|
403
|
+
throw new Error('ops-access: source "hub" requires hubUrl (e.g. http://127.0.0.1:3090) or env ACCESS_HUB_URL');
|
|
395
404
|
}
|
|
396
405
|
backend = new HubBackend({
|
|
397
406
|
baseUrl: hubUrl,
|
|
@@ -491,7 +500,7 @@ export function apply(ctx, config) {
|
|
|
491
500
|
return { ok: false, error: String(err?.message ?? err) };
|
|
492
501
|
}
|
|
493
502
|
},
|
|
494
|
-
async resolve(kind, profileName, agent) {
|
|
503
|
+
async resolve(kind, profileName, agent, request) {
|
|
495
504
|
profileName = stripKindPrefix(kind, profileName);
|
|
496
505
|
const provider = providers.get(kind);
|
|
497
506
|
if (!provider) {
|
|
@@ -501,15 +510,21 @@ export function apply(ctx, config) {
|
|
|
501
510
|
// Once a broker is registered it is consulted on EVERY resolve —
|
|
502
511
|
// including calls without an agent. The no-agent ruling (fail closed to
|
|
503
512
|
// ro, or deny outright) is policy, and policy lives in the broker, not
|
|
504
|
-
// 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.
|
|
505
515
|
let tier = 'ro';
|
|
506
516
|
if (broker) {
|
|
507
|
-
const decision = broker(kind, profileName, agent);
|
|
517
|
+
const decision = broker(kind, profileName, agent, request);
|
|
508
518
|
if (typeof decision === 'object') {
|
|
509
519
|
throw new Error(`ops-access: access denied for ${kind}/${profileName}: ${decision.deny}`);
|
|
510
520
|
}
|
|
511
|
-
|
|
512
|
-
|
|
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`);
|
|
513
528
|
}
|
|
514
529
|
// A missing SOURCE (yaml: no registry file) throws from the backend
|
|
515
530
|
// verbatim (SourceUnavailableError); an unreadable source propagates
|
|
@@ -1041,7 +1056,7 @@ export function apply(ctx, config) {
|
|
|
1041
1056
|
sendJsonError(res, 405, new Error('method not allowed'));
|
|
1042
1057
|
return;
|
|
1043
1058
|
}
|
|
1044
|
-
const list = await backend.listRequests(
|
|
1059
|
+
const list = await backend.listRequests();
|
|
1045
1060
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1046
1061
|
res.end(JSON.stringify(list));
|
|
1047
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,
|
|
210
|
-
* registered, resolve consults it on EVERY call; `agent` is
|
|
211
|
-
* system-internal calls, and the no-agent ruling belongs to
|
|
212
|
-
* does not answer policy on its behalf). Without a
|
|
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
|
-
|
|
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
|
+
"version": "0.4.0",
|
|
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",
|