openvibers/openvibe.ai Commits
Memory M4: an agent reads a granted profile, audited with the ids (#321)
Changes
@@@ -19,7 +19,8 @@* POST /api/v1/memory/items/:id/settle the person settles a conflict: { keep: <id> } keeps one and archives the other* GET /api/v1/memory/reads the person's own read log: who read, for what, how many notes (never ids, never text)* GET /api/v1/memory/profile?scope= the cached profile, rebuilt by the cheap tier only when its items_hash moved,-- * at most once an hour per owner and scope++ * at most once an hour per owner and scope; the person sees their own, an agent++ * with ai.memory.read sees a scope its grant names, audited in memory_reads* GET /api/v1/memory/search?q=&scopes= for agents (ai.memory.read + on_behalf_of): active items only, in the* granted scopes, at most 8, audited in memory_reads with ids and never text* GET /api/v1/memory/import/providers the cloud walkthroughs and the extraction prompt (M3, person token only)@@@ -732,7 +733,21 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = conassertConfigured();const scope = validateScope(req.query.scope, 'user');requireScope(req, scope);-- return { profile: (await profileFor(owner, scope)).profile };++ const out = await profileFor(owner, scope);++ // An agent's profile is a read of the notes it was built from: audit it the way search is audited, with the++ // ids only. The person reading their own profile is not an audited agent read.++ if (req.principal.kind !== 'user') {++ const rows = await activeRows(owner, scope);++ await q('INSERT INTO memory_reads (id, owner, actor, purpose, scopes, item_ids, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)')++ .run(`mrd_${ids.ulid()}`, owner, req.principal.sub, readPurpose(req), JSON.stringify([scope]), JSON.stringify(rows.map((r) => r.id)), iso(clock.now()));++ }++ return { profile: out.profile };++ }++++ /** The purpose an agent names for a read, the same for search and for the profile. */++ function readPurpose(req) {++ const purpose = req.query.purpose == null ? (req.query.task == null ? null : String(req.query.task)) : String(req.query.purpose);++ return purpose ? purpose.slice(0, 200) : null;}/** An agent's search: active items only, the granted scopes, at most 8, and one memory_reads row with ids only. */@@@ -754,9 +769,8 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = conif (items.length >= MAX_SEARCH) break;}if (req.principal.kind !== 'user') {-- const purpose = req.query.purpose == null ? (req.query.task == null ? null : String(req.query.task)) : String(req.query.purpose);await q('INSERT INTO memory_reads (id, owner, actor, purpose, scopes, item_ids, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)')-- .run(`mrd_${ids.ulid()}`, owner, req.principal.sub, purpose ? purpose.slice(0, 200) : null, JSON.stringify(scopes), JSON.stringify(items.map((i) => i.id)), iso(clock.now()));++ .run(`mrd_${ids.ulid()}`, owner, req.principal.sub, readPurpose(req), JSON.stringify(scopes), JSON.stringify(items.map((i) => i.id)), iso(clock.now()));}return { items, count: items.length };}@@@ -889,8 +903,8 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = con/*** The memory routes. A person's own Network token acts for themselves; a service token needs the capability and-- * names the person with `on_behalf_of`, but it may only search (audited) and import suggestions. Every other route is-- * person-only: a service gets 403 `capability.denied`.++ * names the person with `on_behalf_of`, but it may only search and read the profile of a granted scope (both++ * audited) and import suggestions. Every other route is person-only: a service gets 403 `capability.denied`.*/function memoryRouter({ memory, memoryImport = null, auth, config, clock = { now: () => Date.now() }, log = console, sendError }) {const r = express.Router();@@@ -936,9 +950,9 @@ function memoryRouter({ memory, memoryImport = null, auth, config, clock = { nowtry { res.set('Cache-Control', 'no-store').json(await fn(req)); } catch (err) { sendError(res, err, req.ov); }};/**-- * The person's own routes. A service with the read or write capability may only search (the audited agent read)-- * and import suggestions; it may not browse, profile, patch or delete the person's memory. The guard runs after-- * `guard`, which has set req.principal.++ * The person's own routes. A service with the read or write capability may only search and read the profile (the++ * audited agent reads) and import suggestions; it may not browse, patch or delete the person's memory. The guard++ * runs after `guard`, which has set req.principal.*/function personOnly(req, res, next) {if (req.principal.kind === 'service') {@@@ -952,7 +966,9 @@ function memoryRouter({ memory, memoryImport = null, auth, config, clock = { nowr.patch('/api/v1/memory/items/:id', guard(WRITE_CAP), personOnly, run(async (req) => await memory.patch(req, ownerOf(req), req.params.id)));r.delete('/api/v1/memory/items/:id', guard(WRITE_CAP), personOnly, run(async (req) => await memory.remove(req, ownerOf(req), req.params.id)));r.delete('/api/v1/memory/items', guard(WRITE_CAP), personOnly, run(async (req) => await memory.removeBySource(req, ownerOf(req))));-- r.get('/api/v1/memory/profile', guard(READ_CAP), personOnly, run(async (req) => await memory.profile(req, ownerOf(req))));++ // M4: an agent with ai.memory.read may read the profile of a scope its grant names. The profile is a summary of++ // that scope's active notes, so it is the same grant as search, and it is audited with the ids only.++ r.get('/api/v1/memory/profile', guard(READ_CAP), run(async (req) => await memory.profile(req, ownerOf(req))));// M2b: the person's own read log (who read their memory, and how much of it). Person-only, like the other// browsing routes: a service has no business in the person's audit.r.get('/api/v1/memory/reads', guard(READ_CAP), personOnly, run(async (req) => await memory.reads(req, ownerOf(req))));@@@ -141,7 +141,7 @@ route('post', '/memory/items/{id}/settle', {});route('get', '/memory/profile', {operationId: 'getMemoryProfile', summary: 'The cached profile for one scope', tags: ['memory'], security: memorySecurity,-- description: 'ai.memory.read. Rebuilt by the cheap tier only when items_hash changes, at most once an hour per owner and scope; the result is stored as written.',++ description: 'ai.memory.read. The person\u2019s own profile, or an agent\u2019s: a service token with on_behalf_of reads only a scope its grant names, and that read is audited in memory_reads with the note ids it was built from, never the text. Rebuilt by the cheap tier only when items_hash changes, at most once an hour per owner and scope; the result is stored as written.',parameters: [query('scope', str()), query('on_behalf_of', str())],responses: { 200: json('The cached profile', memoryResult), 503: problem, ...errors([401, 403]) },});@@@ -1,8 +1,8 @@'use strict';/**-- * Memory M2a: the person's store in OpenVibe.AI. Sealing and AAD binding, dedupe, server-side redaction, owner scope,-- * the agent search (active only, at most 8, audited), the profile cache, delete, the 503 without a key, account data-- * and the no-plaintext-in-logs rule.++ * Memory M2a/M4: the person's store in OpenVibe.AI. Sealing and AAD binding, dedupe, server-side redaction, owner++ * scope, the agent search (active only, at most 8, audited) and the scoped agent profile (audited), the profile cache,++ * delete, the 503 without a key, account data and the no-plaintext-in-logs rule.*/const assert = require('assert');const crypto = require('crypto');@@@ -187,7 +187,7 @@ t.test('an agent sees active items only, at most 8, in its granted scopes, and t} finally { await h.stop(); }});--t.test('a service may only search and suggest: every other memory route is person-only', async () => {++t.test('a service may only search, read a granted profile and suggest: every other memory route is person-only', async () => {const h = await boot({ env: { AI_MEMORY_KEY: KEY } });try {const who = userToken(A);@@@ -200,7 +200,6 @@ t.test('a service may only search and suggest: every other memory route is perso};denied(await request(h.base, 'GET', `/api/v1/memory/items?on_behalf_of=${A}`, { tok: read }));denied(await request(h.base, 'GET', `/api/v1/memory/items/${id}?on_behalf_of=${A}`, { tok: read }));-- denied(await request(h.base, 'GET', `/api/v1/memory/profile?scope=user&on_behalf_of=${A}`, { tok: read }));denied(await request(h.base, 'PATCH', `/api/v1/memory/items/${id}`, { tok: serviceWrite(), body: { text: 'hijacked', on_behalf_of: A } }));denied(await request(h.base, 'DELETE', `/api/v1/memory/items/${id}?on_behalf_of=${A}`, { tok: serviceWrite() }));denied(await request(h.base, 'DELETE', `/api/v1/memory/items?source=person&on_behalf_of=${A}`, { tok: serviceWrite() }));@@@ -218,11 +217,23 @@ t.test('a service may only search and suggest: every other memory route is persoassert.deepStrictEqual([row.status, row.source, Number(row.confidence)], ['suggested', 'codes', 0.5]);// Search still works for a service, and it is audited.-- const search = await request(h.base, 'GET', `/api/v1/memory/search?q=active&scopes=user&on_behalf_of=${A}`, { tok: read });++ const search = await request(h.base, 'GET', `/api/v1/memory/search?q=active&scopes=user&purpose=task-active&on_behalf_of=${A}`, { tok: read });assert.deepStrictEqual([search.status, search.body.items.length], [200, 1]);-- const audit = await h.db.prepare('SELECT actor, item_ids FROM memory_reads WHERE owner = ?').get(`user:${A}`);++ const audit = await h.db.prepare("SELECT actor, item_ids FROM memory_reads WHERE owner = ? AND purpose = 'task-active'").get(`user:${A}`);assert.equal(audit.actor, 'svc:codes');assert.ok(!audit.item_ids.includes('active note'));++++ // M4: the profile is the second audited agent read. A service reads only a scope its grant names, and the++ // empty project scope keeps this test off the profile builder.++ const projectRead = serviceRead({ memory_scopes: ['user', 'project:empty'] });++ const prof = await request(h.base, 'GET', `/api/v1/memory/profile?scope=project:empty&purpose=profile-task&on_behalf_of=${A}`, { tok: projectRead });++ assert.deepStrictEqual([prof.status, prof.body.profile.scope, prof.body.profile.text], [200, 'project:empty', '']);++ const profileAudit = await h.db.prepare("SELECT actor, purpose, scopes, item_ids FROM memory_reads WHERE owner = ? AND purpose = 'profile-task'").get(`user:${A}`);++ assert.equal(profileAudit.actor, 'svc:codes');++ assert.deepStrictEqual(JSON.parse(profileAudit.scopes), ['project:empty']);++ assert.deepStrictEqual(JSON.parse(profileAudit.item_ids), []);++ const wrongScope = await request(h.base, 'GET', `/api/v1/memory/profile?scope=project:empty&on_behalf_of=${A}`, { tok: read });++ assert.deepStrictEqual([wrongScope.status, wrongScope.body.code], [403, 'memory.scope_denied']);} finally { await h.stop(); }});@@@ -328,6 +339,41 @@ t.test('a scope change re-seals the text, and deleting one source leaves the oth} finally { await h.stop(); }});++t.test('an agent profile is scoped to its grant and audited with the ids it was built from', async () => {++ const h = await boot({ env: { AI_MEMORY_KEY: KEY } });++ try {++ const who = userToken(A);++ const made = await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: who, body: { items: [++ item('the agent profile note', { source: 'claude-code' }),++ ] } });++ const id = made.body.items[0].id;++ const m = createMemory({ db: h.db, config: h.config, clock: { now: () => Date.now() }, log: h.log || console,++ buildProfile: async ({ items }) => `A person with ${items.length} notes.` });++ const owner = `user:${A}`;++ const req = { principal: { kind: 'service', sub: 'svc:codes', claims: { memory_scopes: ['user'] } },++ query: { scope: 'user', purpose: 'task-profile' }, body: {}, ov: {} };++ const out = await m.profile(req, owner);++ assert.equal(out.profile.text, 'A person with 1 notes.');++ const audit = await h.db.prepare('SELECT actor, purpose, scopes, item_ids FROM memory_reads WHERE owner = ?').get(owner);++ assert.deepStrictEqual([audit.actor, audit.purpose], ['svc:codes', 'task-profile']);++ assert.deepStrictEqual(JSON.parse(audit.scopes), ['user']);++ assert.deepStrictEqual(JSON.parse(audit.item_ids), [id]);++ assert.ok(!audit.item_ids.includes('agent profile note'), 'the audit holds ids, never text');++++ // A scope the grant does not name is refused before a profile is built.++ await assert.rejects(++ () => m.profile({ ...req, query: { scope: 'project:demo' } }, owner),++ (err) => err.status === 403 && err.code === 'memory.scope_denied',++ );++++ // The person's own profile is not an audited agent read.++ const before = Number(await h.db.value('SELECT count(*)::int FROM memory_reads WHERE owner = $1', [owner]));++ await m.profile({ principal: { kind: 'user', subject: { type: 'user', id: A }, claims: {} }, query: { scope: 'user' }, body: {}, ov: {} }, owner);++ const after = Number(await h.db.value('SELECT count(*)::int FROM memory_reads WHERE owner = $1', [owner]));++ assert.equal(after, before);++ } finally { await h.stop(); }++});++t.test('the profile is cached, rebuilt only when items_hash moves and at most once an hour, and is stored as written', async () => {const h = await boot({ env: { AI_MEMORY_KEY: KEY } });try {
@@ -19,7 +19,8 @@
@@ -19,7 +19,8 @@
* POST /api/v1/memory/items/:id/settle the person settles a conflict: { keep: <id> } keeps one and archives the other
* POST /api/v1/memory/items/:id/settle the person settles a conflict: { keep: <id> } keeps one and archives the other
* GET /api/v1/memory/reads the person's own read log: who read, for what, how many notes (never ids, never text)
* GET /api/v1/memory/reads the person's own read log: who read, for what, how many notes (never ids, never text)
* GET /api/v1/memory/profile?scope= the cached profile, rebuilt by the cheap tier only when its items_hash moved,
* GET /api/v1/memory/profile?scope= the cached profile, rebuilt by the cheap tier only when its items_hash moved,
- * at most once an hour per owner and scope
+ * at most once an hour per owner and scope; the person sees their own, an agent
+ * with ai.memory.read sees a scope its grant names, audited in memory_reads
* GET /api/v1/memory/search?q=&scopes= for agents (ai.memory.read + on_behalf_of): active items only, in the
* GET /api/v1/memory/search?q=&scopes= for agents (ai.memory.read + on_behalf_of): active items only, in the
* granted scopes, at most 8, audited in memory_reads with ids and never text
* granted scopes, at most 8, audited in memory_reads with ids and never text
* GET /api/v1/memory/import/providers the cloud walkthroughs and the extraction prompt (M3, person token only)
* GET /api/v1/memory/import/providers the cloud walkthroughs and the extraction prompt (M3, person token only)
@@ -732,7 +733,21 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = con
@@ -732,7 +733,21 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = con
assertConfigured();
assertConfigured();
const scope = validateScope(req.query.scope, 'user');
const scope = validateScope(req.query.scope, 'user');
requireScope(req, scope);
requireScope(req, scope);
- return { profile: (await profileFor(owner, scope)).profile };
+ const out = await profileFor(owner, scope);
+ // An agent's profile is a read of the notes it was built from: audit it the way search is audited, with the
+ // ids only. The person reading their own profile is not an audited agent read.
+ if (req.principal.kind !== 'user') {
+ const rows = await activeRows(owner, scope);
+ await q('INSERT INTO memory_reads (id, owner, actor, purpose, scopes, item_ids, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)')
+ .run(`mrd_${ids.ulid()}`, owner, req.principal.sub, readPurpose(req), JSON.stringify([scope]), JSON.stringify(rows.map((r) => r.id)), iso(clock.now()));
+ }
+ return { profile: out.profile };
+ }
+
+ /** The purpose an agent names for a read, the same for search and for the profile. */
+ function readPurpose(req) {
+ const purpose = req.query.purpose == null ? (req.query.task == null ? null : String(req.query.task)) : String(req.query.purpose);
+ return purpose ? purpose.slice(0, 200) : null;
}
}
/** An agent's search: active items only, the granted scopes, at most 8, and one memory_reads row with ids only. */
/** An agent's search: active items only, the granted scopes, at most 8, and one memory_reads row with ids only. */
@@ -754,9 +769,8 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = con
@@ -754,9 +769,8 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = con
if (items.length >= MAX_SEARCH) break;
if (items.length >= MAX_SEARCH) break;
}
}
if (req.principal.kind !== 'user') {
if (req.principal.kind !== 'user') {
- const purpose = req.query.purpose == null ? (req.query.task == null ? null : String(req.query.task)) : String(req.query.purpose);
await q('INSERT INTO memory_reads (id, owner, actor, purpose, scopes, item_ids, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)')
await q('INSERT INTO memory_reads (id, owner, actor, purpose, scopes, item_ids, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)')
- .run(`mrd_${ids.ulid()}`, owner, req.principal.sub, purpose ? purpose.slice(0, 200) : null, JSON.stringify(scopes), JSON.stringify(items.map((i) => i.id)), iso(clock.now()));
+ .run(`mrd_${ids.ulid()}`, owner, req.principal.sub, readPurpose(req), JSON.stringify(scopes), JSON.stringify(items.map((i) => i.id)), iso(clock.now()));
}
}
return { items, count: items.length };
return { items, count: items.length };
}
}
@@ -889,8 +903,8 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = con
@@ -889,8 +903,8 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = con
/**
/**
* The memory routes. A person's own Network token acts for themselves; a service token needs the capability and
* The memory routes. A person's own Network token acts for themselves; a service token needs the capability and
- * names the person with `on_behalf_of`, but it may only search (audited) and import suggestions. Every other route is
- * person-only: a service gets 403 `capability.denied`.
+ * names the person with `on_behalf_of`, but it may only search and read the profile of a granted scope (both
+ * audited) and import suggestions. Every other route is person-only: a service gets 403 `capability.denied`.
*/
*/
function memoryRouter({ memory, memoryImport = null, auth, config, clock = { now: () => Date.now() }, log = console, sendError }) {
function memoryRouter({ memory, memoryImport = null, auth, config, clock = { now: () => Date.now() }, log = console, sendError }) {
const r = express.Router();
const r = express.Router();
@@ -936,9 +950,9 @@ function memoryRouter({ memory, memoryImport = null, auth, config, clock = { now
@@ -936,9 +950,9 @@ function memoryRouter({ memory, memoryImport = null, auth, config, clock = { now
try { res.set('Cache-Control', 'no-store').json(await fn(req)); } catch (err) { sendError(res, err, req.ov); }
try { res.set('Cache-Control', 'no-store').json(await fn(req)); } catch (err) { sendError(res, err, req.ov); }
};
};
/**
/**
- * The person's own routes. A service with the read or write capability may only search (the audited agent read)
- * and import suggestions; it may not browse, profile, patch or delete the person's memory. The guard runs after
- * `guard`, which has set req.principal.
+ * The person's own routes. A service with the read or write capability may only search and read the profile (the
+ * audited agent reads) and import suggestions; it may not browse, patch or delete the person's memory. The guard
+ * runs after `guard`, which has set req.principal.
*/
*/
function personOnly(req, res, next) {
function personOnly(req, res, next) {
if (req.principal.kind === 'service') {
if (req.principal.kind === 'service') {
@@ -952,7 +966,9 @@ function memoryRouter({ memory, memoryImport = null, auth, config, clock = { now
@@ -952,7 +966,9 @@ function memoryRouter({ memory, memoryImport = null, auth, config, clock = { now
r.patch('/api/v1/memory/items/:id', guard(WRITE_CAP), personOnly, run(async (req) => await memory.patch(req, ownerOf(req), req.params.id)));
r.patch('/api/v1/memory/items/:id', guard(WRITE_CAP), personOnly, run(async (req) => await memory.patch(req, ownerOf(req), req.params.id)));
r.delete('/api/v1/memory/items/:id', guard(WRITE_CAP), personOnly, run(async (req) => await memory.remove(req, ownerOf(req), req.params.id)));
r.delete('/api/v1/memory/items/:id', guard(WRITE_CAP), personOnly, run(async (req) => await memory.remove(req, ownerOf(req), req.params.id)));
r.delete('/api/v1/memory/items', guard(WRITE_CAP), personOnly, run(async (req) => await memory.removeBySource(req, ownerOf(req))));
r.delete('/api/v1/memory/items', guard(WRITE_CAP), personOnly, run(async (req) => await memory.removeBySource(req, ownerOf(req))));
- r.get('/api/v1/memory/profile', guard(READ_CAP), personOnly, run(async (req) => await memory.profile(req, ownerOf(req))));
+ // M4: an agent with ai.memory.read may read the profile of a scope its grant names. The profile is a summary of
+ // that scope's active notes, so it is the same grant as search, and it is audited with the ids only.
+ r.get('/api/v1/memory/profile', guard(READ_CAP), run(async (req) => await memory.profile(req, ownerOf(req))));
// M2b: the person's own read log (who read their memory, and how much of it). Person-only, like the other
// M2b: the person's own read log (who read their memory, and how much of it). Person-only, like the other
// browsing routes: a service has no business in the person's audit.
// browsing routes: a service has no business in the person's audit.
r.get('/api/v1/memory/reads', guard(READ_CAP), personOnly, run(async (req) => await memory.reads(req, ownerOf(req))));
r.get('/api/v1/memory/reads', guard(READ_CAP), personOnly, run(async (req) => await memory.reads(req, ownerOf(req))));
@@ -141,7 +141,7 @@ route('post', '/memory/items/{id}/settle', {
@@ -141,7 +141,7 @@ route('post', '/memory/items/{id}/settle', {
});
});
route('get', '/memory/profile', {
route('get', '/memory/profile', {
operationId: 'getMemoryProfile', summary: 'The cached profile for one scope', tags: ['memory'], security: memorySecurity,
operationId: 'getMemoryProfile', summary: 'The cached profile for one scope', tags: ['memory'], security: memorySecurity,
- description: 'ai.memory.read. Rebuilt by the cheap tier only when items_hash changes, at most once an hour per owner and scope; the result is stored as written.',
+ description: 'ai.memory.read. The person\u2019s own profile, or an agent\u2019s: a service token with on_behalf_of reads only a scope its grant names, and that read is audited in memory_reads with the note ids it was built from, never the text. Rebuilt by the cheap tier only when items_hash changes, at most once an hour per owner and scope; the result is stored as written.',
parameters: [query('scope', str()), query('on_behalf_of', str())],
parameters: [query('scope', str()), query('on_behalf_of', str())],
responses: { 200: json('The cached profile', memoryResult), 503: problem, ...errors([401, 403]) },
responses: { 200: json('The cached profile', memoryResult), 503: problem, ...errors([401, 403]) },
});
});
@@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
'use strict';
'use strict';
/**
/**
- * Memory M2a: the person's store in OpenVibe.AI. Sealing and AAD binding, dedupe, server-side redaction, owner scope,
- * the agent search (active only, at most 8, audited), the profile cache, delete, the 503 without a key, account data
- * and the no-plaintext-in-logs rule.
+ * Memory M2a/M4: the person's store in OpenVibe.AI. Sealing and AAD binding, dedupe, server-side redaction, owner
+ * scope, the agent search (active only, at most 8, audited) and the scoped agent profile (audited), the profile cache,
+ * delete, the 503 without a key, account data and the no-plaintext-in-logs rule.
*/
*/
const assert = require('assert');
const assert = require('assert');
const crypto = require('crypto');
const crypto = require('crypto');
@@ -187,7 +187,7 @@ t.test('an agent sees active items only, at most 8, in its granted scopes, and t
@@ -187,7 +187,7 @@ t.test('an agent sees active items only, at most 8, in its granted scopes, and t
} finally { await h.stop(); }
} finally { await h.stop(); }
});
});
-t.test('a service may only search and suggest: every other memory route is person-only', async () => {
+t.test('a service may only search, read a granted profile and suggest: every other memory route is person-only', async () => {
const h = await boot({ env: { AI_MEMORY_KEY: KEY } });
const h = await boot({ env: { AI_MEMORY_KEY: KEY } });
try {
try {
const who = userToken(A);
const who = userToken(A);
@@ -200,7 +200,6 @@ t.test('a service may only search and suggest: every other memory route is perso
@@ -200,7 +200,6 @@ t.test('a service may only search and suggest: every other memory route is perso
};
};
denied(await request(h.base, 'GET', `/api/v1/memory/items?on_behalf_of=${A}`, { tok: read }));
denied(await request(h.base, 'GET', `/api/v1/memory/items?on_behalf_of=${A}`, { tok: read }));
denied(await request(h.base, 'GET', `/api/v1/memory/items/${id}?on_behalf_of=${A}`, { tok: read }));
denied(await request(h.base, 'GET', `/api/v1/memory/items/${id}?on_behalf_of=${A}`, { tok: read }));
- denied(await request(h.base, 'GET', `/api/v1/memory/profile?scope=user&on_behalf_of=${A}`, { tok: read }));
denied(await request(h.base, 'PATCH', `/api/v1/memory/items/${id}`, { tok: serviceWrite(), body: { text: 'hijacked', on_behalf_of: A } }));
denied(await request(h.base, 'PATCH', `/api/v1/memory/items/${id}`, { tok: serviceWrite(), body: { text: 'hijacked', on_behalf_of: A } }));
denied(await request(h.base, 'DELETE', `/api/v1/memory/items/${id}?on_behalf_of=${A}`, { tok: serviceWrite() }));
denied(await request(h.base, 'DELETE', `/api/v1/memory/items/${id}?on_behalf_of=${A}`, { tok: serviceWrite() }));
denied(await request(h.base, 'DELETE', `/api/v1/memory/items?source=person&on_behalf_of=${A}`, { tok: serviceWrite() }));
denied(await request(h.base, 'DELETE', `/api/v1/memory/items?source=person&on_behalf_of=${A}`, { tok: serviceWrite() }));
@@ -218,11 +217,23 @@ t.test('a service may only search and suggest: every other memory route is perso
@@ -218,11 +217,23 @@ t.test('a service may only search and suggest: every other memory route is perso
assert.deepStrictEqual([row.status, row.source, Number(row.confidence)], ['suggested', 'codes', 0.5]);
assert.deepStrictEqual([row.status, row.source, Number(row.confidence)], ['suggested', 'codes', 0.5]);
// Search still works for a service, and it is audited.
// Search still works for a service, and it is audited.
- const search = await request(h.base, 'GET', `/api/v1/memory/search?q=active&scopes=user&on_behalf_of=${A}`, { tok: read });
+ const search = await request(h.base, 'GET', `/api/v1/memory/search?q=active&scopes=user&purpose=task-active&on_behalf_of=${A}`, { tok: read });
assert.deepStrictEqual([search.status, search.body.items.length], [200, 1]);
assert.deepStrictEqual([search.status, search.body.items.length], [200, 1]);
- const audit = await h.db.prepare('SELECT actor, item_ids FROM memory_reads WHERE owner = ?').get(`user:${A}`);
+ const audit = await h.db.prepare("SELECT actor, item_ids FROM memory_reads WHERE owner = ? AND purpose = 'task-active'").get(`user:${A}`);
assert.equal(audit.actor, 'svc:codes');
assert.equal(audit.actor, 'svc:codes');
assert.ok(!audit.item_ids.includes('active note'));
assert.ok(!audit.item_ids.includes('active note'));
+
+ // M4: the profile is the second audited agent read. A service reads only a scope its grant names, and the
+ // empty project scope keeps this test off the profile builder.
+ const projectRead = serviceRead({ memory_scopes: ['user', 'project:empty'] });
+ const prof = await request(h.base, 'GET', `/api/v1/memory/profile?scope=project:empty&purpose=profile-task&on_behalf_of=${A}`, { tok: projectRead });
+ assert.deepStrictEqual([prof.status, prof.body.profile.scope, prof.body.profile.text], [200, 'project:empty', '']);
+ const profileAudit = await h.db.prepare("SELECT actor, purpose, scopes, item_ids FROM memory_reads WHERE owner = ? AND purpose = 'profile-task'").get(`user:${A}`);
+ assert.equal(profileAudit.actor, 'svc:codes');
+ assert.deepStrictEqual(JSON.parse(profileAudit.scopes), ['project:empty']);
+ assert.deepStrictEqual(JSON.parse(profileAudit.item_ids), []);
+ const wrongScope = await request(h.base, 'GET', `/api/v1/memory/profile?scope=project:empty&on_behalf_of=${A}`, { tok: read });
+ assert.deepStrictEqual([wrongScope.status, wrongScope.body.code], [403, 'memory.scope_denied']);
} finally { await h.stop(); }
} finally { await h.stop(); }
});
});
@@ -328,6 +339,41 @@ t.test('a scope change re-seals the text, and deleting one source leaves the oth
@@ -328,6 +339,41 @@ t.test('a scope change re-seals the text, and deleting one source leaves the oth
} finally { await h.stop(); }
} finally { await h.stop(); }
});
});
+t.test('an agent profile is scoped to its grant and audited with the ids it was built from', async () => {
+ const h = await boot({ env: { AI_MEMORY_KEY: KEY } });
+ try {
+ const who = userToken(A);
+ const made = await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: who, body: { items: [
+ item('the agent profile note', { source: 'claude-code' }),
+ ] } });
+ const id = made.body.items[0].id;
+ const m = createMemory({ db: h.db, config: h.config, clock: { now: () => Date.now() }, log: h.log || console,
+ buildProfile: async ({ items }) => `A person with ${items.length} notes.` });
+ const owner = `user:${A}`;
+ const req = { principal: { kind: 'service', sub: 'svc:codes', claims: { memory_scopes: ['user'] } },
+ query: { scope: 'user', purpose: 'task-profile' }, body: {}, ov: {} };
+ const out = await m.profile(req, owner);
+ assert.equal(out.profile.text, 'A person with 1 notes.');
+ const audit = await h.db.prepare('SELECT actor, purpose, scopes, item_ids FROM memory_reads WHERE owner = ?').get(owner);
+ assert.deepStrictEqual([audit.actor, audit.purpose], ['svc:codes', 'task-profile']);
+ assert.deepStrictEqual(JSON.parse(audit.scopes), ['user']);
+ assert.deepStrictEqual(JSON.parse(audit.item_ids), [id]);
+ assert.ok(!audit.item_ids.includes('agent profile note'), 'the audit holds ids, never text');
+
+ // A scope the grant does not name is refused before a profile is built.
+ await assert.rejects(
+ () => m.profile({ ...req, query: { scope: 'project:demo' } }, owner),
+ (err) => err.status === 403 && err.code === 'memory.scope_denied',
+ );
+
+ // The person's own profile is not an audited agent read.
+ const before = Number(await h.db.value('SELECT count(*)::int FROM memory_reads WHERE owner = $1', [owner]));
+ await m.profile({ principal: { kind: 'user', subject: { type: 'user', id: A }, claims: {} }, query: { scope: 'user' }, body: {}, ov: {} }, owner);
+ const after = Number(await h.db.value('SELECT count(*)::int FROM memory_reads WHERE owner = $1', [owner]));
+ assert.equal(after, before);
+ } finally { await h.stop(); }
+});
+
t.test('the profile is cached, rebuilt only when items_hash moves and at most once an hour, and is stored as written', async () => {
t.test('the profile is cached, rebuilt only when items_hash moves and at most once an hour, and is stored as written', async () => {
const h = await boot({ env: { AI_MEMORY_KEY: KEY } });
const h = await boot({ env: { AI_MEMORY_KEY: KEY } });
try {
try {