Skip to content

Commit dcefb24

Browse files
nficanoclaude
andcommitted
feat(runtime): lease gate on tool_call / delegate emission (§9.3)
`LeaseManager.AuthorizeOperation` previously had no in-runtime caller outside `AuthorizeModelUse`/tests — meaning a buggy or hostile agent could ignore the lease entirely. Spec §9.3 requires the runtime to evaluate the lease before any authority-bearing operation. - Expose `JobContext.AuthorizeOperation(namespace, pattern)` so agents building their own dispatch can call it explicitly. - Auto-gate `JobContext.ToolCallAsync` against `tool.call` and `JobContext.DelegateAsync` against `agent.delegate`, but only when the lease actually declares the namespace — leases that omit it remain permissive (matches §9.7's "MAY allow when configured" wording and preserves existing tests that don't set up tool leases). - Plumb the `LeaseManager` from `JobManager` into `JobContext` so the gate uses the runtime-configured time provider. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7d63525 commit dcefb24

4 files changed

Lines changed: 63 additions & 8 deletions

File tree

src/Arcp.Runtime/JobContext.cs

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,28 @@ public sealed class JobContext
2323
{
2424
private readonly Job _job;
2525
private readonly CredentialManager? _credentials;
26+
private readonly Arcp.Runtime.Leases.LeaseManager? _leases;
2627

27-
internal JobContext(Job job, ILogger logger, CredentialManager? credentials = null)
28+
internal JobContext(Job job, ILogger logger, CredentialManager? credentials = null,
29+
Arcp.Runtime.Leases.LeaseManager? leases = null)
2830
{
2931
_job = job;
3032
_credentials = credentials;
33+
_leases = leases;
3134
Logger = logger;
3235
}
3336

37+
/// <summary>Synchronously evaluate the job's lease for an operation under
38+
/// <paramref name="namespaceName"/> against <paramref name="pattern"/> (spec §9.3).
39+
/// Throws <see cref="PermissionDeniedException"/> on lease miss and
40+
/// <see cref="LeaseExpiredException"/> on lease expiry. Agents that build their own tool
41+
/// dispatch SHOULD call this before performing any authority-bearing operation.</summary>
42+
public void AuthorizeOperation(string namespaceName, string pattern)
43+
{
44+
var leases = _leases ?? new Arcp.Runtime.Leases.LeaseManager();
45+
leases.AuthorizeOperation(_job.Lease, _job.LeaseConstraints, namespaceName, pattern);
46+
}
47+
3448
/// <summary>Gets the job id.</summary>
3549
public JobId JobId => _job.JobId;
3650

@@ -88,14 +102,31 @@ public ValueTask RotateCredentialAsync(
88102
return _credentials.RotateAsync(_job, credentialId, next, cancellationToken);
89103
}
90104

91-
/// <summary>Tool call (asynchronous).</summary>
92-
public ValueTask ToolCallAsync(string tool, string callId, object? args, CancellationToken cancellationToken = default) =>
93-
_job.EmitEventAsync(EventKinds.ToolCall, new ToolCallBody
105+
/// <summary>Emit a <c>tool_call</c> event. If the job's lease declares <c>tool.call</c>, the
106+
/// tool name is gated against the lease patterns first (spec §9.3); a non-matching tool
107+
/// raises <see cref="PermissionDeniedException"/> before the event is emitted.</summary>
108+
public ValueTask ToolCallAsync(string tool, string callId, object? args, CancellationToken cancellationToken = default)
109+
{
110+
EnforceIfLeased(LeaseNamespaces.ToolCall, tool);
111+
return _job.EmitEventAsync(EventKinds.ToolCall, new ToolCallBody
94112
{
95113
Tool = tool,
96114
CallId = callId,
97115
Args = args is null ? null : ArcpJson.ToJsonElement(args),
98116
}, cancellationToken);
117+
}
118+
119+
/// <summary>Gate an operation when the lease declares the given namespace. Leases that omit
120+
/// the namespace remain permissive (spec §9.7 explicitly allows this when a runtime is
121+
/// configured to do so; tighter policies SHOULD call <see cref="AuthorizeOperation"/>
122+
/// directly).</summary>
123+
private void EnforceIfLeased(string namespaceName, string pattern)
124+
{
125+
if (_job.Lease.Capabilities.ContainsKey(namespaceName))
126+
{
127+
AuthorizeOperation(namespaceName, pattern);
128+
}
129+
}
99130

100131
/// <summary>Tool result (asynchronous).</summary>
101132
public ValueTask ToolResultAsync(string callId, object? result, ToolError? error = null, CancellationToken cancellationToken = default) =>
@@ -120,14 +151,18 @@ public ValueTask ArtifactRefAsync(string uri, string? contentType = null, long?
120151
Sha256 = sha256,
121152
}, cancellationToken);
122153

123-
/// <summary>Delegate (asynchronous).</summary>
124-
public ValueTask DelegateAsync(string childJobId, string agent, object? input, CancellationToken cancellationToken = default) =>
125-
_job.EmitEventAsync(EventKinds.Delegate, new DelegateBody
154+
/// <summary>Emit a <c>delegate</c> event. If the job's lease declares <c>agent.delegate</c>, the
155+
/// child agent name is gated against the lease patterns first (spec §9.3, §10).</summary>
156+
public ValueTask DelegateAsync(string childJobId, string agent, object? input, CancellationToken cancellationToken = default)
157+
{
158+
EnforceIfLeased(LeaseNamespaces.AgentDelegate, agent);
159+
return _job.EmitEventAsync(EventKinds.Delegate, new DelegateBody
126160
{
127161
ChildJobId = childJobId,
128162
Agent = agent,
129163
Input = input is null ? null : ArcpJson.ToJsonElement(input),
130164
}, cancellationToken);
165+
}
131166

132167
/// <summary>Emit a <c>progress</c> event (spec §8.2.1).</summary>
133168
public ValueTask ProgressAsync(long current, long? total = null, string? units = null, string? message = null, CancellationToken cancellationToken = default)

src/Arcp.Runtime/JobManager.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ private static JobAcceptedPayload BuildAccepted(Job job)
177177
public async Task RunAsync(Job job, IAgent agent, Func<Envelope, CancellationToken, ValueTask> emit, CancellationToken cancellationToken)
178178
{
179179
job.MarkRunning();
180-
var ctx = new JobContext(job, _loggers.CreateLogger($"Arcp.Job.{job.JobId.Value}"), _credentials);
180+
var ctx = new JobContext(job, _loggers.CreateLogger($"Arcp.Job.{job.JobId.Value}"), _credentials, _leases);
181181

182182
// Watchdog cancellation source — cancelled in `finally` so the watchdog never outlives
183183
// the job and never emits a late lease-expired event after the terminal result.

src/Arcp.Runtime/PublicAPI.Unshipped.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ Arcp.Runtime.Job.WriteChunkAsync(Arcp.Core.Ids.ResultId resultId, string! data,
9191
Arcp.Runtime.JobContext
9292
Arcp.Runtime.JobContext.Agent.get -> Arcp.Core.Agents.AgentRef
9393
Arcp.Runtime.JobContext.ArtifactRefAsync(string! uri, string? contentType = null, long? byteSize = null, string? sha256 = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask
94+
Arcp.Runtime.JobContext.AuthorizeOperation(string! namespaceName, string! pattern) -> void
9495
Arcp.Runtime.JobContext.BeginResultStream() -> Arcp.Core.Ids.ResultId
9596
Arcp.Runtime.JobContext.Budget.get -> System.Collections.Generic.IReadOnlyDictionary<string!, decimal>!
9697
Arcp.Runtime.JobContext.Cancellation.get -> System.Threading.CancellationToken

tests/Arcp.UnitTests/LeaseTests.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,23 @@ public void BudgetAmount_parse_throws_on_invalid()
7777
var act = () => BudgetAmount.Parse("not a budget");
7878
act.Should().Throw<FormatException>();
7979
}
80+
81+
[Fact]
82+
public void GlobMatch_is_permissive_for_double_star_and_strict_otherwise()
83+
{
84+
// Anchor the runtime's lease-gate behavior: when a lease declares tool.call:["calc.*"],
85+
// ctx.ToolCallAsync("calc.add", ...) is allowed but ctx.ToolCallAsync("fs.write", ...)
86+
// raises PermissionDenied (spec §9.3).
87+
var manager = new Arcp.Runtime.Leases.LeaseManager();
88+
var lease = new Lease(new Dictionary<string, IReadOnlyList<string>>
89+
{
90+
[LeaseNamespaces.ToolCall] = new[] { "calc.*" },
91+
});
92+
93+
var ok = () => manager.AuthorizeOperation(lease, null, LeaseNamespaces.ToolCall, "calc.add");
94+
var bad = () => manager.AuthorizeOperation(lease, null, LeaseNamespaces.ToolCall, "fs.write");
95+
96+
ok.Should().NotThrow();
97+
bad.Should().Throw<Arcp.Core.Errors.PermissionDeniedException>();
98+
}
8099
}

0 commit comments

Comments
 (0)