Skip to content

Harden profile ceilings, task-token scoping, and result evidence - #40

Merged
somoore merged 5 commits into
mainfrom
harden/policy-ceilings-and-evidence
Jul 31, 2026
Merged

somoore merged 5 commits into
mainfrom
harden/policy-ceilings-and-evidence

Conversation

@somoore

@somoore somoore commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Summary

Five commits of security hardening and evidence-integrity fixes across policy, the gateway, and the result pipeline:

  • Profile ceilings are now strictly a cap. Config load rejects a ceiling that outranks the profile it caps (equal-rank narrowing stays legal), checkProfile re-checks rank and runner constraints against the ceiling at request time, and callers dispatch Decision.EffectiveRunner so the executed runner belongs to the profile policy actually granted. The require_explicit/require_lease invariant now keys on capabilities rather than profile name, so a custom-named profile granting high-risk capabilities cannot skip it.
  • Per-task tokens are scoped to their own delegation. The five task-scoped read/cancel paths (status/result/transcript/artifact/cancel) now authorize against the token's origin session, list_tasks is filtered to the token's session, and delegate derives the session only from the token's stored context — closing a hole where a subdelegating worker's runner could read or cancel unrelated tasks, or mint children under a session of its choosing.
  • Evidence integrity. commitGit fails the task on DiffStat/ChangedFiles errors instead of silently reporting a code change with no diff; a run that both overflows output limits and exits non-zero surfaces as worker_failed (failure outranks truncation) with truncation still carried through the result envelope, task mirror, and result.v1.json.
  • Hot reload actually reloads. policy.Engine.Reload swaps config in place under a mutex so the mesh RPC server sees repaired peer trust; repair_peers no longer claims a restart is required (or success it couldn't deliver).
  • Hygiene. Audit dir 0700; SQLite db chmod'd before first migrations; expired/revoked leases pruned after 30 days; case-insensitive cycle detection on the inbound path; docs/CLAUDE.md corrections so documented example configs validate again.

Test plan

  • make test (fmt-check, mod-check, supply-chain, workflow-lint, dup, vuln, unit, build) green in CI
  • Focused unit tests added alongside each policy/gateway/runner change

somoore added 5 commits July 31, 2026 13:13
profile_ceilings remapped the effective profile unconditionally, with no
check that the ceiling was weaker. A mapping pointing at a stronger profile
silently escalated every request naming the keyed profile, and the runner
that executed was resolved from the requested profile name, so it could
belong to a profile the worker never enforced.

- Reject escalating and undefined ceilings at config load; keep rank <= so
  an equal-risk cap (the documented system-change -> repo-dev) still loads.
- Re-run the runner checks against the ceiling in checkProfile, and expose
  Decision.EffectiveRunner so the mesh server dispatches the runner that
  belongs to the profile policy actually granted.
- Move the require_explicit/require_lease invariant into ValidateSemantics
  keyed on capabilities instead of profile name, so a custom name granting
  edit_repo can no longer skip it. Validate the normalized capability set so
  legacy claims-only profiles are covered too.
- Convert ValidateSemantics to a params struct; four adjacent bools made
  positional call sites an ordering hazard.
- Add require_lease to the profile_validate request structs, tool schemas,
  and the profile validate CLI flags so callers can satisfy the invariant.
A per-task token authenticated a worker's runner reaching back into the
gateway during subdelegation, but the five task-scoped read/cancel paths
never consulted the token's provenance -- only delegate did. A worker's
runner, whose output the design treats as untrusted, could therefore read
the result, transcript, or artifact of, or cancel, any task this origin had
delegated.

Both the task context file backing a token and peer_tasks already carry
origin_session_id, so the token's session is the ownership key: it covers
the token's own task and anything that task delegated onward, and nothing
else. No migration needed.

- Gate status/result/transcript/artifact/cancel through authorizeTaskAccess.
- Filter list_tasks to the token's session and withhold local task history.
- Leave host-token callers unscoped; that is this machine's own agent, and
  origin-restart recovery depends on those lookups reaching every task.
- Tasks predating this scoping have no mirrored session and stay reachable
  only by the host token.
… repairs

Three evidence-integrity fixes:

- commitGit discarded DiffStat and ChangedFiles errors, so a git failure
  produced an empty diffstat and empty file list -- and because the bundle
  is only built when files are non-empty, a real code change could be
  reported with no diff and no artifact. Both errors now fail the task.

- RunProgram checked truncation before exit status, so a run that both
  overflowed the output limit and exited non-zero surfaced as
  result_too_large and masked worker_failed. Failure now wins, and
  truncation is reported on CompletedProcess so it is still visible.

- repair_peers rewrote peer trust in the config but returned
  requires_daemon_restart: true, because reloadConfig replaced only the
  gateway's engine pointer while the mesh RPC server -- which enforces
  inbound peer policy -- kept the old one. policy.Engine.Reload now swaps
  config in place under a mutex so every holder of the shared engine sees
  it, and repair_peers reports the reload outcome honestly instead of
  claiming success it could not deliver.
Docs:
- CLAUDE.md said the gateway exposes ~41 MCP tools; it is 31 (plus 22 on
  the stdio setup surface), verified against both the advertised lists and
  the dispatch switches.
- Reword the high-risk profile invariant to match the capability-keyed
  enforcement, and document the ceiling-as-cap rule and task-token scoping.
- docs/configuration.md's minimal-shape [profiles.dev] granted edit_repo
  with neither flag, so the documented config no longer validated. Fixed,
  along with the ceilings paragraph and the ladder's name-based framing.
- repair_peers docs no longer tell users a restart is required.

Hygiene:
- Audit directory is 0700, matching the store; the log file was already
  0600 but sat in a 0755 directory.
- Chmod the SQLite db before migrations run, closing the window where task
  history was world-readable during the first writes. The -wal/-shm
  sidecars do not exist until then, so they stay in the post-migration
  pass, which already tolerates a missing file.
- Prune leases that expired or were revoked longer than 30 days ago; the
  file was append-only and grew without bound. Validation still refuses an
  expired or revoked lease, so pruning cannot turn a denial into an accept.
- Cycle detection in CheckInbound is case-insensitive, matching
  validateDelegationChain on the mesh RPC path.
- Unify StdoutRef/StderrRef on the log: prefix via an exported
  runner.LogRef; the shell adapter emitted a bare path.
- Factor the shared task-read prologue into fetchOwnedTask so the ownership
  check cannot be omitted from a future read path.
Follow-ups from review of the preceding commits:

- CommandOutput.Truncated now carries output-limit truncation through the
  result envelope, the task mirror, and result.v1.json (whose
  additionalProperties:false would otherwise reject the field). Marking the
  process struct alone left the origin unable to tell that a worker_failed
  task also had incomplete stdout/stderr -- the half of the truncation fix
  that actually reaches the caller.

- delegate derived the origin session from the request body before falling
  back to token provenance. Since that session is now the ownership key for
  task-token reads, a worker's runner could mint children under a session of
  its choosing and later read that session's tasks. The session now comes
  only from the token's stored context when one is present, and a token
  whose context carries no session is refused rather than falling through to
  the shared gateway-default session.

- docs/configuration.md's [profiles.interactive] example granted four
  high-risk capabilities with require_explicit but no require_lease, so the
  documented config no longer validated.

Verified, no change needed: a profile allowed by a hot-reloaded policy whose
runner is absent from the startup-built registry fails cleanly with
runner_unavailable; and revRange substitutes HEAD for an empty base, so
failing on DiffStat errors cannot break a task in a repo without commits.
@somoore
somoore force-pushed the harden/policy-ceilings-and-evidence branch from 25b6d2c to 5082747 Compare July 31, 2026 18:13
@somoore
somoore merged commit 9943498 into main Jul 31, 2026
4 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 25b6d2c892

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/mcp/gateway/gateway.go Resolved
Comment thread internal/config/config.go Resolved
Comment thread internal/runner/exec.go Resolved
Comment thread internal/policy/leases.go Resolved
Comment thread internal/mcp/gateway/gateway.go Resolved
Comment thread internal/mcp/gateway/gateway.go Resolved
Comment thread internal/policy/policy.go Resolved
@somoore
somoore deleted the harden/policy-ceilings-and-evidence branch July 31, 2026 18:18
somoore added a commit that referenced this pull request Oct 28, 2026
…rungs

The ceiling subset check rejected capping an agent profile such as repo-dev
or system-change to query, because no agent profile lists the probe
capabilities. A config using such a cap would then fail to load. Unrestricted
shell already covers those read-only inspection commands, so treat them as
implied by shell=allow. A shell=deny profile such as readonly-agent still
cannot cap to them.

Addresses review feedback on #40.
somoore added a commit that referenced this pull request Oct 28, 2026
somoore added a commit that referenced this pull request Oct 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant