@kernel.chat/kbot 4.0.0 → 4.1.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/README.md +6 -0
- package/dist/cache-warmth.d.ts +25 -0
- package/dist/cache-warmth.js +131 -0
- package/dist/futures/debate/index.d.ts +7 -0
- package/dist/futures/debate/index.js +6 -0
- package/dist/futures/debate/runner.d.ts +34 -0
- package/dist/futures/debate/runner.js +140 -0
- package/dist/futures/debate/synthesis.d.ts +25 -0
- package/dist/futures/debate/synthesis.js +81 -0
- package/dist/futures/debate/types.d.ts +72 -0
- package/dist/futures/debate/types.js +12 -0
- package/dist/futures/forecast/index.d.ts +5 -0
- package/dist/futures/forecast/index.js +5 -0
- package/dist/futures/forecast/projection.d.ts +31 -0
- package/dist/futures/forecast/projection.js +177 -0
- package/dist/futures/forecast/synthesize.d.ts +19 -0
- package/dist/futures/forecast/synthesize.js +89 -0
- package/dist/futures/forecast/types.d.ts +59 -0
- package/dist/futures/forecast/types.js +15 -0
- package/dist/futures/harness/critic-evaluator.d.ts +39 -0
- package/dist/futures/harness/critic-evaluator.js +131 -0
- package/dist/futures/harness/evolution-loop.d.ts +41 -0
- package/dist/futures/harness/evolution-loop.js +168 -0
- package/dist/futures/harness/index.d.ts +16 -0
- package/dist/futures/harness/index.js +13 -0
- package/dist/futures/harness/meta-evolution.d.ts +32 -0
- package/dist/futures/harness/meta-evolution.js +52 -0
- package/dist/futures/harness/noop-evolution.d.ts +23 -0
- package/dist/futures/harness/noop-evolution.js +29 -0
- package/dist/futures/harness/persistence.d.ts +30 -0
- package/dist/futures/harness/persistence.js +99 -0
- package/dist/futures/harness/types.d.ts +147 -0
- package/dist/futures/harness/types.js +18 -0
- package/dist/futures/index.d.ts +16 -0
- package/dist/futures/index.js +22 -0
- package/dist/futures/latent-state/envelope.d.ts +39 -0
- package/dist/futures/latent-state/envelope.js +178 -0
- package/dist/futures/latent-state/index.d.ts +5 -0
- package/dist/futures/latent-state/index.js +3 -0
- package/dist/futures/latent-state/types.d.ts +47 -0
- package/dist/futures/latent-state/types.js +13 -0
- package/dist/futures/persona/check.d.ts +45 -0
- package/dist/futures/persona/check.js +205 -0
- package/dist/futures/persona/index.d.ts +5 -0
- package/dist/futures/persona/index.js +5 -0
- package/dist/futures/persona/registry.d.ts +22 -0
- package/dist/futures/persona/registry.js +124 -0
- package/dist/futures/persona/types.d.ts +68 -0
- package/dist/futures/persona/types.js +28 -0
- package/dist/futures/skill-graph/graph.d.ts +31 -0
- package/dist/futures/skill-graph/graph.js +151 -0
- package/dist/futures/skill-graph/index.d.ts +13 -0
- package/dist/futures/skill-graph/index.js +10 -0
- package/dist/futures/skill-graph/synthesis.d.ts +20 -0
- package/dist/futures/skill-graph/synthesis.js +83 -0
- package/dist/futures/skill-graph/types.d.ts +53 -0
- package/dist/futures/skill-graph/types.js +19 -0
- package/dist/streaming.js +18 -0
- package/package.json +1 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Latent-state envelope — runtime helpers.
|
|
3
|
+
* Pure functions over `LatentEnvelope`. Node `crypto` (sha256) for hashing.
|
|
4
|
+
*/
|
|
5
|
+
import type { LatentEnvelope, ProvenanceEntry } from './types.js';
|
|
6
|
+
/** JSON.stringify with deterministic key order (Object.keys().sort()). */
|
|
7
|
+
export declare function stableStringify(value: unknown): string;
|
|
8
|
+
export interface CreateEnvelopeOpts {
|
|
9
|
+
from: string;
|
|
10
|
+
to: string;
|
|
11
|
+
text?: string;
|
|
12
|
+
structured?: Record<string, unknown>;
|
|
13
|
+
createdAt?: string;
|
|
14
|
+
note?: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Build a fresh envelope. Auto-derives `kind`, stamps `createdAt`, seeds
|
|
18
|
+
* provenance with a single step from `opts.from`, and computes `contentHash`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createEnvelope(opts: CreateEnvelopeOpts): LatentEnvelope;
|
|
21
|
+
/** JSON-serialize an envelope with stable key order. */
|
|
22
|
+
export declare function serialize(env: LatentEnvelope): string;
|
|
23
|
+
/** Parse, validate shape, recompute hash, throw if mismatch. */
|
|
24
|
+
export declare function deserialize(s: string): LatentEnvelope;
|
|
25
|
+
/** True iff stored `contentHash` matches a fresh hash of the body. */
|
|
26
|
+
export declare function verifyHash(env: LatentEnvelope): boolean;
|
|
27
|
+
/** Append a provenance step and rehash. Returns a new envelope. */
|
|
28
|
+
export declare function withProvenance(env: LatentEnvelope, entry: Omit<ProvenanceEntry, 'step'> & {
|
|
29
|
+
step?: number;
|
|
30
|
+
}): LatentEnvelope;
|
|
31
|
+
/**
|
|
32
|
+
* Combine two envelopes from the same from/to pair.
|
|
33
|
+
* - text: a + '\n' + b
|
|
34
|
+
* - structured: shallow merge with one-level deep-merge for plain objects
|
|
35
|
+
* - provenance: a then b, renumbered sequentially
|
|
36
|
+
* - createdAt: fresh ISO timestamp; contentHash: recomputed
|
|
37
|
+
*/
|
|
38
|
+
export declare function merge(a: LatentEnvelope, b: LatentEnvelope): LatentEnvelope;
|
|
39
|
+
//# sourceMappingURL=envelope.d.ts.map
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Latent-state envelope — runtime helpers.
|
|
3
|
+
* Pure functions over `LatentEnvelope`. Node `crypto` (sha256) for hashing.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
/** JSON.stringify with deterministic key order (Object.keys().sort()). */
|
|
7
|
+
export function stableStringify(value) {
|
|
8
|
+
return JSON.stringify(canonicalize(value));
|
|
9
|
+
}
|
|
10
|
+
function canonicalize(value) {
|
|
11
|
+
if (value === null || typeof value !== 'object')
|
|
12
|
+
return value;
|
|
13
|
+
if (Array.isArray(value))
|
|
14
|
+
return value.map(canonicalize);
|
|
15
|
+
const obj = value;
|
|
16
|
+
const out = {};
|
|
17
|
+
for (const key of Object.keys(obj).sort())
|
|
18
|
+
out[key] = canonicalize(obj[key]);
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
function hashableBody(env) {
|
|
22
|
+
return {
|
|
23
|
+
version: env.version, from: env.from, to: env.to, kind: env.kind,
|
|
24
|
+
text: env.text, structured: env.structured,
|
|
25
|
+
provenance: env.provenance, createdAt: env.createdAt,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function computeHash(env) {
|
|
29
|
+
return createHash('sha256').update(stableStringify(hashableBody(env))).digest('hex');
|
|
30
|
+
}
|
|
31
|
+
function classify(text, structured) {
|
|
32
|
+
if (text !== undefined && structured !== undefined)
|
|
33
|
+
return 'mixed';
|
|
34
|
+
if (structured !== undefined)
|
|
35
|
+
return 'structured';
|
|
36
|
+
return 'text';
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Build a fresh envelope. Auto-derives `kind`, stamps `createdAt`, seeds
|
|
40
|
+
* provenance with a single step from `opts.from`, and computes `contentHash`.
|
|
41
|
+
*/
|
|
42
|
+
export function createEnvelope(opts) {
|
|
43
|
+
const kind = classify(opts.text, opts.structured);
|
|
44
|
+
const createdAt = opts.createdAt ?? new Date().toISOString();
|
|
45
|
+
const provenance = [
|
|
46
|
+
{
|
|
47
|
+
step: 1,
|
|
48
|
+
agent: opts.from,
|
|
49
|
+
ts: createdAt,
|
|
50
|
+
...(opts.note !== undefined ? { note: opts.note } : {}),
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
const body = {
|
|
54
|
+
version: 1,
|
|
55
|
+
from: opts.from,
|
|
56
|
+
to: opts.to,
|
|
57
|
+
kind,
|
|
58
|
+
...(opts.text !== undefined ? { text: opts.text } : {}),
|
|
59
|
+
...(opts.structured !== undefined ? { structured: opts.structured } : {}),
|
|
60
|
+
provenance,
|
|
61
|
+
createdAt,
|
|
62
|
+
};
|
|
63
|
+
return { ...body, contentHash: computeHash(body) };
|
|
64
|
+
}
|
|
65
|
+
/** JSON-serialize an envelope with stable key order. */
|
|
66
|
+
export function serialize(env) {
|
|
67
|
+
return stableStringify(env);
|
|
68
|
+
}
|
|
69
|
+
/** Parse, validate shape, recompute hash, throw if mismatch. */
|
|
70
|
+
export function deserialize(s) {
|
|
71
|
+
let raw;
|
|
72
|
+
try {
|
|
73
|
+
raw = JSON.parse(s);
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
throw new Error(`latent-state: invalid JSON: ${err.message}`);
|
|
77
|
+
}
|
|
78
|
+
const env = assertShape(raw);
|
|
79
|
+
if (!verifyHash(env)) {
|
|
80
|
+
throw new Error('latent-state: contentHash mismatch — envelope tampered');
|
|
81
|
+
}
|
|
82
|
+
return env;
|
|
83
|
+
}
|
|
84
|
+
function assertShape(raw) {
|
|
85
|
+
if (!raw || typeof raw !== 'object')
|
|
86
|
+
throw new Error('latent-state: envelope must be an object');
|
|
87
|
+
const o = raw;
|
|
88
|
+
if (o.version !== 1)
|
|
89
|
+
throw new Error(`latent-state: unsupported version ${String(o.version)}`);
|
|
90
|
+
if (typeof o.from !== 'string' || typeof o.to !== 'string')
|
|
91
|
+
throw new Error('latent-state: from/to must be strings');
|
|
92
|
+
if (o.kind !== 'text' && o.kind !== 'structured' && o.kind !== 'mixed')
|
|
93
|
+
throw new Error(`latent-state: invalid kind ${String(o.kind)}`);
|
|
94
|
+
if (typeof o.createdAt !== 'string' || typeof o.contentHash !== 'string')
|
|
95
|
+
throw new Error('latent-state: createdAt/contentHash must be strings');
|
|
96
|
+
if (!Array.isArray(o.provenance) || o.provenance.length === 0)
|
|
97
|
+
throw new Error('latent-state: provenance must be a non-empty array');
|
|
98
|
+
return o;
|
|
99
|
+
}
|
|
100
|
+
/** True iff stored `contentHash` matches a fresh hash of the body. */
|
|
101
|
+
export function verifyHash(env) {
|
|
102
|
+
const { contentHash, ...body } = env;
|
|
103
|
+
return computeHash(body) === contentHash;
|
|
104
|
+
}
|
|
105
|
+
/** Append a provenance step and rehash. Returns a new envelope. */
|
|
106
|
+
export function withProvenance(env, entry) {
|
|
107
|
+
const nextStep = entry.step ?? Math.max(0, ...env.provenance.map((p) => p.step)) + 1;
|
|
108
|
+
const provenance = [
|
|
109
|
+
...env.provenance,
|
|
110
|
+
{
|
|
111
|
+
step: nextStep,
|
|
112
|
+
agent: entry.agent,
|
|
113
|
+
ts: entry.ts,
|
|
114
|
+
...(entry.note !== undefined ? { note: entry.note } : {}),
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
const { contentHash: _drop, ...rest } = { ...env, provenance };
|
|
118
|
+
void _drop;
|
|
119
|
+
return { ...rest, contentHash: computeHash(rest) };
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Combine two envelopes from the same from/to pair.
|
|
123
|
+
* - text: a + '\n' + b
|
|
124
|
+
* - structured: shallow merge with one-level deep-merge for plain objects
|
|
125
|
+
* - provenance: a then b, renumbered sequentially
|
|
126
|
+
* - createdAt: fresh ISO timestamp; contentHash: recomputed
|
|
127
|
+
*/
|
|
128
|
+
export function merge(a, b) {
|
|
129
|
+
if (a.from !== b.from || a.to !== b.to) {
|
|
130
|
+
throw new Error('latent-state: cannot merge envelopes with different from/to');
|
|
131
|
+
}
|
|
132
|
+
const text = mergeText(a.text, b.text);
|
|
133
|
+
const structured = mergeStructured(a.structured, b.structured);
|
|
134
|
+
const kind = classify(text, structured);
|
|
135
|
+
const provenance = [...a.provenance, ...b.provenance].map((p, i) => ({ ...p, step: i + 1 }));
|
|
136
|
+
const body = {
|
|
137
|
+
version: 1,
|
|
138
|
+
from: a.from,
|
|
139
|
+
to: a.to,
|
|
140
|
+
kind,
|
|
141
|
+
...(text !== undefined ? { text } : {}),
|
|
142
|
+
...(structured !== undefined ? { structured } : {}),
|
|
143
|
+
provenance,
|
|
144
|
+
createdAt: new Date().toISOString(),
|
|
145
|
+
};
|
|
146
|
+
return { ...body, contentHash: computeHash(body) };
|
|
147
|
+
}
|
|
148
|
+
function mergeText(a, b) {
|
|
149
|
+
if (a === undefined && b === undefined)
|
|
150
|
+
return undefined;
|
|
151
|
+
if (a === undefined)
|
|
152
|
+
return b;
|
|
153
|
+
if (b === undefined)
|
|
154
|
+
return a;
|
|
155
|
+
return `${a}\n${b}`;
|
|
156
|
+
}
|
|
157
|
+
function mergeStructured(a, b) {
|
|
158
|
+
if (!a && !b)
|
|
159
|
+
return undefined;
|
|
160
|
+
if (!a)
|
|
161
|
+
return { ...b };
|
|
162
|
+
if (!b)
|
|
163
|
+
return { ...a };
|
|
164
|
+
const out = { ...a };
|
|
165
|
+
for (const k of Object.keys(b)) {
|
|
166
|
+
const av = a[k], bv = b[k];
|
|
167
|
+
if (av && bv &&
|
|
168
|
+
typeof av === 'object' && typeof bv === 'object' &&
|
|
169
|
+
!Array.isArray(av) && !Array.isArray(bv)) {
|
|
170
|
+
out[k] = { ...av, ...bv };
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
out[k] = bv;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
//# sourceMappingURL=envelope.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Latent-state envelope — public surface. */
|
|
2
|
+
export type { AgentTransfer, EnvelopeKind, LatentEnvelope, ProvenanceEntry, } from './types.js';
|
|
3
|
+
export { createEnvelope, deserialize, merge, serialize, stableStringify, verifyHash, withProvenance, } from './envelope.js';
|
|
4
|
+
export type { CreateEnvelopeOpts } from './envelope.js';
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Latent-state envelope — type definitions.
|
|
3
|
+
*
|
|
4
|
+
* Maps onto "Recursive Multi-Agent Systems" (Stanford/UIUC/NVIDIA/MIT,
|
|
5
|
+
* arXiv:2604.25917). Today most inter-agent handoffs are plain text. As
|
|
6
|
+
* models gain native structured-state IO, the harness should already be
|
|
7
|
+
* carrying typed envelopes so the upgrade path is a payload swap, not an
|
|
8
|
+
* interface change.
|
|
9
|
+
*
|
|
10
|
+
* Runtime lives in `envelope.ts`.
|
|
11
|
+
*/
|
|
12
|
+
/** One step in the chain of custody. */
|
|
13
|
+
export interface ProvenanceEntry {
|
|
14
|
+
step: number;
|
|
15
|
+
agent: string;
|
|
16
|
+
ts: string;
|
|
17
|
+
note?: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Payload discriminator.
|
|
21
|
+
* - 'text' — text only (today's common case)
|
|
22
|
+
* - 'structured' — structured payload only (latent-state native)
|
|
23
|
+
* - 'mixed' — both present
|
|
24
|
+
*/
|
|
25
|
+
export type EnvelopeKind = 'text' | 'structured' | 'mixed';
|
|
26
|
+
/**
|
|
27
|
+
* `contentHash` is sha256 over a canonical JSON serialization of the body
|
|
28
|
+
* (every field except contentHash itself). Used by `verifyHash` to detect
|
|
29
|
+
* tampering and by `merge` to derive fresh hashes after combining.
|
|
30
|
+
*/
|
|
31
|
+
export interface LatentEnvelope {
|
|
32
|
+
version: 1;
|
|
33
|
+
from: string;
|
|
34
|
+
to: string;
|
|
35
|
+
kind: EnvelopeKind;
|
|
36
|
+
text?: string;
|
|
37
|
+
structured?: Record<string, unknown>;
|
|
38
|
+
provenance: ProvenanceEntry[];
|
|
39
|
+
createdAt: string;
|
|
40
|
+
contentHash: string;
|
|
41
|
+
}
|
|
42
|
+
/** Wraps an envelope for transport. `ackToken` reserved for future ack flows. */
|
|
43
|
+
export interface AgentTransfer {
|
|
44
|
+
envelope: LatentEnvelope;
|
|
45
|
+
ackToken?: string;
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Latent-state envelope — type definitions.
|
|
3
|
+
*
|
|
4
|
+
* Maps onto "Recursive Multi-Agent Systems" (Stanford/UIUC/NVIDIA/MIT,
|
|
5
|
+
* arXiv:2604.25917). Today most inter-agent handoffs are plain text. As
|
|
6
|
+
* models gain native structured-state IO, the harness should already be
|
|
7
|
+
* carrying typed envelopes so the upgrade path is a payload swap, not an
|
|
8
|
+
* interface change.
|
|
9
|
+
*
|
|
10
|
+
* Runtime lives in `envelope.ts`.
|
|
11
|
+
*/
|
|
12
|
+
export {};
|
|
13
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { PermissionGrant, Persona, Verdict } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Reset all rate-limit state. Test-only seam; not exported from index.ts.
|
|
4
|
+
*/
|
|
5
|
+
export declare function _resetRateLimits(): void;
|
|
6
|
+
/**
|
|
7
|
+
* Resolve (persona, tool, args) → Verdict.
|
|
8
|
+
*
|
|
9
|
+
* Iterates scopes in order; the first scope whose toolPattern matches and
|
|
10
|
+
* whose argConstraints + rateLimit + blast-radius all pass produces an
|
|
11
|
+
* `allowed: true` verdict. If a scope's toolPattern matches but a check
|
|
12
|
+
* fails, we continue checking later scopes — a denial on one scope doesn't
|
|
13
|
+
* forbid a later, more permissive scope.
|
|
14
|
+
*
|
|
15
|
+
* If no scope matches the tool name at all, the verdict is denied with
|
|
16
|
+
* reason "no scope matched". If scopes matched but all failed sub-checks,
|
|
17
|
+
* the verdict is denied with the *last* failure reason.
|
|
18
|
+
*/
|
|
19
|
+
export declare function canInvoke(persona: Persona, toolName: string, args: Record<string, unknown>, opts?: {
|
|
20
|
+
now?: number;
|
|
21
|
+
}): Verdict;
|
|
22
|
+
/**
|
|
23
|
+
* Throws PermissionDeniedError if canInvoke yields a denied verdict.
|
|
24
|
+
* Returns the verdict on success for callers that want to inspect matchedScope.
|
|
25
|
+
*/
|
|
26
|
+
export declare function enforce(grant: PermissionGrant, opts?: {
|
|
27
|
+
now?: number;
|
|
28
|
+
}): Verdict;
|
|
29
|
+
/**
|
|
30
|
+
* Compose multiple personas into a single one.
|
|
31
|
+
* - id: joined with "+"
|
|
32
|
+
* - description: joined with "; "
|
|
33
|
+
* - scopes: concatenated (canInvoke iterates in order, so earliest wins ties)
|
|
34
|
+
* - maxBlastRadius: max over all inputs (undefined treated as 'none')
|
|
35
|
+
*
|
|
36
|
+
* Rate-limit state is keyed on the *resulting* persona's id, so merged
|
|
37
|
+
* personas have their own counter independent of the inputs.
|
|
38
|
+
*/
|
|
39
|
+
export declare function mergePersonas(...personas: Persona[]): Persona;
|
|
40
|
+
/**
|
|
41
|
+
* Lookup helper. Throws on miss so callers fail loudly rather than silently
|
|
42
|
+
* proceeding without a scope.
|
|
43
|
+
*/
|
|
44
|
+
export declare function loadPersona(id: string, registry: Record<string, Persona>): Persona;
|
|
45
|
+
//# sourceMappingURL=check.d.ts.map
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// futures/persona/check — runtime resolver for (persona, tool, args) → Verdict.
|
|
2
|
+
//
|
|
3
|
+
// Pure resolution + a module-level Map for rate-limit counters. The Map is
|
|
4
|
+
// process-scoped, which is correct for a single CLI run; longer-lived state
|
|
5
|
+
// (e.g. across daemon restarts) is out of scope for v5 phase 1.
|
|
6
|
+
import { BLAST_RADIUS_ORDER, PermissionDeniedError, } from './types.js';
|
|
7
|
+
/** keyed by `${persona.id}::${toolName}` */
|
|
8
|
+
const RATE_LIMIT_STATE = new Map();
|
|
9
|
+
/**
|
|
10
|
+
* Reset all rate-limit state. Test-only seam; not exported from index.ts.
|
|
11
|
+
*/
|
|
12
|
+
export function _resetRateLimits() {
|
|
13
|
+
RATE_LIMIT_STATE.clear();
|
|
14
|
+
}
|
|
15
|
+
function rateKey(personaId, toolName) {
|
|
16
|
+
return `${personaId}::${toolName}`;
|
|
17
|
+
}
|
|
18
|
+
function blastRadiusRank(r) {
|
|
19
|
+
return BLAST_RADIUS_ORDER.indexOf(r);
|
|
20
|
+
}
|
|
21
|
+
function maxBlastRadius(a, b) {
|
|
22
|
+
return blastRadiusRank(a) >= blastRadiusRank(b) ? a : b;
|
|
23
|
+
}
|
|
24
|
+
function patternMatches(pattern, toolName) {
|
|
25
|
+
if (typeof pattern === 'string')
|
|
26
|
+
return pattern === toolName;
|
|
27
|
+
return pattern.test(toolName);
|
|
28
|
+
}
|
|
29
|
+
function checkArgRule(argName, value, rule) {
|
|
30
|
+
// type
|
|
31
|
+
if (rule.type === 'enum') {
|
|
32
|
+
if (!rule.allowedValues || !rule.allowedValues.includes(value)) {
|
|
33
|
+
return { ok: false, reason: `arg "${argName}" not in allowedValues` };
|
|
34
|
+
}
|
|
35
|
+
return { ok: true };
|
|
36
|
+
}
|
|
37
|
+
if (rule.type === 'string') {
|
|
38
|
+
if (typeof value !== 'string')
|
|
39
|
+
return { ok: false, reason: `arg "${argName}" must be string` };
|
|
40
|
+
if (rule.pattern) {
|
|
41
|
+
const matched = rule.pattern.test(value);
|
|
42
|
+
if (rule.denyPattern && matched) {
|
|
43
|
+
return { ok: false, reason: `arg "${argName}" matches deny pattern` };
|
|
44
|
+
}
|
|
45
|
+
if (!rule.denyPattern && !matched) {
|
|
46
|
+
return { ok: false, reason: `arg "${argName}" does not match required pattern` };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (rule.min !== undefined && value.length < rule.min) {
|
|
50
|
+
return { ok: false, reason: `arg "${argName}" shorter than min=${rule.min}` };
|
|
51
|
+
}
|
|
52
|
+
if (rule.max !== undefined && value.length > rule.max) {
|
|
53
|
+
return { ok: false, reason: `arg "${argName}" longer than max=${rule.max}` };
|
|
54
|
+
}
|
|
55
|
+
return { ok: true };
|
|
56
|
+
}
|
|
57
|
+
if (rule.type === 'number') {
|
|
58
|
+
if (typeof value !== 'number' || Number.isNaN(value)) {
|
|
59
|
+
return { ok: false, reason: `arg "${argName}" must be number` };
|
|
60
|
+
}
|
|
61
|
+
if (rule.min !== undefined && value < rule.min) {
|
|
62
|
+
return { ok: false, reason: `arg "${argName}" below min=${rule.min}` };
|
|
63
|
+
}
|
|
64
|
+
if (rule.max !== undefined && value > rule.max) {
|
|
65
|
+
return { ok: false, reason: `arg "${argName}" above max=${rule.max}` };
|
|
66
|
+
}
|
|
67
|
+
if (rule.allowedValues && !rule.allowedValues.includes(value)) {
|
|
68
|
+
return { ok: false, reason: `arg "${argName}" not in allowedValues` };
|
|
69
|
+
}
|
|
70
|
+
return { ok: true };
|
|
71
|
+
}
|
|
72
|
+
if (rule.type === 'boolean') {
|
|
73
|
+
if (typeof value !== 'boolean')
|
|
74
|
+
return { ok: false, reason: `arg "${argName}" must be boolean` };
|
|
75
|
+
if (rule.allowedValues && !rule.allowedValues.includes(value)) {
|
|
76
|
+
return { ok: false, reason: `arg "${argName}" not in allowedValues` };
|
|
77
|
+
}
|
|
78
|
+
return { ok: true };
|
|
79
|
+
}
|
|
80
|
+
return { ok: false, reason: `arg "${argName}" unknown rule type` };
|
|
81
|
+
}
|
|
82
|
+
function checkArgs(scope, args) {
|
|
83
|
+
if (!scope.argConstraints)
|
|
84
|
+
return { ok: true };
|
|
85
|
+
for (const [argName, rule] of Object.entries(scope.argConstraints)) {
|
|
86
|
+
const result = checkArgRule(argName, args[argName], rule);
|
|
87
|
+
if (!result.ok)
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
return { ok: true };
|
|
91
|
+
}
|
|
92
|
+
function checkRateLimit(personaId, toolName, scope, now) {
|
|
93
|
+
if (!scope.rateLimit)
|
|
94
|
+
return { ok: true };
|
|
95
|
+
const { max, windowMs } = scope.rateLimit;
|
|
96
|
+
const key = rateKey(personaId, toolName);
|
|
97
|
+
const bucket = RATE_LIMIT_STATE.get(key) ?? { hits: [] };
|
|
98
|
+
// drop hits outside the window
|
|
99
|
+
bucket.hits = bucket.hits.filter((ts) => now - ts < windowMs);
|
|
100
|
+
if (bucket.hits.length >= max) {
|
|
101
|
+
RATE_LIMIT_STATE.set(key, bucket);
|
|
102
|
+
return { ok: false, reason: `rate limit exceeded (${max}/${windowMs}ms)` };
|
|
103
|
+
}
|
|
104
|
+
bucket.hits.push(now);
|
|
105
|
+
RATE_LIMIT_STATE.set(key, bucket);
|
|
106
|
+
return { ok: true };
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Resolve (persona, tool, args) → Verdict.
|
|
110
|
+
*
|
|
111
|
+
* Iterates scopes in order; the first scope whose toolPattern matches and
|
|
112
|
+
* whose argConstraints + rateLimit + blast-radius all pass produces an
|
|
113
|
+
* `allowed: true` verdict. If a scope's toolPattern matches but a check
|
|
114
|
+
* fails, we continue checking later scopes — a denial on one scope doesn't
|
|
115
|
+
* forbid a later, more permissive scope.
|
|
116
|
+
*
|
|
117
|
+
* If no scope matches the tool name at all, the verdict is denied with
|
|
118
|
+
* reason "no scope matched". If scopes matched but all failed sub-checks,
|
|
119
|
+
* the verdict is denied with the *last* failure reason.
|
|
120
|
+
*/
|
|
121
|
+
export function canInvoke(persona, toolName, args, opts) {
|
|
122
|
+
const now = opts?.now ?? Date.now();
|
|
123
|
+
let lastFailure = null;
|
|
124
|
+
let anyToolMatch = false;
|
|
125
|
+
const personaCap = persona.maxBlastRadius;
|
|
126
|
+
for (const scope of persona.scopes) {
|
|
127
|
+
if (!patternMatches(scope.toolPattern, toolName))
|
|
128
|
+
continue;
|
|
129
|
+
anyToolMatch = true;
|
|
130
|
+
// enforce blast-radius cap
|
|
131
|
+
const scopeRadius = scope.blastRadius ?? personaCap ?? 'none';
|
|
132
|
+
if (personaCap && blastRadiusRank(scopeRadius) > blastRadiusRank(personaCap)) {
|
|
133
|
+
lastFailure = {
|
|
134
|
+
reason: `scope blastRadius=${scopeRadius} exceeds persona max=${personaCap}`,
|
|
135
|
+
scope,
|
|
136
|
+
};
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const argResult = checkArgs(scope, args);
|
|
140
|
+
if (!argResult.ok) {
|
|
141
|
+
lastFailure = { reason: argResult.reason, scope };
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const rateResult = checkRateLimit(persona.id, toolName, scope, now);
|
|
145
|
+
if (!rateResult.ok) {
|
|
146
|
+
lastFailure = { reason: rateResult.reason, scope };
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
return { allowed: true, matchedScope: scope };
|
|
150
|
+
}
|
|
151
|
+
if (lastFailure) {
|
|
152
|
+
return { allowed: false, reason: lastFailure.reason, matchedScope: lastFailure.scope };
|
|
153
|
+
}
|
|
154
|
+
if (!anyToolMatch) {
|
|
155
|
+
return { allowed: false, reason: `no scope matched tool "${toolName}"` };
|
|
156
|
+
}
|
|
157
|
+
return { allowed: false, reason: 'permission denied' };
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Throws PermissionDeniedError if canInvoke yields a denied verdict.
|
|
161
|
+
* Returns the verdict on success for callers that want to inspect matchedScope.
|
|
162
|
+
*/
|
|
163
|
+
export function enforce(grant, opts) {
|
|
164
|
+
const verdict = canInvoke(grant.persona, grant.toolName, grant.args, opts);
|
|
165
|
+
if (!verdict.allowed)
|
|
166
|
+
throw new PermissionDeniedError(grant, verdict);
|
|
167
|
+
return verdict;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Compose multiple personas into a single one.
|
|
171
|
+
* - id: joined with "+"
|
|
172
|
+
* - description: joined with "; "
|
|
173
|
+
* - scopes: concatenated (canInvoke iterates in order, so earliest wins ties)
|
|
174
|
+
* - maxBlastRadius: max over all inputs (undefined treated as 'none')
|
|
175
|
+
*
|
|
176
|
+
* Rate-limit state is keyed on the *resulting* persona's id, so merged
|
|
177
|
+
* personas have their own counter independent of the inputs.
|
|
178
|
+
*/
|
|
179
|
+
export function mergePersonas(...personas) {
|
|
180
|
+
if (personas.length === 0) {
|
|
181
|
+
return { id: 'empty', description: 'empty merged persona', scopes: [], maxBlastRadius: 'none' };
|
|
182
|
+
}
|
|
183
|
+
if (personas.length === 1)
|
|
184
|
+
return personas[0];
|
|
185
|
+
const id = personas.map((p) => p.id).join('+');
|
|
186
|
+
const description = personas.map((p) => p.description).join('; ');
|
|
187
|
+
const scopes = personas.flatMap((p) => p.scopes);
|
|
188
|
+
let cap = 'none';
|
|
189
|
+
for (const p of personas) {
|
|
190
|
+
if (p.maxBlastRadius)
|
|
191
|
+
cap = maxBlastRadius(cap, p.maxBlastRadius);
|
|
192
|
+
}
|
|
193
|
+
return { id, description, scopes, maxBlastRadius: cap };
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Lookup helper. Throws on miss so callers fail loudly rather than silently
|
|
197
|
+
* proceeding without a scope.
|
|
198
|
+
*/
|
|
199
|
+
export function loadPersona(id, registry) {
|
|
200
|
+
const found = registry[id];
|
|
201
|
+
if (!found)
|
|
202
|
+
throw new Error(`unknown persona id "${id}"`);
|
|
203
|
+
return found;
|
|
204
|
+
}
|
|
205
|
+
//# sourceMappingURL=check.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type { ArgRule, BlastRadius, PermissionGrant, Persona, Scope, Verdict, } from './types.js';
|
|
2
|
+
export { BLAST_RADIUS_ORDER, PermissionDeniedError } from './types.js';
|
|
3
|
+
export { canInvoke, enforce, mergePersonas, loadPersona } from './check.js';
|
|
4
|
+
export { RESEARCHER, CODER, COMPUTER_USE, PERSONA_REGISTRY } from './registry.js';
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// futures/persona — public surface.
|
|
2
|
+
export { BLAST_RADIUS_ORDER, PermissionDeniedError } from './types.js';
|
|
3
|
+
export { canInvoke, enforce, mergePersonas, loadPersona } from './check.js';
|
|
4
|
+
export { RESEARCHER, CODER, COMPUTER_USE, PERSONA_REGISTRY } from './registry.js';
|
|
5
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Persona } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Researcher: read-only research tools. Cannot mutate filesystem, repo, or
|
|
4
|
+
* external state. Web fetches and grep are fine; writes are not.
|
|
5
|
+
*/
|
|
6
|
+
export declare const RESEARCHER: Persona;
|
|
7
|
+
/**
|
|
8
|
+
* Coder: read-write inside the workspace. Bash allowed but the most common
|
|
9
|
+
* destructive forms are denied via deny-pattern argRules. No force pushes.
|
|
10
|
+
* Rate limit on bash so a runaway loop can't burn 10k commands.
|
|
11
|
+
*/
|
|
12
|
+
export declare const CODER: Persona;
|
|
13
|
+
/**
|
|
14
|
+
* Computer-use: explicit destructive opt-in. GUI tools that drive the user's
|
|
15
|
+
* physical desktop. Rate-limited so a hung loop can't spam clicks.
|
|
16
|
+
*/
|
|
17
|
+
export declare const COMPUTER_USE: Persona;
|
|
18
|
+
/**
|
|
19
|
+
* Default registry. Add to this when wiring more personas.
|
|
20
|
+
*/
|
|
21
|
+
export declare const PERSONA_REGISTRY: Record<string, Persona>;
|
|
22
|
+
//# sourceMappingURL=registry.d.ts.map
|