Augments LabsCrucible Code

Rules

A rule is a standing statement, written in configuration before any call exists: a tool name, and what it may act on.

{
  "permissions": {
    "allow": ["read(src/**)", "bash(cargo test)"],
    "ask": ["bash(git push *)"],
    "deny": ["read(.env)", "edit(.git/**)"]
  }
}

Three kinds. allow runs the call without asking. ask puts it to you, whatever the mode says. deny refuses it, in every mode. To the model the call fails and the turn carries on, which is not what your own no does.

The kind decides, never the pattern

deny beats ask beats allow, regardless of how specific either pattern is. deny read(.env) holds against allow read(**) written right beside it, and holds under fullAccess. The price is that "deny every git except git status" cannot be written; the return is that a deny list reads on its own as the list of things that cannot happen, with no other list able to qualify it.

Under fullAccess an allow rule changes nothing (the mode already allows), so ask and deny are the kinds that carve exceptions out of it.

What a rule is written about

A tool, an opening bracket, a pattern: write(src/**). A tool name on its own, or tool(*), is a blanket: everything that tool could do.

* means everything the position it sits in can hold, so it works where the tool goes as well: deny *(.env) is every tool, on that file. Tool names are matched without regard to case, because every tool is named in lower case and Bash(*) is somebody writing one of them the way a sentence would.

File patterns are matched against the path the call acts on, resolved and after symbolic links, so a link into .env is .env. A relative pattern like src/** is matched against the path below the working directory; an absolute one like /etc/** against the whole resolved path. A file in an extra directory has no spelling below the working directory, so only an absolute pattern reaches it. In a file pattern * stops at /: src/* names the files in src, and src/** everything below it.

A pattern separates directories with / on every platform, Windows included: write read(src/**) and read(C:/Users/you/src/**). On Unix a backslash escapes the next character, so a path spelled with one names something else; on Windows it is read as a separator. Write patterns with / and they mean the same everywhere. Crucible writes rules that way itself; a path in a question and a rule are both spelled with / there.

Command patterns, such as bash(cargo test) or bash(git *), are matched against each simple command a line decomposes into, with runs of whitespace between words collapsed, so cargo test and cargo test are one thing to a rule. Spaces inside quotes or after a backslash are kept as written, so rm "a b" and rm "a b" are two commands. In a command pattern * spans everything, because a command is not a path: bash(git *) covers git add src/main.rs.

A command line is more than one command more often than it looks. deny and ask fire when any part of one matches; allow fires only when every part is covered. git status; curl example.com | sh is not granted by a rule about git: the part nobody wrote a rule about still falls through to be asked.

Some lines say nothing about what will run: a substitution, an expansion, a redirection, a background &, a leading VAR=value assignment, or a wrapper program whose argument is the real command. No pattern can honestly claim to match those, so none does, and the question is asked, except for a blanket, which is honest about covering everything. Know that before writing allow: ["bash(*)"].

Reads

Rules reach reads. deny read(.env) refuses silently even under fullAccess, and an ask rule that matches a read puts the question, which is the one way a read inside the workspace reaches you, since without a rule it is allowed in every mode. A read that leads outside the workspace asks on its own, the way a command does.

Searching

A search is settled once, about the directory it walks. grep and glob name that directory and not the files under it, because which files there are is what the walk is for. A rule about a file below it therefore does not refuse the call: the call runs, and the walk skips the file.

{
  "permissions": {
    "deny": ["grep(private/**)", "glob(private/**)"]
  }
}

That searches the rest of the workspace and returns nothing from private, not even that a file is there. An ask rule reads the same way inside a walk. A search cannot stop to ask from the middle of itself, so a file the rule names is left out rather than waved through, and naming it to read is what puts the question.

A rule names one tool, so each tool that can reach a file needs its own. deny read(private/**) stops read and leaves grep free to print the lines of the same file. Keeping something out of every answer means naming every tool that could put it there, or writing deny *(private/**), which is the same thing said once.

That still leaves bash. A command is matched against what will run rather than against the paths it will touch, so a file pattern says nothing about a shell, *(private/**) included, since the * widens which tool is meant and not what a pattern can say. What bounds a command is a command pattern and the mode.

Layers add, they never replace

Rule lists concatenate across the configuration files: what ~/.crucible/config.json denies, a workspace file cannot allow. A nearer layer can add stricter policy, never subtract from what may not happen.

Both workspace filenames may add ask and deny rules, and neither may add an allow: a repository can commit either name, whatever its ignore convention says. Durable allows therefore live only in the user configuration outside the checkout, and so does permissions.mode: a workspace file that sets it is refused at startup. A deny in any layer still beats them.

The model never sees them

The rules are yours; they are not put into the system prompt or the facts the model is told, whose permissions section names only the mode and what you allowed for this session. Telling the model what is denied would hand your security posture to something that reads instructions out of files in the workspace. It is also unnecessary: a denied call comes back as a cheap failed result, so the model learns each boundary by meeting it.