Comment attraper un changement de permission silencieux fait par une AI avant qu'il ne parte en production ? La porte de snapshot de la matrice d'accès, mesurée sur un CRM à 7 entrées
Sur @objectstack/cli 18.2.0, os validate laisse passer allowDelete false→true sans un mot. Dans une app qui a opté, os compile fait échouer le build avec une ligne : qui gagne quoi, tant qu'un humain n'a pas régénéré le snapshot.
La réponse d’abord. os validate ne lit pas la valeur d’allowDelete : un agent qui la fait passer de false à true sur un jeu de permissions passe la validation sans la moindre remarque. C’est ce que la checklist de revue appelle « aucune règle ne se déclenche », et cela reste vrai. Il existe une porte qui se déclenche, si l’app y a opté : une matrice d’accès committée, une ligne par (jeu de permissions × objet) portant les bits effectifs create, read, edit et delete, que os compile redérive des métadonnées à chaque build et compare au snapshot. Une dérive fait échouer le build avec une ligne sémantique, 'crm_sales_user' gains delete on 'crm_opportunity', jusqu’à ce qu’un humain régénère le snapshot, et le git diff du snapshot est ce que le relecteur signe. Mesuré sur le @objectstack/cli 18.2.0 publié, la version qu’installe npm create objectstack@latest, sur l’exemple CRM fourni, dont la matrice compte sept entrées : une modification, une ligne de dérive, une ligne de JSON changée.
Le concept à retenir de cet article est la capacité effective, pas les drapeaux bruts : la matrice rapporte ce qu’un jeu de permissions peut réellement faire une fois les bits super-utilisateur repliés, avec la ligne de base des enregistrements de l’objet sur la même ligne. Le changement a toujours besoin d’un humain. Il ne peut simplement plus être silencieux.
Le changement, et les deux commandes
La cible est examples/app-crm du dépôt ObjectStack au tag @objectstack/[email protected], installé contre les paquets 18.2.0 publiés plutôt que contre le monorepo. Elle a opté pour la porte : un access-matrix.json se trouve à côté d’objectstack.config.ts. La modification est celle que l’article de revue a parcourue, un mot à la ligne 50 de src/security/sales-positions.ts :
export const SalesUserPermissionSet = definePermissionSet({
name: 'crm_sales_user',
label: 'CRM Sales User',
objects: {
crm_account: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
crm_contact: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
- crm_opportunity: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
+ crm_opportunity: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
Première porte, npx os validate. La sortie est réduite aux lignes qui comptent ; rien de ce qui est omis ne nomme allowDelete, crm_sales_user ni une matrice.
$ npx os --version
@objectstack/cli/18.2.0 linux-x64 node-v22.22.0
$ npx os validate
◆ Validate
→ Loading configuration...
→ Validating against ObjectStack Protocol...
→ Running author-time rules (53)...
→ Checking that every required capability has a provider installable in this edition...
→ Checking package docs (ADR-0046)...
✓ Validation passed (824ms)
Security: 3 Positions 2 Permissions
$ echo $?
0
Cinquante-trois règles de temps d’écriture ont tourné. Aucune n’a d’avis sur le fait que les commerciaux devraient ou non supprimer des opportunités, parce que c’est un fait sur votre entreprise et non sur les métadonnées. La phrase de l’article de revue est exacte : les deux valeurs sont valides, les deux sont applicables, le runtime appliquera celle que vous fusionnez.
Deuxième porte, npx os compile. Même configuration, mêmes 53 règles, puis une étape que validate n’a pas :
$ npx os compile
◆ Compile
→ Loading configuration...
→ Normalizing stack definition...
→ Lowering inline handlers...
→ Validating protocol compliance...
→ Running author-time rules (53)...
→ Checking that every required capability has a provider installable in this edition...
→ Checking access-matrix snapshot (ADR-0090 D6)...
✗ Access matrix drift (1 change) — capability changes must be reviewed
• 'crm_sales_user' gains delete on 'crm_opportunity'
If intended, re-run with --update-access-matrix and commit the snapshot — its diff IS the review artifact.
$ echo $?
1
os build est la même commande sous un autre nom (dans le code source de la CLI 18.2.0, Build extends Compile) et échoue avec la même ligne. Avec --json, l’échec devient une charge utile qu’une étape de CI peut lire :
{ "success": false, "error": "access matrix drift",
"changes": ["'crm_sales_user' gains delete on 'crm_opportunity'"],
"warnings": [ … ], "conversions": [] }
C’est là tout le mécanisme, et le reste de cet article porte sur ce que la ligne signifie, sur la raison pour laquelle elle dit delete alors que le code source disait allowDelete, et sur ce que la porte ne fait pas.
Ce qu’est la porte
La documentation d’ObjectStack la décrit en un paragraphe qui mérite d’être cité plutôt que paraphrasé :
AI drafts permission metadata on this platform; the one thing it must never do is silently change who can do what. The access-matrix gate makes every capability change a visible, reviewable build event: the (permission set × object) matrix is derived purely from metadata on every os compile, diffed against a committed snapshot, and any drift fails the build with a semantic explanation until a human regenerates the snapshot.
(Sur cette plateforme, l’AI rédige des métadonnées de permissions ; la seule chose qu’elle ne doit jamais faire est de changer en silence qui peut faire quoi. La porte de la matrice d’accès fait de chaque changement de capacité un événement de build visible et relisible : la matrice (jeu de permissions × objet) est dérivée purement des métadonnées à chaque os compile, comparée à un snapshot committé, et toute dérive fait échouer le build avec une explication sémantique jusqu’à ce qu’un humain régénère le snapshot.)
Trois faits à son sujet, chacun vérifié sur 18.2.0 :
- Elle est par app et optionnelle. On committe un
access-matrix.jsonà côté d’objectstack.config.ts; le premier se génère avecos compile --update-access-matrix. Pas de fichier, pas de porte : la CLI lit le chemin une fois et traite un fichier absent comme « n’a pas opté ». Les deux exemples fournis,app-crmetapp-showcase, sont livrés avec la porte activée. Un échafaudage neuf denpm create objectstack@latestne l’est pas : le modèle 18.2.0 ne contient aucunaccess-matrix.json, la première commande de l’activation vous revient donc. - Ce n’est pas l’une des 53 règles. Le code source de la CLI la classe comme « pas une règle du registre : elle lit (et, avec le drapeau, écrit) un fichier à côté de la configuration au lieu de répondre à une question sur la stack ». C’est pourquoi
validateetlintne la voient jamais : ils exécutent le registre, et la porte du snapshot habite danscompile. - Elle est petite. La matrice committée complète du CRM compte sept entrées, et sept lignes sont toutes les autorisations que l’app déclare : six objets pour
crm_sales_useret une ligne d’insertion seule pour le profil anonymeguest_portalderrière le formulaire public web-to-lead.
| Jeu de permissions | Objet | create | read | edit | delete | View All | Modify All | Ligne de base des enregistrements (OWD) |
|---|---|---|---|---|---|---|---|---|
crm_sales_user | crm_account | ✓ | ✓ | ✓ | – | – | – | public_read_write |
crm_sales_user | crm_activity | ✓ | ✓ | ✓ | – | – | – | public_read_write |
crm_sales_user | crm_contact | ✓ | ✓ | ✓ | – | – | – | public_read_write |
crm_sales_user | crm_lead | ✓ | ✓ | ✓ | – | – | – | public_read_write |
crm_sales_user | crm_opportunity | ✓ | ✓ | ✓ | – | – | – | public_read_write |
crm_sales_user | crm_opportunity_line_item | ✓ | ✓ | ✓ | – | – | – | controlled_by_parent |
guest_portal | crm_lead | ✓ | – | – | – | – | – | public_read_write |
Un relecteur qui n’a jamais ouvert le code du CRM peut lire ce tableau en moins d’une minute et dire ce que l’app permet à un utilisateur commercial. C’est tout l’intérêt de le garder comme fichier committé : le modèle de capacités de l’application est un tableau de sept lignes dans le dépôt, et toute pull request qui change le tableau doit le dire dans son diff.
Sur le disque, chaque ligne est un objet JSON. La ligne crm_opportunity, avant la modification :
{
"permissionSet": "crm_sales_user",
"object": "crm_opportunity",
"create": true,
"read": true,
"edit": true,
"delete": false,
"viewAllRecords": false,
"modifyAllRecords": false,
"sharingModel": "public_read_write"
}
Les lignes de dérive sont générées à partir d’un diff de deux tableaux de ce genre, et il existe exactement cinq formes de ligne. C’est le vocabulaire qu’un relecteur va lire, les voici donc, tirées du code source 18.2.0 de diffAccessMatrix :
| Ce qui a changé | La ligne |
|---|---|
| Une ligne a disparu | 'SET' loses ALL access to 'OBJECT' (entry removed) |
| Une ligne est apparue | 'SET' gains access to 'OBJECT' (create, read, …) |
| Un bit a basculé sur une ligne existante | 'SET' gains delete on 'OBJECT' ou 'SET' loses read on 'OBJECT' ; View All et Modify All ont la même forme |
| La profondeur de lecture ou d’écriture a bougé | 'SET' read depth on 'OBJECT': own → org |
| La ligne de base des enregistrements de l’objet a bougé | 'OBJECT' record baseline (OWD): private → public_read_write (affects every principal) |
Notre modification est la troisième forme. La ligne dit gains delete plutôt que de citer la clé, et c’est le sujet de la section suivante.
La capacité effective, pas les drapeaux bruts
Regardez de nouveau la ligne ci-dessus. Elle a une colonne delete, pas une colonne allowDelete, et la différence est délibérée : la matrice rapporte la capacité effective. Dans la spécification 18.2.0, une seule fonction décide si une permission d’objet accorde un verbe, et c’est la même fonction que la porte d’application du runtime interroge avant de répondre 403. Son repli, tiré d’objectPermissionGrants dans @objectstack/spec :
readest vrai quandallowReadest vrai, ouviewAllRecordsest vrai, oumodifyAllRecordsest vrai.edit,deleteettransfersont vrais quand leur propre bit est vrai oumodifyAllRecordsest vrai.createn’a pas de raccourci super-utilisateur : Modify All élargit edit et delete, il ne fabrique pas d’autorisation de create.exportest une conjonction,allowExportetread, de sorte qu’un jeu qui accorde l’export sur un objet qu’il ne peut pas lire n’accorde rien. (La matrice ne porte que les quatre colonnes CRUD ;transferetexportsont les autres verbes du repli, cités ici parce que le repli est l’essentiel.)
Le propre commentaire de la spécification sur la raison pour laquelle ce n’est pas permission[target] === true décrit l’écart entre déclaré et appliqué « tourné dans le sens dangereux : un prédicat qui cache une action au seul administrateur qui a le pouvoir de l’utiliser ». Une matrice construite à partir des bits allow* bruts aurait le même angle mort. La matrice interroge donc le repli, et les deux colonnes super-utilisateur restent à côté en bits bruts, parce qu’elles sont le contexte dans lequel un relecteur lit les colonnes CRUD.
La conséquence mesurable : une autre modification d’une seule ligne sur la même ligne, en remplaçant allowDelete: false par modifyAllRecords: true, produit deux lignes au lieu d’une, et la première est le delete que personne n’a tapé :
$ npx os compile
✗ Access matrix drift (2 changes) — capability changes must be reviewed
• 'crm_sales_user' gains delete on 'crm_opportunity'
• 'crm_sales_user' gains Modify All Data on 'crm_opportunity'
npx os validate sur cette modification : ✓ Validation passed (732ms). La ligne source ne dit plus rien de delete ; la matrice dit true, parce que le runtime laisserait passer la suppression, et le travail de la matrice est de dire au relecteur ce que dirait le 403, pas ce que dit le fichier source.
Une paire que la spécification n’accepte plus, et c’est la modification évidente à essayer : laisser allowDelete: false en place et ajouter modifyAllRecords: true à côté. Avant 18.2.0, cette paire se chargeait, et la matrice était la seule porte qui signalait le delete ainsi accordé. Depuis 18.2.0, la spécification la refuse avant qu’une règle ou une porte ne s’exécute, validate sort en 1 et compile en 2, avec le même message :
$ npx os validate
✗ [ { "code": "custom", "path": ["objects", "crm_opportunity", "allowDelete"],
"message": "allowDelete: false is refused beside modifyAllRecords: true since @objectstack/spec 18 (ADR-0049): Modify All Records is the write bypass for edit, delete and transfer, and the permission evaluator grants delete on the bypass bit before it reads allowDelete, so this false is never enforced. …" } ]
$ echo $?
1
C’est le repli ci-dessus, appliqué une porte plus tôt : un false que le runtime ignorerait est désormais une erreur de schéma et non un mensonge silencieux, et ce que signifie l’écriture acceptée, c’est la ligne de la matrice qui le dit.
La dernière colonne compte pour la même raison. La deuxième passe de l’article de revue vous disait de lire le sharingModel d’un objet même quand le diff n’y touche pas, parce que public_read_write sur un objet de démonstration copié signifie lecture et écriture pour toute l’organisation, toutes portes au vert. Ici la ligne de base est sur la ligne, à côté de l’autorisation, et un changement de ligne de base est une ligne de dérive à part entière, marquée affects every principal, parce qu’un basculement de l’OWD change d’un coup ce que signifie chaque ligne de cet objet.
Régénérer, puis lire le diff
Le message d’échec dit quoi faire quand le changement est voulu, et la boucle tient en trois commandes.
$ npx os compile --update-access-matrix
→ Checking that every required capability has a provider installable in this edition...
→ Access matrix snapshot written to access-matrix.json (ADR-0090 D6) — review the diff.
→ Writing artifact...
✓ Build complete (1121ms)
$ git diff -U8 access-matrix.json
@@ -46,17 +46,17 @@
"sharingModel": "public_read_write"
},
{
"permissionSet": "crm_sales_user",
"object": "crm_opportunity",
"create": true,
"read": true,
"edit": true,
- "delete": false,
+ "delete": true,
"viewAllRecords": false,
"modifyAllRecords": false,
"sharingModel": "public_read_write"
},
$ npx os compile
→ Checking access-matrix snapshot (ADR-0090 D6)...
✓ Build complete (702ms)
Deux notes pratiques tirées de l’exécution. Le drapeau réécrit tout le fichier sans condition ; après une régénération, comparez donc le nombre de hunks dans access-matrix.json au nombre de lignes sémantiques imprimées par le compile en échec : une ligne, un hunk, et tout ce qui dépasse est un changement que vous n’avez pas demandé. Et lisez le hunk avec -U8 plutôt qu’avec les trois lignes de contexte par défaut. Les clés permissionSet et object de la ligne se trouvent cinq et six lignes au-dessus du bit modifié ; un git diff par défaut vous montre "delete": false → true avec le nom de la ligne coupé, et la ligne sémantique du compile devient alors la seule chose qui vous dise de qui était ce delete.
La pull request porte maintenant deux hunks qui disent la même chose dans deux vocabulaires : la bascule du code source écrite par un agent, et la ligne de capacité régénérée par un humain. La formule de la documentation pour la seconde est la bonne, et c’est la phrase que cet article existe pour mesurer : « its diff IS the review artifact », son diff EST l’artefact de revue.
Sa place parmi les autres défenses
La porte est l’un de trois mécanismes bâtis sur les mêmes autorisations structurées, et chacun attrape quelque chose que les autres ne peuvent pas.
| Mécanisme | Ce qu’il attrape | Quand il tourne | Ce qu’il fait |
|---|---|---|---|
| Linter de posture de sécurité (ADR-0090 D7) | une déclaration dangereuse en elle-même : un OWD non défini, une suggestion d’ancre à haut privilège, un alias retiré | validate, build, lint, comme règles du registre | fait échouer le build avec le message de la règle |
| Porte de snapshot de la matrice d’accès (ADR-0090 D6) | un changement d’apparence légitime dont un humain doit tout de même signer le rayon d’impact | compile et build, quand l’app a opté | fait échouer le build avec des lignes sémantiques jusqu’à régénération du snapshot |
| Moteur d’explication | pourquoi une requête d’un principal a été tranchée ainsi | au runtime, par décision | répond à la question, n’applique rien de nouveau |
La documentation résume le lien entre les deux derniers en une phrase : la matrice « is the same evaluation run over representative (permission set × object) pairs; explain is the per-decision zoom lens », c’est la même évaluation exécutée sur des paires (jeu de permissions × objet) représentatives ; explain est la loupe par décision. Et la raison pour laquelle les trois existent est la même : les autorisations sont des données structurées à vocabulaire fermé, une étape de build peut donc en dériver un tableau. Un modèle de permissions écrit en prédicats libres dans le code applicatif n’a aucun tableau à comparer.
Ce que la porte ne fait pas
Cinq limites, dites franchement, parce que la phrase « le build attrape les changements de permissions » est de celles qu’on répète sans leurs conditions.
- Elle est optionnelle, et désactivée par défaut. Un échafaudage 18.2.0 neuf n’a pas de snapshot, donc pas de porte. L’activation tient en une commande et un fichier committé, et la pull request qui ajoute ce fichier est celle à relire avec le plus de soin : ce snapshot est la ligne de base contre laquelle chaque diff ultérieur sera lu.
- Elle tourne dans
compileetbuild, jamais dansvalidatenilint. Mesuré plus haut : la même modification,validateen exit 0 sans mention de la matrice,compileen exit 1. Une chaîne de CI qui exécuteos validateet déclare l’app vérifiée n’a pas exécuté cette porte. - Elle rend le changement visible, pas impossible.
--update-access-matrixest un drapeau, et un agent peut l’exécuter et committer lui-même le nouveau snapshot. Ce que l’agent ne peut pas faire, c’est faire disparaître le changement de capacité de la pull request : la ligne est dans le diff, qu’un humain ou un modèle l’ait régénérée. Le troisième échelon de l’échelle de revue, est-ce correct ?, reste celui du relecteur, et l’apport de la porte est que la question arrive désormais avec la forme de la réponse sur une seule ligne. - Son périmètre est (jeu de permissions × objet). Create, read, edit, delete, les deux bits super-utilisateur, la profondeur de lecture et d’écriture, et la ligne de base de l’objet. Pas la sécurité au niveau des champs, pas les règles de partage, pas les prédicats au niveau des lignes, pas quels utilisateurs détiennent le jeu (ce sont des données de runtime, comme le disent déjà les limites de l’article de revue), et pas ce que fait le corps d’une action une fois qu’il s’exécute.
- Pour une bascule d’un mot, le diff du code source la montre déjà. C’est l’objection légitime :
allowDelete: false → trueest visible danssales-positions.tspour quiconque lit le diff. La matrice gagne sa place là où le changement de code et le changement de capacité n’ont pas la même forme. UnmodifyAllRecords: trueest une ligne de code et deux lignes de capacité. Une modification desharingModeldans un fichier d’objet est une ligne de code et une ligne sur la ligne de chaque jeu pour cet objet. Un modèle de permissions assemblé sur plusieurs fichiers aboutit tout de même à un seul tableau trié qu’un relecteur peut lire sans en ouvrir aucun. La valeur de la porte n’est pas de voir la bascule. C’est de la voir dans le vocabulaire que le runtime applique.
Les deux lignes à ajouter à la checklist
L’article de revue se terminait par une checklist. La porte ajoute une ligne à sa deuxième passe et une question à la première.
[ ] Which door? If the pipeline ran only os validate, the access-matrix gate did not run.
[ ] Pass 2 — permissions:
[ ] access-matrix.json is committed (opt in once: npx os compile --update-access-matrix)
[ ] on drift: one semantic line per hunk in access-matrix.json, and the PR says why
[ ] read each hunk with -U8, so the permissionSet and object are in it
[ ] for every "gains" line, say who gains what, and read the OWD on the same row
Pointez votre agent vers une cible dont les autorisations sont un tableau
Un agent peut générer un modèle de permissions dans n’importe quelle stack. Ce qu’il ne peut pas faire dans la plupart d’entre elles, c’est vous remettre un tableau de sept lignes que le build redérive et compare à chaque exécution, parce que dans la plupart des stacks le modèle de permissions est éparpillé entre des middlewares, des filtres de requête et un ou deux templates, et qu’il n’y a aucun tableau à comparer. La porte ci-dessus existe parce que, dans ObjectStack, les autorisations sont des métadonnées typées à vocabulaire fermé, la propriété même qui rend le diff relisible et empêche une permission déclarée de n’être qu’un commentaire. Si vous voulez l’argument sur l’endroit où l’application elle-même s’exécute, il est dans où les permissions des agents AI sont appliquées.
Pointez donc votre agent vers cette cible, avec un fichier de règles ou directement avec la spécification et la surface MCP, et faites du snapshot la première chose que vous committez :
npm create objectstack@latest my-app && cd my-app
# ouvrez-le dans votre agent de code et décrivez ce dont le métier a besoin
npx os compile --update-access-matrix # le premier snapshot : relisez-le, committez-le
npx os compile # en CI, à chaque exécution ; os validate seul n'exécute pas la porte
À partir de là, un agent est libre de rédiger des autorisations, et la seule chose qu’il ne peut pas faire est de changer qui peut faire quoi sans une ligne dans la sortie du build et une ligne dans le diff.
Pour l’exécuter, le dépôt ObjectStack fournit ObjectOS Community — l’image ghcr.io/objectstack-ai/objectstack, os start et la Console, gratuite et auto-hébergée — tandis qu’ObjectOS Cloud est l’édition hébergée et ObjectOS Enterprise l’édition autogérée, sous licence.