@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 +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 +10 -3
- package/lib/index.js +23 -14
- 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
|
@@ -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
|
-
|
|
109
|
-
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;
|
|
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
|
-
|
|
96
|
-
export
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
518
|
-
|
|
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(
|
|
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,
|
|
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.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",
|