CSCI 1730 · Fall 2026
7 OCaps
7.1 Introduction
A capability is a reference to a value (in our case, an object) that also carries authority. Because there is no ambient authority, performing operations in an object capability (OCaps) language can only be done by invoking methods of an object that has the authority (or at least simulates it).
An interesting consequence of this design is that an object can convey less authority than it has. For instance, you may be writing code that is given both read and write authority to a filesystem, but you may invoke some other (untrusted) code to which you want to give only read access and other code to which you want to give only write access. In a conventional programming language, at best you can achieve this by making various operating system-specific calls (and perhaps not even then). In an OCaps language, assuming that code hasn’t been given the authority through some other means, you can simply give it less authority by just passing it a weaker object. This is called attenuation (to reduce the force or effect of something).
Attenuating is straightforward when methods return conventional data, because data on their own cannot do anything. But sometimes a method might return another capability (object), which embodies computation. Therefore, the attenuator needs to make sure that the attenuation carries over to returned objects. This turns out to be fairly straightforward with a few elegant programming patterns.
7.2 Software
You will program in Rhombus Quasi-OCaps, #lang rhombus-quasi-ocaps. Install it from DrRacket’s File menu, with Install Package... and the URL https://github.com/shriram/rhombus-quasi-ocaps.git. The language is described in its manual.
7.3 Assignment
You will write four attenuators in wrappers.rhm. The first makes a file descriptor revocable; the second limits how many times a file descriptor can be read. The last two make a directory revocable; the first of those is deliberately naive, so you can see what the second one fixes. You will also write tests.rhm, which instantiates the capabilities, runs the attenuators, and checks what they do. Two more files are given to you, and you do not edit them: directory.rhm, which defines the capabilities, and keeper.rhm, a module that you will use in your tests.
tests.rhm is written in #lang rhombus, because check is not available in #lang rhombus-quasi-ocaps. It is also the only module that has access to real authority: it creates the directory and passes capabilities to everything else. The other three files are in #lang rhombus-quasi-ocaps and hold no authority of their own; their functions receive, as arguments, whatever tests.rhm passes them.
7.3.1 The Capabilities
directory.rhm defines two interfaces and a function:
interface ReadOnlyFileDescriptor: |
method read() :: String |
|
interface Directory: |
method open(name :: String) :: ReadOnlyFileDescriptor |
|
fun directory_containing(files :: Map) :: Directory |
A ReadOnlyFileDescriptor is the authority to read one particular file, and nothing else: it cannot write, and does not reveal which file it reads. A Directory is the authority to open, by name, any file in it, and what open hands back is one of these descriptors. Recall the difference between cat foo and cat < foo: cat foo needs a Directory to map the name to a file (and a malicious cat can do other things as well), while cat < foo is handed an anonymous read-only descriptor.
directory_containing makes a Directory from a (hash)map of file names to their contents. The stencil’s tests.rhm illustrates how this works:
def d = directory_containing({"notes.txt": "the quick brown fox"}) |
check: d.open("notes.txt").read() ~is "the quick brown fox" |
check: d.open("missing.txt") ~throws "no such file: missing.txt" |
The first two attenuators you write work on a ReadOnlyFileDescriptor, and the last two on a Directory. The difference between the two interfaces that matters here is that read returns a string, which is data, while open returns a descriptor, which is a(n object) capability.
7.3.2 Revoking a Descriptor
Suppose some code is to be given a descriptor, but only for a while. A revocable descriptor behaves like the original until it is switched off, and raises an error after that. Making one therefore produces two things: the attenuated descriptor, which goes to the code being given access, and the means of switching it off, which stays with the code that granted the access. wrappers.rhm declares a class for the pair with the following (which you should not change):
class Revocable(cap, revoke) |
|
fun revocable_file(f :: ReadOnlyFileDescriptor) :: Revocable |
revocable_file(f) returns a Revocable. The cap field is a ReadOnlyFileDescriptor that forwards all read calls to f: i.e., it behaves exactly like the original f does, so a client can use it interchangeably. However, this is only true until revoke is called. revoke is a thunk (a function of no arguments). After it has been called, calling cap.read() raises an error whose message is "revoked". Thus, in tests.rhm, we can write:
def r = revocable_file(d.open("notes.txt")) |
check: r.cap.read() ~is "the quick brown fox" |
r.revoke() |
check: r.cap.read() ~throws "revoked" |
7.3.3 Count-Limiting a Descriptor
fun limited(n :: Int) :: (ReadOnlyFileDescriptor -> ReadOnlyFileDescriptor) |
limited(n) returns a function from descriptors to descriptors. Applied to a descriptor, the function returns a descriptor that forwards the first n calls to read and, on every call after that, raises an error whose message is "no reads left":
def two = limited(2)(d.open("notes.txt")) |
check: two.read() ~is "the quick brown fox" |
check: two.read() ~is "the quick brown fox" |
check: two.read() ~throws "no reads left" |
Note again that what limited returns is a function from a descriptor to a descriptor. That is, it can take any descriptor, and its result can be used anywhere a descriptor is expected. In particular, we have another attenuator, revocable_file, that takes descriptors (and whose result’s cap field is also a descriptor). Therefore, we can compose these!
In tests.rhm, show both orders of composition. For each order, check that both attenuations still take effect. Write tests that check each combination.
Finally, because limited(n) both takes and returns a descriptor, it also composes with itself:
limited(2)(limited(5)(d.open("notes.txt"))) |
Before you run anything, write checks that reflect how many reads you expect this to allow, and a comment saying why. Then run them. For each check that fails: comment out the old expected return value; leave your comment for why you were expecting that; replace the expected return value with the new one; and write a comment that explains what was wrong with your understanding.
7.3.4 Revoking a Directory
Because a capability represents authority, so long as access to the capability remains, that authority can be used for longer than it should be allowed to. We will explore this and see how it can be fixed.
keeper.rhm is a module that holds on to whatever it is handed, as a cache would:
#lang rhombus-quasi-ocaps |
|
def kept_descriptor = Box(#false) |
|
fun keep(f): |
kept_descriptor.value := f |
|
fun leak(): |
kept_descriptor.value |
keep(f) stores the descriptor f, and leak() returns the stored descriptor, however much later it is called. Observe that the code stores the descriptor, not the file’s content string. A string the keeper had been given could not be taken back either, but it also could not do anything, whereas a descriptor potentially has continuing authority to read.
Write two attenuators for a Directory:
fun revocable_directory_naive(d :: Directory) :: Revocable |
fun revocable_directory(d :: Directory) :: Revocable |
Both return a Revocable whose cap is a Directory, and both treat open the same way: before revoke, cap.open(name) does what d.open(name) does, and after it, cap.open(name) raises an error whose message is "revoked". What revoking a directory should mean, though, is that afterwards nothing obtained through it can read: not a fresh cap.open, and not a descriptor that cap.open handed out earlier. The naive attenuator delivers only the first half of that; revocable_directory delivers both.
Write the naive one exactly the way you wrote revocable_file: open checks whether it has been revoked, forwards to d, and returns what came back. Then, in tests.rhm, open a descriptor through the attenuated directory, hand it to the keeper, revoke, and see what happens. The stencil has these checks already, and once you have written the function they pass:
def rd = revocable_directory_naive(d) |
keep(rd.cap.open("notes.txt")) |
rd.revoke() |
check: rd.cap.open("notes.txt") ~throws "revoked" |
check: leak().read() ~is "the quick brown fox" |
Revocation blocked open. However, it did not affect the descriptor that open had already returned, which the keeper holds. This is a buggy attenuator.
Your task is to write revocable_directory so that it does not have this problem. Its open must wrap the descriptor it is about to return in a revocable descriptor that the same revoke switches off, so that one call revokes the Directory and every descriptor it ever handed out. The same sequence against it must end differently:
def rd2 = revocable_directory(d) |
keep(rd2.cap.open("notes.txt")) |
rd2.revoke() |
check: rd2.cap.open("notes.txt") ~throws "revoked" |
check: leak().read() ~throws "revoked" |
The general rule, of which this is one case, is that an attenuator has to attenuate every capability that crosses its boundary, not only the object it wraps. A method that returns a capability has to wrap its result, as here, and a method that takes a capability as an argument (a callback, for instance) has to wrap its argument. This assignment asks only for the first.
7.4 Testing
Your tests go in tests.rhm, in check: form, as in the examples above. Errors match by message, so your attenuators must raise exactly the strings given above.
7.5 Starter Code
You are given:
directory.rhm, which defines ReadOnlyFileDescriptor, Directory, and directory_containing.
keeper.rhm, the module shown above.
wrappers.rhm, with Revocable and the four signatures as stubs.
tests.rhm, with the directory set up and the checks for directory.rhm and revocable_directory_naive written.
7.6 What To Hand In
wrappers.rhm and tests.rhm.