@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
159 lines • 6.52 kB
JavaScript
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { SPECS } from "../../commands/spec/index.js";
import { PolicyError } from "../../policy/errors.js";
import { headAllowed } from "../../policy/match/allow.js";
import { splitPattern } from "../../policy/match/pattern.js";
/** The command name a pattern starts with, empty for a bare wildcard. */
function head(pattern) {
const token = splitPattern(pattern)[0] ?? '';
return token === '*' ? '' : token;
}
/**
* Whether a deny pattern matches every line an ask pattern does.
*
* A pattern is a token prefix, so a shorter deny covers a longer ask
* (`git` covers `git push`) and a `*` token in the deny covers whatever
* the ask names there. The reverse never holds.
*/
function coversPattern(deny, ask) {
const want = splitPattern(deny);
const have = splitPattern(ask);
if (want.length > have.length)
return false;
return want.every((w, i) => w === '*' || w === have[i]);
}
/**
* Whether a deny rule refuses every line an ask rule asks about.
*
* Three things have to hold, and each is the reason for one arm. The
* deny must name no paths, or the ask still fires on the operands the
* deny leaves alone. It must be written at the same anchor, which is
* where deny beats ask unconditionally; across anchors the deeper rule
* leads and which one that is depends on the line, so this reports
* nothing rather than guess (a top-level deny does shadow a mount-scoped
* ask on the same command, and is deliberately left to the run). And
* every command the ask names must be covered by one of its patterns,
* since an ask naming a command the deny misses still has work to do.
*/
function shadowed(ask, deny) {
if ((deny.paths ?? []).length > 0)
return false;
if ((deny.mount ?? '') !== (ask.mount ?? ''))
return false;
const denyCommands = deny.commands ?? [];
const askCommands = ask.commands ?? [];
if (denyCommands.length === 0)
return true;
if (askCommands.length === 0)
return false;
return askCommands.every((a) => denyCommands.some((d) => coversPattern(d, a)));
}
/**
* The first rule naming a builtin the allow list never allows.
*
* A rule on a command the allow list leaves out reads as a guard and is
* not one: the allow list refuses the command first, so nothing reaches
* the rule and the operator is protected only in the document. Only a name this repo
* ships a spec for is judged, because any other word may be a CLI the
* host registers after the workspace is built, which `checkCliVerbs`
* checks at `createSession` instead.
*/
function uninstalled(rules) {
if (rules.allow === null)
return null;
for (const [verb, entries] of [
['deny', rules.deny],
['ask', rules.ask],
]) {
for (const rule of entries) {
for (const pattern of rule.commands ?? []) {
const name = head(pattern);
if (name === '' || !(name in SPECS))
continue;
if (!headAllowed(name, rules)) {
return `${verb} rule names ${name}, which the allow list never allows, so the rule can never fire`;
}
}
}
}
return null;
}
/**
* The first ask an outranking deny already refuses.
*
* Deny is read before ask at the same anchor, so an ask a deny covers
* can never be reached: the line is refused before anyone is asked, and
* the sign-off the operator wrote is never requested.
*/
function deadAsk(rules) {
for (const ask of rules.ask) {
for (const deny of rules.deny) {
if (shadowed(ask, deny)) {
const a = (ask.commands ?? []).join(', ') || '*';
const d = (deny.commands ?? []).join(', ') || '*';
return `ask rule ${a} can never fire: the deny rule ${d} refuses the same commands and outranks it`;
}
}
}
return null;
}
/**
* Refuse a document whose rules cannot behave as written.
*
* Both checks name a rule that is dead on arrival, which is worse than a
* missing rule because it reads as a guard. Raised where the document is
* compiled, so a deployment learns at startup rather than the first time
* an agent types the line the operator thought was covered.
*/
export function checkRules(rules) {
if (rules === null)
return;
for (const problem of [uninstalled(rules), deadAsk(rules)]) {
if (problem !== null)
throw new PolicyError(problem);
}
}
/**
* Refuse a rule naming a verb the CLI it names does not have.
*
* Deferred to `createSession` rather than done beside the other two,
* because a CLI is registered on the workspace after it is built: at
* compile time `git push` is just two words, and only once `git` is
* installed is there a program tree to check the verb against. A head
* word no installed CLI claims is left alone, since it may be a command,
* a function, or a CLI registered later.
*/
export function checkCliVerbs(rules, verbs) {
if (rules === null)
return;
for (const [verb, entries] of [
['deny', rules.deny],
['ask', rules.ask],
]) {
for (const rule of entries) {
for (const pattern of rule.commands ?? []) {
const tokens = splitPattern(pattern);
const cli = tokens[0];
const name = tokens[1];
if (tokens.length < 2 || cli === undefined || !verbs.has(cli))
continue;
if (name === '*' || (name !== undefined && verbs.get(cli)?.has(name) === true))
continue;
throw new PolicyError(`${verb} rule names ${pattern}, which the ${cli} CLI has no verb for, so the rule can never fire`);
}
}
}
}
//# sourceMappingURL=validate.js.map