AboutExpertiseWorkR&DBlogToolsStartContact

A Copilot agent manifest is a security boundary. Here is the linter for it.

Every scoping property in a Microsoft 365 Copilot agent manifest is optional, and in six places omitting one grants the widest scope instead of the narrowest. A JSON Schema validator approves those manifests, because nothing in them is invalid: something optional is missing. So we wrote the check that does catch it. It finds six problems in a manifest that passes every other test.

pH7x Systems® · · 6 min read

A declarative agent for Microsoft 365 Copilot is not code. It is a JSON manifest that tells the model what it is for, what it may read and what it may do. Copilot supplies the model, the orchestration and the permission trimming. You supply the boundaries.

Almost every property that sets those boundaries is optional. In six places, leaving one out does not narrow the agent. It grants the widest scope available.

The line that grants everything

A manifest needs three fields: name, description and instructions. Everything after that is capabilities, and this is the one that matters most.

json
{
  "capabilities": [
    {
      "name": "OneDriveAndSharePoint",
      "items_by_url": [
        { "url": "https://tenant.sharepoint.com/sites/Tenders/Documents/Live" }
      ]
    }
  ]
}

Now delete the items_by_url array. The documentation is explicit: omit both items_by_url and items_by_sharepoint_ids and the agent can access every OneDrive and SharePoint source in the organisation.

Not fewer. All of them. And the broken version does not break. It produces an agent that works, answers questions, and grounds them in every site the person chatting with it happens to have access to.

Copilot still trims by that person's permissions, so the agent cannot read what they could not open themselves. That guarantee is real, and we wrote about the same boundary in the AI assistant that only reads what the user can read. But trimming is not scope. Someone with wide access now has a tender agent answering from HR files and board minutes, because nothing told it not to.

The same rule appears six times:

Capability Omit this And the agent gets
OneDriveAndSharePoint items_by_url, items_by_sharepoint_ids Every SharePoint and OneDrive source in the organisation
GraphConnectors connections Every Copilot connector in the organisation
TeamsMessages urls Every channel, meeting and chat
Meetings items_by_id Every meeting
WebSearch sites The open web
Email folders The whole mailbox

This is a defensible design decision. An agent that could read nothing by default would be useless out of the box. But it inverts the habit of anyone who writes security configuration, where an omitted rule usually means denied. Here, silence means yes.

The trap that survives knowing the pattern

Schema 1.8 added two write capabilities, EmailActions and MeetingActions. The first covers triage, supervised send, delete, inbox rules, auto reply and folder management.

The documentation states that EmailActions operates independently of Email, and that scope restrictions configured on Email, including folders, shared_mailbox and group_mailboxes, do not apply to it. So this manifest does not do what its author intended:

json
{
  "capabilities": [
    { "name": "Email", "folders": [ { "folder_id": "inbox" } ] },
    { "name": "EmailActions" }
  ]
}

Reading is limited to the inbox. Deleting, moving and creating inbox rules is not limited at all. There is no way to scope EmailActions: the object has exactly one property, its name. The decision is binary and should be made deliberately.

That manifest is the work of somebody who was being careful. Careful in the wrong place produces a file that reads as cautious and is not.

The check that catches it

A JSON Schema validator approves every broken manifest above, because nothing in them is invalid. Something optional is missing, which is a different thing and the only thing that matters here.

So the review has to look for absence, and that is mechanical work, which means it should not be done by a person reading carefully at five in the afternoon.

python
# Each capability, the properties that scope it, and what happens without them.
SCOPE = {
    "OneDriveAndSharePoint": (("items_by_url", "items_by_sharepoint_ids"),
                              "every SharePoint and OneDrive source"),
    "GraphConnectors": (("connections",), "every Copilot connector"),
    "TeamsMessages":   (("urls",),        "every channel, meeting and chat"),
    "Meetings":        (("items_by_id",), "every meeting"),
    "WebSearch":       (("sites",),       "the open web"),
    "Email":           (("folders", "shared_mailbox", "group_mailboxes"),
                        "the whole mailbox"),
}
# Write capabilities. There is no way to scope them: the choice is binary.
WRITES = {"EmailActions": "delete, move and create inbox rules",
          "MeetingActions": "book meetings and change the calendar"}

def check(manifest: dict) -> list[tuple[str, str]]:
    found = []
    caps = {c.get("name"): c for c in manifest.get("capabilities", [])
            if isinstance(c, dict)}

    for name, cap in caps.items():
        if name in SCOPE:
            props, everything = SCOPE[name]
            if not any(cap.get(p) for p in props):
                found.append(("OPEN", f"{name} without {' or '.join(props)}:"
                                      f" reads {everything}"))
        if name in WRITES:
            found.append(("WRITES", f"{name} can {WRITES[name]}"))

    # Email is scoped, EmailActions is not, and the file gives no sign of it.
    if "EmailActions" in caps and any(caps.get("Email", {}).get(p)
                                      for p in SCOPE["Email"][0]):
        found.append(("TRAP", "Email is scoped but EmailActions ignores that limit"))

    if not manifest.get("behavior_overrides", {}).get(
            "special_instructions", {}).get("discourage_model_knowledge"):
        found.append(("INVENTS", "answers from model knowledge when sources are silent"))
    if not manifest.get("disclaimer", {}).get("text"):
        found.append(("NO NOTICE", "the reader is not told where answers come from"))
    return found

Wrap that in twenty lines that read a file and exit non zero, and run it against a manifest every other tool approves:

bash
$ python check_agent.py tender-desk.json
Tender Desk  schema v1.8
  OPEN      OneDriveAndSharePoint without items_by_url or items_by_sharepoint_ids:
            reads every SharePoint and OneDrive source
  WRITES    EmailActions can delete, move and create inbox rules
  OPEN      WebSearch without sites: reads the open web
  TRAP      Email is scoped but EmailActions ignores that limit
  INVENTS   answers from model knowledge when sources are silent
  NO NOTICE the reader is not told where answers come from

6 to review
$ echo $?
1

Scope the same agent properly and it goes quiet and exits zero, which is what lets it sit in front of a deployment rather than in a document nobody opens.

The TRAP line is the one worth the whole script. It fires only when somebody has scoped Email and then added EmailActions beside it, which is precisely the manifest a careful person writes.

We ran it against a realistic manifest for our own tenant, with the SharePoint site properly scoped, and it still had something to say: TeamsMessages had been added without a urls array, so an agent meant for one document library could read every channel and chat its user belongs to. Nobody decided that. It was a line that was never there.

What it does not catch

Worth saying plainly, because a check that overstates its coverage is worse than none.

It reads a file. It does not know whether the SharePoint URL points at a folder with twelve documents or a site with forty thousand, and that difference matters more than anything the script measures. It cannot tell whether the instructions are any good. And it says nothing about whether the people using the agent should have access to that content at all, which is a permissions question that predates the agent and outlives it.

What it removes is the part of the review a person is worst at: noticing that something is not there.

Why this is worth the care

The appeal of a declarative agent is that there is no code to maintain. A manifest, a few knowledge sources, and something genuinely useful for a team that would never have got a custom application built for them.

That is real, and it is also why scope matters more here than in an application. Nobody reviews a JSON file the way they review a pull request. There is no test suite. There is no compiler to tell you that the array you left out means the whole tenant.

Now there is at least a script.

Keep reading