feat(qwen,goose): record delegated fs I/O and gate it with result-phase policy - #1222
Conversation
|
@serena-ruan this finishes the file-I/O recording / content-policy follow-up you left in QWEN_FOLLOWUPS.md after #1100, in both qwen and goose. Would you be up for reviewing? Two design calls are flagged in the description: writes are gated before the write happens, and result-phase policy is content-only here since the harness round-trip carries no request_data. Happy to adjust either. |
|
Heads up on the red |
|
Status now that the run finished: the remaining red checks are not from this change.
Rebasing on the latest main to re-trigger CI. |
e75c92f to
222d8e4
Compare
|
Thanks for tackling a real observability and policy gap, and for the thorough Qwen/Goose test coverage. I see two blockers in the current design:
The duplicated Qwen/Goose implementation is a secondary maintainability concern; a shared helper would reduce semantic drift. The underlying feature is worthwhile, but I do not think we should establish the current phase behavior as the policy contract. |
|
Thanks PattaraS, fair read and I agree with both.
One split I'd like to confirm on reads specifically: a read's content doesn't exist until the read runs, so the pre-op call can carry name and path but not content. I'd gate the read at PHASE_TOOL_CALL on name/path, and keep the content check on the returned bytes at the result phase but framed as advisory (whether content reaches the model), not as a side-effect gate. Does that division work for you, or would you prefer reads handled differently?
I'll push the revision. Thanks for the careful review. |
|
Pushed the revision. Both blockers are addressed:
On the duplication: a clean shared helper needs consolidating the ACP error types and helpers, which are now triplicated across qwen, goose, and the acp_executor added in #2152. I kept this PR focused on the two correctness fixes rather than bundle that refactor with a security change, and I am happy to do the shared-helper extraction as a follow-up that covers all three uniformly. Let me know if you would rather have it in here. |
|
Thanks for the revision. The call-phase and stale-event parts look addressed, but I may still be missing the post-write result phase. At the latest head, both write handlers appear to run Could you please add that post-write |
|
Hey good point, I added it now. Both write handlers run I also renamed New test in both harnesses covers the payload and the deny path. Both fail on the old head. |
b1592f7 to
32ea159
Compare
|
Closing this PR because it has been labeled The label was last applied on 2026-07-17T09:26:33Z. If you are ready to continue, please reopen this PR or open a new one. |
32ea159 to
d721eb1
Compare
…se policy
Omnigent's OSEnvironment but left two layers as documented follow-ups: the
delegated I/O was invisible in history and no content policy ran on it.
Wire both onto the existing _handle_fs_read / _handle_fs_write handlers:
- emit a paired ToolCallRequest + ToolCallComplete per op so the I/O shows in
history (the adapter renders them as observed function_call items)
- run PHASE_TOOL_RESULT content policy on the bytes; an explicit deny refuses
the op (a write is gated before it happens), failing open otherwise
Content-only: the harness policy round-trip carries no request_data, so the
payload is {"result": content}. Closes the file-I/O recording / content policy
item in docs/QWEN_FOLLOWUPS.md.
Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com>
Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
Addresses the review on the delegated-fs recording/policy work. 1. Phase semantics. A delegated write was gated by a result-phase policy eval before the write, which is content-only and fails open, so a policy timeout would let the write through. Gate writes (and reads) at PHASE_TOOL_CALL with the tool name, path, and content, failing closed on an eval error or an ASK verdict (delegated fs has no elicitation path). Reads keep the result-phase content check that decides whether the read bytes reach the model. 2. Audit records. Stale prior-turn server fs requests were answered at turn start, running real I/O, and then had their ToolCall events cleared before they reached history. Drain those events into history instead of dropping them, so the I/O they performed is recorded. Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com> Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
The write handlers gated at PHASE_TOOL_CALL and then wrote, but never ran a result-phase evaluation, so the value env.write() returned was never policy checked and the audit record dropped it. Reads already did both phases. Run PHASE_TOOL_RESULT after the write carrying the actual result. A denial records BLOCKED and refuses the response; it cannot undo the write, since it runs after the operation. The success record now carries the real result too, matching the read path. _fs_content_policy_denies was read-specific, so it is now _fs_result_policy_denies and takes any result. Read behavior is unchanged. Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com> Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
d721eb1 to
e119a51
Compare
|
/review |
|
🏷️ Doc impact: Internal changes to the goose/qwen ACP executors adding audit-event recording and policy gating for delegated fs ops, plus tests and an internal followups note — no user-facing surface, integration, or built-in policy changed. Auto-classified on merge. Set the label manually before merging to override. · run |
|
…se policy (omnigent-ai#1222) * feat(qwen,goose): record delegated fs I/O and gate it with result-phase policy Omnigent's OSEnvironment but left two layers as documented follow-ups: the delegated I/O was invisible in history and no content policy ran on it. Wire both onto the existing _handle_fs_read / _handle_fs_write handlers: - emit a paired ToolCallRequest + ToolCallComplete per op so the I/O shows in history (the adapter renders them as observed function_call items) - run PHASE_TOOL_RESULT content policy on the bytes; an explicit deny refuses the op (a write is gated before it happens), failing open otherwise Content-only: the harness policy round-trip carries no request_data, so the payload is {"result": content}. Closes the file-I/O recording / content policy item in docs/QWEN_FOLLOWUPS.md. Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com> Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com> * fix(qwen,goose): gate delegated fs at the call phase and audit stale ops Addresses the review on the delegated-fs recording/policy work. 1. Phase semantics. A delegated write was gated by a result-phase policy eval before the write, which is content-only and fails open, so a policy timeout would let the write through. Gate writes (and reads) at PHASE_TOOL_CALL with the tool name, path, and content, failing closed on an eval error or an ASK verdict (delegated fs has no elicitation path). Reads keep the result-phase content check that decides whether the read bytes reach the model. 2. Audit records. Stale prior-turn server fs requests were answered at turn start, running real I/O, and then had their ToolCall events cleared before they reached history. Drain those events into history instead of dropping them, so the I/O they performed is recorded. Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com> Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com> * fix(qwen,goose): evaluate result-phase policy after a delegated write The write handlers gated at PHASE_TOOL_CALL and then wrote, but never ran a result-phase evaluation, so the value env.write() returned was never policy checked and the audit record dropped it. Reads already did both phases. Run PHASE_TOOL_RESULT after the write carrying the actual result. A denial records BLOCKED and refuses the response; it cannot undo the write, since it runs after the operation. The success record now carries the real result too, matching the read path. _fs_content_policy_denies was read-specific, so it is now _fs_result_policy_denies and takes any result. Read behavior is unchanged. Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com> Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com> --------- Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com> Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com> Co-authored-by: Enes Yilmaz <115046343+EnesYilmazcode@users.noreply.github.com>
…se policy (omnigent-ai#1222) * feat(qwen,goose): record delegated fs I/O and gate it with result-phase policy Omnigent's OSEnvironment but left two layers as documented follow-ups: the delegated I/O was invisible in history and no content policy ran on it. Wire both onto the existing _handle_fs_read / _handle_fs_write handlers: - emit a paired ToolCallRequest + ToolCallComplete per op so the I/O shows in history (the adapter renders them as observed function_call items) - run PHASE_TOOL_RESULT content policy on the bytes; an explicit deny refuses the op (a write is gated before it happens), failing open otherwise Content-only: the harness policy round-trip carries no request_data, so the payload is {"result": content}. Closes the file-I/O recording / content policy item in docs/QWEN_FOLLOWUPS.md. Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com> Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com> * fix(qwen,goose): gate delegated fs at the call phase and audit stale ops Addresses the review on the delegated-fs recording/policy work. 1. Phase semantics. A delegated write was gated by a result-phase policy eval before the write, which is content-only and fails open, so a policy timeout would let the write through. Gate writes (and reads) at PHASE_TOOL_CALL with the tool name, path, and content, failing closed on an eval error or an ASK verdict (delegated fs has no elicitation path). Reads keep the result-phase content check that decides whether the read bytes reach the model. 2. Audit records. Stale prior-turn server fs requests were answered at turn start, running real I/O, and then had their ToolCall events cleared before they reached history. Drain those events into history instead of dropping them, so the I/O they performed is recorded. Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com> Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com> * fix(qwen,goose): evaluate result-phase policy after a delegated write The write handlers gated at PHASE_TOOL_CALL and then wrote, but never ran a result-phase evaluation, so the value env.write() returned was never policy checked and the audit record dropped it. Reads already did both phases. Run PHASE_TOOL_RESULT after the write carrying the actual result. A denial records BLOCKED and refuses the response; it cannot undo the write, since it runs after the operation. The success record now carries the real result too, matching the read path. _fs_content_policy_denies was read-specific, so it is now _fs_result_policy_denies and takes any result. Read behavior is unchanged. Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com> Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com> --------- Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com> Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com> Co-authored-by: Enes Yilmaz <115046343+EnesYilmazcode@users.noreply.github.com> Co-authored-by: Nick Isaacs <nick.isaacs@datadoghq.com>
…se policy (omnigent-ai#1222) * feat(qwen,goose): record delegated fs I/O and gate it with result-phase policy Omnigent's OSEnvironment but left two layers as documented follow-ups: the delegated I/O was invisible in history and no content policy ran on it. Wire both onto the existing _handle_fs_read / _handle_fs_write handlers: - emit a paired ToolCallRequest + ToolCallComplete per op so the I/O shows in history (the adapter renders them as observed function_call items) - run PHASE_TOOL_RESULT content policy on the bytes; an explicit deny refuses the op (a write is gated before it happens), failing open otherwise Content-only: the harness policy round-trip carries no request_data, so the payload is {"result": content}. Closes the file-I/O recording / content policy item in docs/QWEN_FOLLOWUPS.md. Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com> Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com> * fix(qwen,goose): gate delegated fs at the call phase and audit stale ops Addresses the review on the delegated-fs recording/policy work. 1. Phase semantics. A delegated write was gated by a result-phase policy eval before the write, which is content-only and fails open, so a policy timeout would let the write through. Gate writes (and reads) at PHASE_TOOL_CALL with the tool name, path, and content, failing closed on an eval error or an ASK verdict (delegated fs has no elicitation path). Reads keep the result-phase content check that decides whether the read bytes reach the model. 2. Audit records. Stale prior-turn server fs requests were answered at turn start, running real I/O, and then had their ToolCall events cleared before they reached history. Drain those events into history instead of dropping them, so the I/O they performed is recorded. Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com> Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com> * fix(qwen,goose): evaluate result-phase policy after a delegated write The write handlers gated at PHASE_TOOL_CALL and then wrote, but never ran a result-phase evaluation, so the value env.write() returned was never policy checked and the audit record dropped it. Reads already did both phases. Run PHASE_TOOL_RESULT after the write carrying the actual result. A denial records BLOCKED and refuses the response; it cannot undo the write, since it runs after the operation. The success record now carries the real result too, matching the read path. _fs_content_policy_denies was read-specific, so it is now _fs_result_policy_denies and takes any result. Read behavior is unchanged. Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com> Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com> --------- Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com> Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com>
…se policy (omnigent-ai#1222) * feat(qwen,goose): record delegated fs I/O and gate it with result-phase policy Omnigent's OSEnvironment but left two layers as documented follow-ups: the delegated I/O was invisible in history and no content policy ran on it. Wire both onto the existing _handle_fs_read / _handle_fs_write handlers: - emit a paired ToolCallRequest + ToolCallComplete per op so the I/O shows in history (the adapter renders them as observed function_call items) - run PHASE_TOOL_RESULT content policy on the bytes; an explicit deny refuses the op (a write is gated before it happens), failing open otherwise Content-only: the harness policy round-trip carries no request_data, so the payload is {"result": content}. Closes the file-I/O recording / content policy item in docs/QWEN_FOLLOWUPS.md. Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com> Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com> * fix(qwen,goose): gate delegated fs at the call phase and audit stale ops Addresses the review on the delegated-fs recording/policy work. 1. Phase semantics. A delegated write was gated by a result-phase policy eval before the write, which is content-only and fails open, so a policy timeout would let the write through. Gate writes (and reads) at PHASE_TOOL_CALL with the tool name, path, and content, failing closed on an eval error or an ASK verdict (delegated fs has no elicitation path). Reads keep the result-phase content check that decides whether the read bytes reach the model. 2. Audit records. Stale prior-turn server fs requests were answered at turn start, running real I/O, and then had their ToolCall events cleared before they reached history. Drain those events into history instead of dropping them, so the I/O they performed is recorded. Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com> Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com> * fix(qwen,goose): evaluate result-phase policy after a delegated write The write handlers gated at PHASE_TOOL_CALL and then wrote, but never ran a result-phase evaluation, so the value env.write() returned was never policy checked and the audit record dropped it. Reads already did both phases. Run PHASE_TOOL_RESULT after the write carrying the actual result. A denial records BLOCKED and refuses the response; it cannot undo the write, since it runs after the operation. The success record now carries the real result too, matching the read path. _fs_content_policy_denies was read-specific, so it is now _fs_result_policy_denies and takes any result. Read behavior is unchanged. Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com> Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com> --------- Signed-off-by: Enes Yilmaz <enesyilmaz5157@gmail.com> Signed-off-by: Pat Sukprasert <pat.sukprasert@databricks.com> Co-authored-by: Enes Yilmaz <115046343+EnesYilmazcode@users.noreply.github.com>
Related issue
N/A. This completes the deferred follow-up from #1100, tracked in
docs/QWEN_FOLLOWUPS.md("File I/O recording / content policy"). No separate issue.Summary
#1100 routed the ACP harnesses' file reads/writes back through Omnigent's
OSEnvironmentbut left two layers as documented follow-ups: the delegated I/O was invisible in history and no content policy ran on it. This wires both onto the existing_handle_fs_read/_handle_fs_writehandlers, in qwen and goose:ToolCallRequest+ToolCallCompleteonto the turn stream, which the harness adapter already renders as observedfunction_call/function_call_outputitems. The handlers buffer the records andrun_turndrains them right after dispatching the request; the buffer is reset per turn so a prior turn's ops don't leak in.PHASE_TOOL_RESULTpolicy. An explicitPOLICY_ACTION_DENYrefuses the op (read: the bytes never reach the agent; write: evaluated beforeOSEnvironment.write, so the write never happens). It fails open otherwise (policy unwired, an eval error, or any non-deny verdict), matchingPHASE_TOOL_RESULT's advisory semantics (FAIL_CLOSED_PHASESisPHASE_TOOL_CALLonly). Result policy here gates on content only: the server reads result-phase tool identity fromrequest_data, which the harness policy round-trip doesn't carry, so the payload is{"result": content}(matching the existing producer inrunner/tool_dispatch.py). A real read/write failure recordsERROR, a denial recordsBLOCKED, success recordsSUCCESS; malformed requests still raise before any record.Applied to both ACP harnesses since they share the identical dispatch pattern (same as #1100 and the history-replay change).
docs/QWEN_FOLLOWUPS.mdis updated to check the item off and note the content-only scope.Test Plan
ToolCallRequest+ToolCallCompletewith a sharedcall_id; aPHASE_TOOL_RESULTdeny refuses a read and records itBLOCKED; a deny prevents a write (theOSEnvironment.writeis never called) and recordsBLOCKED;run_turnsurfaces a delegated op's records onto the turn stream.pytest tests/inner/test_qwen_executor.py tests/inner/test_goose_executor.py tests/inner/test_qwen_agent_integration.py— 155 passed.ruff check+ruff format --checkclean; no lock-file changes.e2e_ui/ per-harness e2e suites are gateway-bound and were not run locally.Type of change
Test coverage
Coverage notes
The new unit tests drive the real dispatch (
_respond_to_agent_request) and the realrun_turnloop with a fakeOSEnvironmentand a fake policy evaluator, so they cover both the recording and the deny/fail-open paths without a live qwen/goose binary. Two design calls worth a look: (1) for writes I run result-phase policy beforeOSEnvironment.writeso an explicit deny actually prevents the write. That bends "result phase = after the side effect," but the content is known up front and blocking a not-yet-incurred write is the useful behavior. (2) Result-phase policy is content-only here, because the harness policy round-trip (evaluate_policy(phase, data)) carries norequest_data, so the server can't see the fs tool's name for the result phase. Happy to adjust either if you would rather keep the phase semantics strict or threadrequest_datathrough the round-trip.